---
title: Remote Client API | Letta Docs
description: Connect to Letta Code remote environments over custom clients.
---

This guide documents how developers can build custom clients that connect to Letta Code and execute from anywhere.

Building a custom client is easy and only involves a few steps to create a fully remote command station for your Letta-powered applications.

The Remote Client API works the best with Letta Cloud, to use the Remote Client API with local environments, read this guide

## Primitives

The Remote Client API has three main primitives: **device**, **connection**, and **client**. The device executes work. The client sends commands. The connection is the live WebSocket session that lets them talk through Letta.

| Primitive  | What it is                                                              | Owns                                                                                                           |
| ---------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| Device     | A machine, VM, container, or sandbox running a Letta Code listener.     | Local filesystem, current working directory, git state, permission mode, model/provider state, tool execution. |
| Connection | A live registered WebSocket session for a device.                       | Routing between a connected device and any clients watching or controlling it.                                 |
| Client     | A UI, app server, automation, or agent that controls a device remotely. | Local client state, outgoing commands, request/response matching, stream rendering.                            |

### Device

A **device** is the execution environment. It is where Letta Code runs tools, reads files, changes directories, checks git status, and sends status updates.

Each device has a stable `deviceId`. Generate it once for the device/runtime and reuse it across reconnects. Use a different `deviceId` for a different machine, sandbox, container, or independently routable runtime.

### Connection

A **connection** is a live online session for a device. Letta Code creates a connection when its listener comes online. The `connectionId` is ephemeral: it identifies the current online listener session, not the permanent identity of the device.

Use `connectionId` when a client opens the status WebSocket:

```
/v1/environments/{connectionId}/status/ws
```

### Client

A **client** is anything that controls or observes the remote device. Examples include the Letta Code desktop app, `chat.letta.com`, a custom dashboard, an app server, or an agent.

Clients usually connect to the **status WebSocket**. They send commands such as `sync`, `input`, `update_model`, and `change_device_state`, then apply incoming events to local state.

### Runtime scope

Most commands include a `runtime` object:

```
{
  "runtime": {
    "agent_id": "agent-...",
    "conversation_id": "conv-..."
  }
}
```

`agent_id` selects the persistent Letta agent. `conversation_id` selects the message thread for that agent. The same device can serve different agents or conversations, so include `runtime` on new commands whenever the command applies to a specific conversation.

### Requests, responses, and events

The protocol has two shapes:

- **Request/response commands** include `request_id` and receive a matching `_response` event, such as `list_models` → `list_models_response`.
- **Streaming commands** start work and emit multiple events. For example, `input` with `payload.kind: "create_message"` emits `update_loop_status`, `stream_delta`, approval events, queue events, and errors until the runtime is idle again.

## Quick start

The smallest useful flow is:

1. List online environments and pick a `connectionId`.
2. Open the status WebSocket for that `connectionId`.
3. Send one `input/create_message` command.
4. Listen for `stream_delta` events until the runtime returns to `WAITING_ON_INPUT`.

### 1. List online environments

Letta Code registers devices for you. A custom client only needs to find an online environment and use its `connectionId`.

Terminal window

```
curl -sS 'https://api.letta.com/v1/environments?onlineOnly=true' \
  -H "Authorization: Bearer $LETTA_API_KEY"
```

Response:

```
{
  "connections": [
    {
      "connectionId": "conn-...",
      "deviceId": "device-abc123",
      "connectionName": "Work laptop",
      "currentMode": "standard",
      "metadata": {
        "workingDirectory": "/workspace/project",
        "gitBranch": "main"
      }
    }
  ],
  "hasNextPage": false
}
```

Pick a connection whose `connectionId` is set and whose device is online.

### 2. Open the status WebSocket

```
wss://api.letta.com/v1/environments/{connectionId}/status/ws?agentId={agentId}&conversationId={conversationId}&channel=stream
```

Authenticate with an `Authorization: Bearer <token>` header whenever possible. If you are connecting from a browser, add `token=` to the WebSocket URL instead. Browsers do not let WebSocket clients set custom headers.

On `open`, send a sync so the device replays its current state:

```
{
  "type": "sync",
  "runtime": {
    "agent_id": "agent-...",
    "conversation_id": "conv-..."
  },
  "recover_approvals": true
}
```

Then listen for status snapshots such as:

```
{
  "type": "update_device_status",
  "device_status": {
    "is_online": true,
    "is_processing": false,
    "current_working_directory": "/workspace/project",
    "current_permission_mode": "standard"
  }
}
```

### 3. Send a message

```
{
  "type": "input",
  "runtime": {
    "agent_id": "agent-...",
    "conversation_id": "conv-..."
  },
  "payload": {
    "kind": "create_message",
    "messages": [
      {
        "role": "user",
        "content": "What files should I inspect first?",
        "client_message_id": "cm_01J_demo"
      }
    ],
    "supports_control_response": true
  }
}
```

### 4. Listen for streamed messages

The runtime streams lifecycle and content events back over the same status WebSocket.

Turn started / active:

```
{
  "type": "update_loop_status",
  "loop_status": {
    "status": "PROCESSING_API_RESPONSE",
    "active_run_ids": ["run-..."]
  }
}
```

Content delta:

```
{
  "type": "stream_delta",
  "delta": {
    "message_type": "assistant_message",
    "content": "Start with README.md and apps/cloud-api/src/server.ts..."
  }
}
```

Turn complete / idle:

```
{
  "type": "update_loop_status",
  "loop_status": {
    "status": "WAITING_ON_INPUT",
    "active_run_ids": []
  }
}
```

When you see `WAITING_ON_INPUT`, the turn is idle and ready for the next message.

## Minimal client example

This example assumes a Node/server-side WebSocket client that supports custom upgrade headers. Browser clients should use the query-token fallback described above.

```
const apiBaseUrl = "https://api.letta.com";
const token = process.env.LETTA_API_KEY!;
const connectionId = "conn-...";
const agentId = "agent-...";
const conversationId = "conv-...";


const wsUrl = new URL(
  `/v1/environments/${connectionId}/status/ws`,
  apiBaseUrl.replace(/^http/, "ws"),
);
wsUrl.searchParams.set("agentId", agentId);
wsUrl.searchParams.set("conversationId", conversationId);
wsUrl.searchParams.set("channel", "stream");


const ws = new WebSocket(wsUrl.toString(), {
  headers: {
    Authorization: `Bearer ${token}`,
  },
});


// Browser fallback only: browser WebSocket clients cannot set custom headers.
// In that case, use `wsUrl.searchParams.set('token', token)` instead.


function send(value: unknown) {
  ws.send(JSON.stringify(value));
}


ws.addEventListener("open", () => {
  send({
    type: "sync",
    runtime: { agent_id: agentId, conversation_id: conversationId },
    recover_approvals: true,
  });


  send({
    type: "input",
    runtime: { agent_id: agentId, conversation_id: conversationId },
    payload: {
      kind: "create_message",
      messages: [
        {
          role: "user",
          content: "Hello from a websocket client",
          client_message_id: crypto.randomUUID(),
        },
      ],
      supports_control_response: true,
    },
  });
});


ws.addEventListener("message", (event) => {
  const msg = JSON.parse(event.data);


  if (typeof msg.seq === "number") {
    send({ type: "ack", seq: msg.seq });
  }


  switch (msg.type) {
    case "update_device_status":
      console.log("device status", msg.device_status);
      break;
    case "update_loop_status":
      console.log("loop", msg.loop_status);
      break;
    case "stream_delta":
      console.log("delta", msg.delta);
      break;
    case "error":
      console.error("runtime error", msg);
      break;
  }
});


setInterval(() => {
  if (ws.readyState === WebSocket.OPEN) {
    send({ type: "ping" });
  }
}, 30_000);
```

## Next steps

For message types, request/response conventions, common operations, and event references, see the [Remote Client API Reference](/letta-code/remote-client-api-reference/index.md).
