Skip to content
Letta Code Letta Code Letta Docs
Sign up
Remote Client API

Remote Client API

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 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.

PrimitiveWhat it isOwns
DeviceA machine, VM, container, or sandbox running a Letta Code listener.Local filesystem, current working directory, git state, permission mode, model/provider state, tool execution.
ConnectionA live registered WebSocket session for a device.Routing between a connected device and any clients watching or controlling it.
ClientA UI, app server, automation, or agent that controls a device remotely.Local client state, outgoing commands, request/response matching, stream rendering.

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.

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

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.

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.

The protocol has two shapes:

  • Request/response commands include request_id and receive a matching _response event, such as list_modelslist_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.

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.

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.

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"
}
}
{
"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
}
}

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.

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);

For message types, request/response conventions, common operations, and event references, see the Remote Client API Reference.