|
| 1 | +--- |
| 2 | +title: Run devices and an orchestrating agent on a Device Connect server |
| 3 | +weight: 3 |
| 4 | + |
| 5 | +# FIXED, DO NOT MODIFY |
| 6 | +layout: learningpathall |
| 7 | +--- |
| 8 | + |
| 9 | +In this section you will use the hosted Device Connect service to provision a tenant, commission a simulated device with NATS JWT credentials, and verify it from a Python client. The hosted service runs the messaging router (NATS) and the registry for you, so the only thing you operate is the device itself and the agent that talks to it. |
| 10 | + |
| 11 | +## Mint device and agent credentials from the Device Connect portal |
| 12 | + |
| 13 | +1. Open [`https://portal.deviceconnect.dev/`](https://portal.deviceconnect.dev/) and sign in. Signing in gives you a **tenant** — an isolated namespace on the shared Device Connect server that only the devices and agents you commission against it can join. Nothing from another tenant can see, publish to, or invoke anything in yours. |
| 14 | +2. Open the **My Devices** section. The page header reads `Manage device credentials for tenant <slug>` — the value of `<slug>` is your **tenant slug** (for example, `beta`). The portal uses it to namespace every identity you create, so `sf-robot` on the `beta` tenant becomes `beta-sf-robot`. The NATS server URL is the same for every tenant: `nats://portal.deviceconnect.dev:4222`. |
| 15 | +3. From the same **My Devices** page, add three identities. Type the short names below and the portal prefixes each one with your tenant slug automatically: |
| 16 | + - `sf-robot`: the San Francisco robot arm |
| 17 | + - `tokyo-robot`: the Tokyo robot arm |
| 18 | + - `bangalore-agent`: the orchestrating agent |
| 19 | + |
| 20 | +For each identity, the portal exposes a **Download** button that gives you a NATS JWT credentials file named `<identity>.creds.json`. Treat each `.creds.json` file like a private key: anyone who has it can act as that identity on your tenant. |
| 21 | + |
| 22 | +Export the tenant settings so the rest of the walkthrough can reuse them. |
| 23 | + |
| 24 | +```bash |
| 25 | +export TENANT=<tenant-slug> |
| 26 | +export NATS_URL=nats://portal.deviceconnect.dev:4222 |
| 27 | +export MESSAGING_BACKEND=nats |
| 28 | +``` |
| 29 | + |
| 30 | +Now save the downloaded credentials to a stable directory: |
| 31 | + |
| 32 | +```bash |
| 33 | +mkdir -p ~/.device-connect/credentials |
| 34 | +mv ~/Downloads/${TENANT}-sf-robot.creds.json ~/.device-connect/credentials/ |
| 35 | +mv ~/Downloads/${TENANT}-tokyo-robot.creds.json ~/.device-connect/credentials/ |
| 36 | +mv ~/Downloads/${TENANT}-bangalore-agent.creds.json ~/.device-connect/credentials/ |
| 37 | +chmod 600 ~/.device-connect/credentials/*.creds.json |
| 38 | +``` |
| 39 | + |
| 40 | +The agent-tools `connect()` function reads `TENANT` only when its `zone` argument is left unset, so always call it as `connect(zone=os.environ["TENANT"])` in the Python snippets below. |
| 41 | + |
| 42 | +The portal also exposes a **Coding Agents** tab that can drive this whole step for you — pointing a coding agent (such as Claude Code or Codex) at it will provision identities and download the credentials on your behalf. The manual flow above is what the agent automates, and is useful to walk through once so you understand what the agent is doing. |
| 43 | + |
| 44 | +## Install the SDKs |
| 45 | + |
| 46 | +Create a virtual environment and install the edge runtime and the agent tools: |
| 47 | + |
| 48 | +```bash |
| 49 | +mkdir -p ~/device-connect-fabric && cd ~/device-connect-fabric |
| 50 | +python3 -m venv .venv |
| 51 | +source .venv/bin/activate |
| 52 | +pip install device-connect-edge device-connect-agent-tools |
| 53 | +``` |
| 54 | + |
| 55 | +You do not need `device-connect-server` locally; Fabric runs that for you in this case. If you would rather self-host, you can install it with `pip install device-connect-server` and run the router and registry yourself; see the [device-connect-server README](https://github.com/arm/device-connect/tree/main/packages/device-connect-server) for the Docker Compose deployment options. |
| 56 | + |
| 57 | +## Write a simulated robot-arm driver |
| 58 | + |
| 59 | +Create a file called `robot_arm.py`. The driver pretends to be a 6-DOF robot arm: it exposes RPCs for moving to a target pose and homing, tracks its current position, and emits a `motion_completed` event after every move. |
| 60 | + |
| 61 | +```python |
| 62 | +import argparse |
| 63 | +import asyncio |
| 64 | +import random |
| 65 | + |
| 66 | +from device_connect_edge import DeviceRuntime |
| 67 | +from device_connect_edge.drivers import DeviceDriver, emit, rpc |
| 68 | +from device_connect_edge.types import DeviceIdentity, DeviceStatus |
| 69 | + |
| 70 | + |
| 71 | +class RobotArmDriver(DeviceDriver): |
| 72 | + device_type = "robot_arm" |
| 73 | + |
| 74 | + def __init__(self): |
| 75 | + super().__init__() |
| 76 | + self._position = {"x": 0.0, "y": 0.0, "z": 0.0} |
| 77 | + |
| 78 | + @property |
| 79 | + def identity(self) -> DeviceIdentity: |
| 80 | + return DeviceIdentity( |
| 81 | + device_type="robot_arm", |
| 82 | + manufacturer="Device Connect", |
| 83 | + model="SIM-ARM-6DOF", |
| 84 | + description="Simulated 6-DOF robot arm", |
| 85 | + ) |
| 86 | + |
| 87 | + @property |
| 88 | + def status(self) -> DeviceStatus: |
| 89 | + return DeviceStatus(availability="available", location="simulator") |
| 90 | + |
| 91 | + @rpc() |
| 92 | + async def move_to(self, x: float, y: float, z: float) -> dict: |
| 93 | + # pretend to physically traverse to the target |
| 94 | + await asyncio.sleep(random.uniform(0.2, 0.6)) |
| 95 | + self._position = {"x": x, "y": y, "z": z} |
| 96 | + await self.motion_completed(target=self._position) |
| 97 | + return {"position": self._position} |
| 98 | + |
| 99 | + @rpc() |
| 100 | + async def home(self) -> dict: |
| 101 | + return await self.move_to(0.0, 0.0, 0.0) |
| 102 | + |
| 103 | + @rpc() |
| 104 | + async def get_position(self) -> dict: |
| 105 | + return self._position |
| 106 | + |
| 107 | + @emit() |
| 108 | + async def motion_completed(self, target: dict): |
| 109 | + return None |
| 110 | + |
| 111 | + |
| 112 | +async def main(): |
| 113 | + parser = argparse.ArgumentParser() |
| 114 | + parser.add_argument("--device-id", required=True) |
| 115 | + args = parser.parse_args() |
| 116 | + |
| 117 | + runtime = DeviceRuntime(driver=RobotArmDriver(), device_id=args.device_id) |
| 118 | + await runtime.run() |
| 119 | + |
| 120 | + |
| 121 | +if __name__ == "__main__": |
| 122 | + asyncio.run(main()) |
| 123 | +``` |
| 124 | + |
| 125 | +Note that there is no `allow_insecure=True` on the runtime. The runtime will only join the mesh if it has valid credentials, which is what makes commissioning meaningful. |
| 126 | + |
| 127 | +## Connect the device to the server |
| 128 | + |
| 129 | +Point the runtime at the Fabric NATS server and at the device's `.creds.json` file. The combination of `NATS_URL` and `NATS_CREDENTIALS_FILE` is what turns the device from "an unauthenticated process" into "a commissioned member of your tenant": |
| 130 | + |
| 131 | +```bash |
| 132 | +NATS_URL=$NATS_URL \ |
| 133 | + NATS_CREDENTIALS_FILE=~/.device-connect/credentials/${TENANT}-sf-robot.creds.json \ |
| 134 | + python robot_arm.py --device-id ${TENANT}-sf-robot |
| 135 | +``` |
| 136 | + |
| 137 | +On startup, the runtime presents its JWT to NATS, NATS verifies it against your tenant's signing key, and the device is allowed to publish, subscribe, and register itself. From this moment it shows up in the portal under your tenant, with its identity, capabilities, and live status. |
| 138 | + |
| 139 | +The `--device-id` you pass on the command line must match the identity inside the credentials file (so `${TENANT}-sf-robot.creds.json` requires `--device-id ${TENANT}-sf-robot`). |
| 140 | + |
| 141 | +In a second terminal, do the same for the Tokyo arm: |
| 142 | + |
| 143 | +```bash |
| 144 | +source ~/device-connect-fabric/.venv/bin/activate |
| 145 | +NATS_URL=$NATS_URL \ |
| 146 | + NATS_CREDENTIALS_FILE=~/.device-connect/credentials/${TENANT}-tokyo-robot.creds.json \ |
| 147 | + python robot_arm.py --device-id ${TENANT}-tokyo-robot |
| 148 | +``` |
| 149 | + |
| 150 | +You now have two commissioned devices on your tenant. |
| 151 | + |
| 152 | +## Discover and invoke from Python |
| 153 | + |
| 154 | +Open a third terminal and run a short client using the agent's credentials. This is the same `device-connect-agent-tools` API you would use from a Strands or LangChain agent. Agents authenticate to the same tenant in the same way devices do, just with a different `.creds.json` file: |
| 155 | + |
| 156 | +```bash |
| 157 | +source ~/device-connect-fabric/.venv/bin/activate |
| 158 | +NATS_URL=$NATS_URL \ |
| 159 | + MESSAGING_BACKEND=nats \ |
| 160 | + TENANT=$TENANT \ |
| 161 | + NATS_CREDENTIALS_FILE=~/.device-connect/credentials/${TENANT}-bangalore-agent.creds.json \ |
| 162 | + python - <<'PY' |
| 163 | +import os |
| 164 | +from device_connect_agent_tools import connect, discover_devices, invoke_device |
| 165 | +
|
| 166 | +tenant = os.environ["TENANT"] |
| 167 | +connect(zone=tenant) |
| 168 | +
|
| 169 | +devices = discover_devices() |
| 170 | +print(f"Found {len(devices)} device(s) on tenant") |
| 171 | +for d in devices: |
| 172 | + print(f" {d['device_id']:24} {d['device_type']:20} {d.get('status', {}).get('availability', '?')}") |
| 173 | +
|
| 174 | +# Drive both arms through the server. The server routes each call to the right device. |
| 175 | +for d in devices: |
| 176 | + print(f"\nhome on {d['device_id']}:") |
| 177 | + print(invoke_device(d['device_id'], 'home')) |
| 178 | +
|
| 179 | +print(f"\nmove {tenant}-sf-robot to (0.2, 0.1, 0.3):") |
| 180 | +print(invoke_device(f'{tenant}-sf-robot', 'move_to', x=0.2, y=0.1, z=0.3)) |
| 181 | +
|
| 182 | +print(f"\nposition of {tenant}-tokyo-robot:") |
| 183 | +print(invoke_device(f'{tenant}-tokyo-robot', 'get_position')) |
| 184 | +PY |
| 185 | +``` |
| 186 | + |
| 187 | +You should see both `sf-robot` and `tokyo-robot` listed, then each one home, then the SF arm move, then Tokyo's position read. The agent never needs to know which network the arms are on; it only needs the tenant's NATS URL and its own credentials. |
| 188 | + |
| 189 | +## (Optional) Attach a Strands AI agent |
| 190 | + |
| 191 | +`device-connect-agent-tools` ships an adapter that turns the same Device Connect mesh into a Strands tool surface: |
| 192 | + |
| 193 | +```bash |
| 194 | +pip install "device-connect-agent-tools[strands]" |
| 195 | +``` |
| 196 | + |
| 197 | +```python |
| 198 | +import asyncio |
| 199 | +from device_connect_agent_tools.adapters.strands_agent import StrandsDeviceConnectAgent |
| 200 | + |
| 201 | +async def main(): |
| 202 | + agent = StrandsDeviceConnectAgent( |
| 203 | + goal="Coordinate the two robot arms: home them, then plan and execute moves on user request", |
| 204 | + model_id="claude-sonnet-4-20250514", |
| 205 | + ) |
| 206 | + async with agent: |
| 207 | + await agent.run() |
| 208 | + |
| 209 | +asyncio.run(main()) |
| 210 | +``` |
| 211 | + |
| 212 | +Run it with the agent's credentials and an Anthropic API key: |
| 213 | + |
| 214 | +```bash |
| 215 | +NATS_URL=$NATS_URL \ |
| 216 | + NATS_CREDENTIALS_FILE=~/.device-connect/credentials/${TENANT}-bangalore-agent.creds.json \ |
| 217 | + TENANT=$TENANT \ |
| 218 | + ANTHROPIC_API_KEY="sk-ant-..." \ |
| 219 | + python my_agent.py |
| 220 | +``` |
| 221 | + |
| 222 | +The agent subscribes to events on your tenant, batches them over a short window, and prompts Claude to react. Claude can call back into devices using `invoke_device()` and `get_device_status()`. |
| 223 | + |
| 224 | +## Tear down |
| 225 | + |
| 226 | +Stop the running terminals with `Ctrl-C`. Your tenant and credentials remain valid until you revoke them from the portal, so there is nothing on your machine to clean up beyond the virtual environment: |
| 227 | + |
| 228 | +```bash |
| 229 | +deactivate |
| 230 | +rm -rf ~/device-connect-fabric/.venv |
| 231 | +``` |
| 232 | + |
| 233 | +To rotate credentials, regenerate them from the portal and replace the `.creds.json` files. Old credentials are revoked the moment new ones are issued. |
| 234 | + |
| 235 | +## What you've built |
| 236 | + |
| 237 | +You now have a working Device Connect deployment with a hosted server in the loop: |
| 238 | + |
| 239 | +- a hosted NATS router and persistent registry on your tenant |
| 240 | +- two commissioned simulated robot arms, each authenticated by its own JWT credential |
| 241 | +- a Python client using `device-connect-agent-tools` to discover the arms and drive them through the server |
| 242 | +- (optionally) a Strands agent coordinating both arms over the same mesh |
| 243 | + |
| 244 | +This is the same shape of deployment you would use to put real robot arms, conveyors, cameras, or actuators on the mesh; only the driver code and the credential names change. |
0 commit comments