# Hosts relay (/docs/sessions/hosts)

Run a shell command or read a file on your desktop from your phone, a browser or a script — no SSH, no inbound port, same account only.

The **hosts relay** lets any client of yours execute a small, fixed vocabulary of operations on one of your desktops that has the codai desktop app open. The desktop keeps an outbound SSE connection to the gateway and announces itself as a **host**; a requester `POST`s an `exec` and the gateway relays it, then relays the answer back. Nothing is persisted, nothing listens on the desktop, and in v1 **host and requester must belong to the same user**.

```text
   phone / browser / script                 gateway                       desktop (host)
   ──────────────────────                 ─────────                      ───────────────
   POST /v1/hosts/:dev/exec  ───────▶  rendezvous by device id  ─────▶  event: exec  (over its SSE)
        { op, args }                                                      runs the op locally
   200 { ok, result }        ◀───────  rendezvous by req_id     ◀─────  POST /v1/hosts/exec/:reqId/result
```

This surface is served by `https://ai.codai.ro` and is **not** part of the published Shared Sessions v1 spec; treat it as *remote sessions v1*.

## Which desktops are online [#which-desktops-are-online]

```bash
curl https://ai.codai.ro/v1/hosts -H "Authorization: Bearer $CODAI_API_KEY"
```

```json
{
  "hosts": [
    {
      "device_id": "1c2f…-uuid",
      "name": "Work laptop",
      "platform": "desktop",
      "os": "windows",
      "hostname": "DRAGOS-PC",
      "roots": ["E:\\gh\\codai", "C:\\Users\\dragos\\Documents"],
      "caps": ["pty"],
      "environment_id": "7c1e2d3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f",
      "since": 1790160000000,
      "last_seen": 1790160045000
    }
  ]
}
```

Only your own devices appear, most recently seen first. `since` and `last_seen` are epoch milliseconds; a host drops off the list 45 seconds after its last heartbeat. `roots` are the directories the desktop will let file operations touch. `caps` lists what the host offers beyond the basic ops (`pty` = a real terminal, `iphone` + `video` = the [iPhone bridge](../apps/iphone-remote.mdx)), and `environment_id` is the environment that serves its terminal or live screen over codai-relay (`null` when none).

## Run something on a host [#run-something-on-a-host]

`POST /v1/hosts/:deviceId/exec` — needs your key **and** an `x-codai-device` header identifying the requester.

<TypeTable
  type={{
    op: { type: "'shell' | 'fs_read' | 'fs_write' | 'fs_list' | 'fs_roots' | 'info' | 'iphone'", description: 'The operation. Fixed vocabulary; see below.', required: true },
    args: { type: 'object', description: 'Per-op arguments.', default: '{}' },
    timeout_ms: { type: 'integer', description: 'How long to wait for the host, 1 000 – 600 000. The gateway waits this plus 2 s before giving up.', default: '60000' },
    label: { type: 'string', description: 'Free-form label shown in the desktop UI, ≤ 200 chars — e.g. "session grp8 · terminal".' },
  }}
/>

<Tabs items={['curl', 'TypeScript', 'Python']}>
<Tab value="curl">

```bash
curl https://ai.codai.ro/v1/hosts/1c2f…/exec \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "x-codai-device: $(uuidgen)" -H "x-codai-device-platform: cli" \
  -H "Content-Type: application/json" \
  -d '{ "op": "shell", "args": { "cmd": "git status --short", "cwd": "E:\\gh\\codai" }, "timeout_ms": 30000, "label": "docs check" }'
```

</Tab>
<Tab value="TypeScript">

```ts
const res = await fetch(`https://ai.codai.ro/v1/hosts/${hostId}/exec`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.CODAI_API_KEY}`,
    'x-codai-device': myDeviceId,
    'x-codai-device-platform': 'web',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ op: 'shell', args: { cmd: 'git status --short', cwd: 'E:\\gh\\codai' }, timeout_ms: 30000 }),
});
const { ok, result, error } = (await res.json()) as {
  ok: boolean;
  result: { code: number; stdout: string; stderr: string } | null;
  error: string | null;
};
```

</Tab>
<Tab value="Python">

```python
r = requests.post(
    f"https://ai.codai.ro/v1/hosts/{host_id}/exec",
    headers={"Authorization": f"Bearer {os.environ['CODAI_API_KEY']}", "x-codai-device": my_device_id},
    json={"op": "shell", "args": {"cmd": "git status --short", "cwd": "E:\\gh\\codai"}, "timeout_ms": 30000},
    timeout=40,
)
body = r.json()
print(body["ok"], body["result"]["stdout"] if body["ok"] else body["error"])
```

</Tab>
</Tabs>

Response — the host's answer, verbatim:

```json
{
  "req_id": "c0de…-uuid",
  "ok": true,
  "result": { "code": 0, "stdout": " M apps/docs/content/docs/en/sessions/hosts.mdx\n", "stderr": "", "truncated": false, "timed_out": false, "duration_ms": 412 },
  "error": null
}
```

A host-side failure — a path outside `roots`, a bad argument, a command that could not start — is still HTTP `200` with `ok: false` and `error` set. Only relay problems are HTTP errors:

| Status | `code` | When |
| --- | --- | --- |
| `400` | `bad_request` | Missing `x-codai-device`, unknown `op`, `timeout_ms` out of range, `iphone` args that are not `{ tool, args? }`. |
| `404` | `not_found` | `deviceId` is not one of your devices (the gateway never reveals other users' device ids). |
| `409` | `host_offline` | It is your device but it is not connected as a host right now — *open codai desktop with remote host on, or start the iPhone bridge*. |
| `504` | `host_timeout` | The host did not answer within `timeout_ms` — *host did not answer within 30000 ms*. |

## Operations [#operations]

The desktop implements each op with its own permission-checked commands; file paths must fall under the host's `roots`.

| `op` | `args` | `result` |
| --- | --- | --- |
| `shell` | `{ cmd: string, cwd?: string, timeout_ms?: number }` — PowerShell on Windows, the login shell elsewhere; `timeout_ms` is clamped to the request's. | `{ code, stdout, stderr, truncated, timed_out, duration_ms }` |
| `fs_read` | `{ path: string, max_bytes?: integer }` — default 64 KB, max 512 KB. | `{ path, content, size, truncated }` |
| `fs_write` | `{ path: string, content: string }` | `{ path, bytes, created }` |
| `fs_list` | `{ path: string }` | `{ path, entries: [{ name, kind, size }], truncated }` |
| `fs_roots` | `{}` | `{ roots: string[] }` |
| `info` | `{}` | `{ os: 'windows' \| 'linux' \| 'macos', hostname, roots, platform: 'desktop' }` |
| `iphone` | `{ tool: string, args?: object }` — only on hosts with cap `iphone`; `tool` matches `^[a-z_]{1,64}$` (checked by the gateway). See [Control an iPhone remotely](../apps/iphone-remote.mdx). | `{ data, image? }` — `image` is an optional `data:image/jpeg;base64,…` screenshot (≤ 1.5 MB, whole result ≤ 2 MB). |

Hosts with cap `pty` (codai desktop with *Serve as remote host*) also open a **real terminal**: the desktop enrols its own BYO environment once and serves `pty` streams over codai-relay; open them with the environment id from `environment_id`, exactly like an environment terminal.

<Callout type="warn">
`shell` runs with the desktop user's privileges. The relay adds no sandbox of its own — the desktop app's autonomy setting and rules gate what it will execute, and every request shows up in its UI with your `label`. Keep API keys that can reach `/v1/hosts` as tightly scoped as any SSH key.
</Callout>

## Building a host [#building-a-host]

You only need this section if you are writing your own host (the codai desktop app already is one).

<Steps>
<Step>

### Announce yourself [#announce-yourself]

Open `GET /v1/hosts/stream` with your key and the host's own `x-codai-device`. Describe the machine in the query string (a GET has no body): `?os=windows&hostname=DRAGOS-PC&roots=E:\gh\codai,C:\Users\dragos\Documents` — `os` ≤ 40 chars, `hostname` ≤ 120, up to 32 roots. Add `caps=` (comma list, ≤ 16, each `^[a-z0-9_-]{1,32}$`) for what you offer and `env=<environment uuid>` when an environment serves your `video` / `pty` streams; a malformed `caps` or `env` is a `400`.

The first frame is `hello`, then a `ping` every 15 s; each ping refreshes your presence (TTL 45 s). The server closes the stream after 6 hours — reconnect with backoff (the desktop uses 1 s → 30 s).

```text
event: hello
data: {"device_id":"1c2f…","heartbeat_ms":15000}

event: ping
data: {"t":1790160045000}

event: exec
data: {"req_id":"c0de…","op":"shell","args":{"cmd":"git status --short","cwd":"E:\\gh\\codai"},"timeout_ms":30000,"label":"docs check","from_device_id":"9a0b…","ts":1790160050000}
```

</Step>
<Step>

### Execute and reply [#execute-and-reply]

For every `exec` frame, run the op and `POST /v1/hosts/exec/:reqId/result` with `{ "ok": boolean, "result"?: any, "error"?: string ≤ 4000 }`. The body must stay under 2 MB, and `reqId` must be the 36-character UUID you received. A missing or late reply simply lets the requester's `timeout_ms` expire.

```bash
curl -X POST https://ai.codai.ro/v1/hosts/exec/c0de…/result \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "x-codai-device: 1c2f…" \
  -H "Content-Type: application/json" \
  -d '{ "ok": true, "result": { "code": 0, "stdout": "…", "stderr": "", "truncated": false, "timed_out": false, "duration_ms": 412 } }'
```

The `req_id` is an unguessable capability handed only to you; the gateway matches the reply to the waiting requester by it and answers `{ "ok": true }`.

</Step>
<Step>

### Enforce your own roots [#enforce-your-own-roots]

The gateway relays; **you** decide what runs. Refuse paths outside your advertised `roots`, clamp `shell` timeouts to the request's `timeout_ms`, cap output sizes (set `truncated: true`), and surface every request in your UI with its `label` and `from_device_id`.

</Step>
</Steps>
