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.
Primitives
Section titled “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
Section titled “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
Section titled “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/wsClient
Section titled “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
Section titled “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
Section titled “Requests, responses, and events”The protocol has two shapes:
- Request/response commands include
request_idand receive a matching_responseevent, such aslist_models→list_models_response. - Streaming commands start work and emit multiple events. For example,
inputwithpayload.kind: "create_message"emitsupdate_loop_status,stream_delta, approval events, queue events, and errors until the runtime is idle again.
Quick start
Section titled “Quick start”The smallest useful flow is:
- List online environments and pick a
connectionId. - Open the status WebSocket for that
connectionId. - Send one
input/create_messagecommand. - Listen for
stream_deltaevents until the runtime returns toWAITING_ON_INPUT.
1. List online environments
Section titled “1. List online environments”Letta Code registers devices for you. A custom client only needs to find an
online environment and use its connectionId.
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
Section titled “2. Open the status WebSocket”wss://api.letta.com/v1/environments/{connectionId}/status/ws?agentId={agentId}&conversationId={conversationId}&channel=streamAuthenticate 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
Section titled “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
Section titled “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
Section titled “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
Section titled “Next steps”For message types, request/response conventions, common operations, and event references, see the Remote Client API Reference.