> For the complete documentation index, see [llms.txt](https://docs.anthriq.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.anthriq.com/bxi-studio/developer/overview/concepts/architecture.md).

# How the SDK talks to a device

The SDK never touches hardware. A C++ bridge process owns the device transport, and the SDK drives that bridge over ZeroMQ. Almost every rule on the task pages — why connecting takes two calls, why shutdown leaves a process running, why a wrong endpoint times out instead of failing — follows from that one fact.

```mermaid
flowchart LR
  subgraph yours["your process"]
    sdk["SDK<br/>(DEALER)"]
  end
  subgraph detached["shared, detached"]
    bridge["C++ bridge<br/>(ROUTER)"]
  end
  subgraph hardware["hardware"]
    firmware["device<br/>firmware"]
  end

  sdk -- "ZeroMQ IPC" --> bridge
  bridge -- "WebSocket" --> firmware
```

The SDK opens two IPC sockets onto the bridge:

| Socket  | Endpoint                              | Carries                             |
| ------- | ------------------------------------- | ----------------------------------- |
| Request | `ipc://<tmp>/bxi-interface.sock`      | Operations and their replies        |
| Log     | `ipc://<tmp>/bxi-interface-logs.sock` | Log lines forwarded from the bridge |

The bridge is a separate process with its own lifetime. It outlives your script, and it is shared with every other client on the machine.

## Why the bridge is separate

The bridge holds the WebSocket session, the device's protocol state, and the decode path for binary stream frames. Keeping it out of process buys three things:

* **Language independence.** The Node.js and Python SDKs are thin clients over one implementation, which is why they behave identically rather than approximately.
* **Survival across restarts.** Restarting a script reattaches to a live device session instead of renegotiating one.
* **Shared access.** Several processes can read one device without each opening a transport to it.

The cost is that process lifecycle becomes your problem, which is what the next section is about.

## What the daemon's lifetime implies

One bridge daemon serves one device. It is keyed by device type, spawned on first use, and it exits on its own **60 seconds** after the last client detaches.

Three consequences that surprise people:

**Shutting down your client does not stop the bridge.** It detaches. Another process may still be attached, so terminating the daemon would cut that process off mid-stream. If you need the process gone — in a test teardown, say — terminate it explicitly rather than relying on `shutdown()`.

**Pointing at a different device replaces the daemon.** The C++ device stub fixes its WebSocket URL when constructed and has no reconfigure call, so a daemon started for one device physically cannot serve another. The SDK records the URL in a sidecar file next to the daemon's PID file and compares on every connect; a mismatch means kill and respawn. Without that check, a second client would silently drive the first client's device.

**One daemon per device, not per client.** Two clients of the same device type share a daemon by default. Driving two devices at once therefore means two daemons, which is what `instanceId` produces. See [Install and connect](/bxi-studio/developer/overview/get-started/connect.md).

## Why connecting is two calls

`initialize()` reaches the *bridge*. `connect()` reaches the *device through it*. They are separate because the bridge can be healthy while the device is not.

`initialize()` does four things in order, and a failure in any of them surfaces differently:

| Step                            | Fails as                           |
| ------------------------------- | ---------------------------------- |
| Locate the bridge executable    | `BridgeNotFoundError`, immediately |
| Spawn or attach to the daemon   | Spawn error naming the path        |
| Open the ZeroMQ socket          | Connection error                   |
| Query `system.get_capabilities` | **Timeout**                        |

That last row is the one worth remembering. Once the socket is open, an unreachable peer produces a timeout rather than a refusal, because ZeroMQ queues messages to an absent peer instead of erroring. A capabilities query that hangs usually means the bridge is not actually listening on the socket you connected to.

Between the two calls the SDK knows what the device *can* do but cannot yet ask it to do anything, so operations in that window fail with a device-not-connected error.

## Why the SDK has no feature list

During `initialize()` the SDK asks the device what it supports, and validates every later call against the answer. Nothing about a device's features is compiled into the SDK.

{% tabs %}
{% tab title="Node.js" %}

```typescript
const capabilities = client.getCachedCapabilities();

for (const [name, feature] of Object.entries(capabilities.features)) {
  console.log(name, feature.operations);
}
```

{% endtab %}

{% tab title="Python" %}

```python
capabilities = client.get_cached_capabilities()

for name, feature in capabilities.features.items():
    print(name, feature.operations)
```

{% endtab %}
{% endtabs %}

Each feature carries three parts, and the third is the one people miss:

| Part         | Holds                                 | Example                    |
| ------------ | ------------------------------------- | -------------------------- |
| `operations` | What the feature accepts              | `read`, `write`            |
| `limits`     | Device-declared bounds                | maximum concurrent streams |
| `metadata`   | Device-declared values the SDK *uses* | contact quality thresholds |

`metadata` is how a device parameterises SDK behaviour rather than the SDK assuming it. The thresholds separating good electrode contact from poor come from the device, so a firmware revision can move them without an SDK release. See [Impedance measurement](/bxi-studio/developer/overview/concepts/impedance.md).

Two practical consequences: an operation the device does not declare is rejected **locally**, with the list of what is available, so a typo costs nothing; and code that branches on capabilities keeps working when a device gains a feature, while code that branches on device type does not.

## How replies find their caller

Every request carries a generated `transactionId`; the reply echoes it. The SDK keeps a table of pending requests keyed by that id, so many requests can be in flight on one socket and replies can arrive in any order.

{% tabs %}
{% tab title="Node.js" %}

```typescript
const [synap, nerv, motor] = await Promise.all([
  client.features.executeOperation("registers", "read", { type: "synap", synap_id: 0 }),
  client.features.executeOperation("registers", "read", { type: "nerv", nerv_id: 0 }),
  client.features.executeOperation("motors", "read", { motor_ids: [0] }),
]);
```

{% endtab %}

{% tab title="Python" %}

```python
synap, nerv, motor = await asyncio.gather(
    client.execute_operation("registers", "read", {"type": "synap", "synap_id": 0}),
    client.execute_operation("registers", "read", {"type": "nerv", "nerv_id": 0}),
    client.execute_operation("motors", "read", {"motor_ids": [0]}),
)
```

{% endtab %}
{% endtabs %}

Subscriptions use the same table with one difference: the transaction stays open, and every message bearing that id is routed to the callback instead of resolving and clearing the entry. That is why a subscription needs an explicit unsubscribe, and why a forgotten one keeps delivering.

Each pending request carries its own timer. On disconnect the table is emptied and every waiting caller is failed, so nothing is left awaiting a reply that can no longer arrive.

## Where this shows up

| Behaviour                                       | Because                                     |
| ----------------------------------------------- | ------------------------------------------- |
| Connecting takes two calls                      | The bridge and the device are separate hops |
| `shutdown()` leaves a process running           | The daemon is shared                        |
| Connecting to another device restarts something | A stub's URL is fixed at construction       |
| A wrong endpoint times out, not refuses         | ZeroMQ queues to an absent peer             |
| An unsupported operation fails instantly        | Validated locally against capabilities      |
| Subscriptions must be closed explicitly         | Their transaction never resolves            |

## Next steps

* [Device support](/bxi-studio/developer/overview/concepts/device-support.md)
* [Stream lifecycle](/bxi-studio/developer/overview/concepts/streams.md)
* [Install and connect](/bxi-studio/developer/overview/get-started/connect.md)
