Skip to content
Discord
Platform
App server

Protocol lifecycle

Understand the Letta App Server WebSocket connection, runtime_start, input, sync, abort, and event handling

The Letta App Server uses one bidirectional WebSocket. Every command, response, and event is a JSON object with a type field. Commands that expect a direct response include a request_id; runtime events include sequencing and idempotency metadata.

Start App Server:

Terminal window
letta server --listen ws://127.0.0.1:4500

The process prints one WebSocket endpoint:

Listening on ws://127.0.0.1:4500
WebSocket: ws://127.0.0.1:4500/ws

Open one connection to /ws. Send every command and receive every response, approval, tool callback, streamed delta, and state update on that socket. App Server rejects the legacy ?channel=control and ?channel=stream layout with HTTP 426.

Loopback listeners do not require authentication. A non-loopback listener refuses to start unless you configure --ws-auth capability-token or --ws-auth signed-bearer-token; native clients send the credential as Authorization: Bearer ... during the WebSocket upgrade.

App Server accepts ws:// listen URLs. Terminate TLS at a reverse proxy when clients connect over wss://. The standard browser WebSocket API cannot set the required Authorization header, so authenticated browser applications need a trusted backend or WebSocket proxy.

HTTP requests carrying an Origin header are rejected. An Origin-bearing WebSocket upgrade is accepted only when App Server authentication is configured and the native client also sends valid authorization.

The server exposes these unauthenticated, no-origin health probes:

EndpointResult
GET /readyz200 OK when the listener accepts connections
GET /healthz200 OK while the process is healthy

Before relying on optional protocol features, read App Server’s version and capabilities over HTTP:

GET /app-server-info
Authorization: Bearer <token>

When authentication is enabled, this endpoint requires the same bearer token as the WebSocket. You can also request the information after connecting:

{ "type": "app_server_info", "request_id": "info-1" }

The app_server_info_response includes the Letta Code version, numeric protocol version, active backend, and capability flags such as runtime_start and split_channels. Current App Server versions report split_channels: false.

App Server accepts multiple concurrent WebSocket clients. Each connection has independent request correlation, runtime subscriptions, event sequencing, external tools, approvals, and terminal processes.

runtime_start subscribes the sending connection to the returned {agent_id, conversation_id} scope. One connection can subscribe to multiple runtimes, and multiple connections can subscribe to the same runtime. Runtime-scoped events are delivered only to subscribed connections; disconnecting one client does not close the others.

Send runtime_start before sending turns. It resolves the agent and conversation, applies runtime state, registers external tools, and replays state.

runtime_start
{
"type": "runtime_start",
"request_id": "runtime-1",
"agent_id": "agent-123",
"conversation_id": "conv-123",
"cwd": "/Users/me/project",
"mode": "standard",
"client_info": {
"name": "my_app",
"title": "My App",
"version": "0.1.0"
},
"recover_approvals": true,
"force_device_status": true
}

The response contains the canonical runtime scope:

runtime_start_response
{
"type": "runtime_start_response",
"request_id": "runtime-1",
"success": true,
"runtime": {
"agent_id": "agent-123",
"conversation_id": "conv-123"
},
"created": {
"agent": false,
"conversation": false
},
"agent": {},
"conversation": {}
}

Use the returned runtime for all scoped commands. Do not reconstruct it from assumptions if App Server created either object.

runtime_start accepts exactly one of agent_id or create_agent.

Create an agent
{
"type": "runtime_start",
"request_id": "runtime-1",
"create_agent": {
"body": {
"name": "Build Agent",
"memory_blocks": []
},
"pin_global": true
},
"create_conversation": {
"body": {}
}
}

Pass an existing conversation_id to resume that conversation. If you omit it, App Server creates a conversation for the resolved agent; create_conversation.body optionally supplies its creation fields. The special conversation ID default selects the agent’s virtual default conversation.

FieldDirectionMeaning
cwdruntime_start, change_device_stateWorking directory for local tools. null resets to the listener boot directory.
moderuntime_start, change_device_statePermission mode for local tool execution.
skill_sourcesruntime_startSkill sources available to the resolved runtime.
recover_approvalsruntime_start, syncProbe backend state for stale pending approvals. Defaults to true.
force_device_statusruntime_start, syncForce a device status replay even if the cached status did not change.
client_inforuntime_startClient metadata for diagnostics and future negotiation.
external_toolsruntime_startController-owned tools registered for this connection and runtime.

Valid permission modes are standard, acceptEdits, unrestricted, and strict.

Use input with payload.kind: "create_message" to send a user turn.

input create_message
{
"type": "input",
"runtime": {
"agent_id": "agent-123",
"conversation_id": "conv-123"
},
"payload": {
"kind": "create_message",
"messages": [
{
"role": "user",
"content": "Inspect the auth flow and report risks.",
"client_message_id": "client-msg-1"
}
],
"client_tool_allowlist": ["Read", "Grep", "Glob"]
}
}

client_message_id is optional, but useful for UI deduplication and local optimistic rows.

client_tool_allowlist narrows the locally executed client tools for this turn. Omit it to use the runtime’s normal toolset. Pass an empty array to expose no client tools for the turn.

Use external_tool_scope_ids to expose scoped controller tools registered by runtime_start. Set exclude_interactive_tools: true for headless clients that cannot surface mid-turn questions to a person.

Use input with payload.kind: "approval_response" to answer a pending approval request.

Allow a tool call
{
"type": "input",
"runtime": {
"agent_id": "agent-123",
"conversation_id": "conv-123"
},
"payload": {
"kind": "approval_response",
"request_id": "approval-123",
"decision": {
"behavior": "allow",
"message": "Approved by controller"
}
}
}
Deny a tool call
{
"type": "input",
"runtime": {
"agent_id": "agent-123",
"conversation_id": "conv-123"
},
"payload": {
"kind": "approval_response",
"request_id": "approval-123",
"decision": {
"behavior": "deny",
"message": "Do not modify production files"
}
}
}

The primary turn event is stream_delta:

stream_delta
{
"type": "stream_delta",
"runtime": {
"agent_id": "agent-123",
"conversation_id": "conv-123"
},
"event_seq": 42,
"emitted_at": "2026-07-31T18:00:00.000Z",
"idempotency_key": "stream_delta:42:550e8400-e29b-41d4-a716-446655440000",
"delta": {
"id": "msg-1",
"date": "2026-06-17T00:00:00.000Z",
"message_type": "status",
"message": "Reading files",
"level": "info"
}
}

A turn is complete when a stream_delta carries delta.message_type: "stop_reason", except for stop_reason: "requires_approval". Approval is a continuation boundary: keep the runtime active, render the matching control_request, and send an approval response. Treat loop_error and error_message deltas as failures.

Also handle runtime and controller events:

EventUse
control_requestPending permission request
external_tool_call_requestController-owned tool callback
update_loop_statusActive run IDs and waiting/running state
update_device_statusRuntime availability, cwd, mode, and status snapshots
update_queueFull turn queue snapshot
update_subagent_stateSubagent state snapshots
stream_deltaAgent output and lifecycle deltas

All events arrive on the same WebSocket. Route scoped events by runtime, correlate direct responses by request_id, and process increasing event_seq values per connection when present. Use idempotency_key to deduplicate replayed or retried events. Preserve unknown fields and event types so clients remain forward-compatible.

A successful runtime_start automatically replays the subscribed runtime’s current state after its response. Use sync on an established connection when a UI needs another replay or explicit pending-approval recovery.

sync
{
"type": "sync",
"request_id": "sync-1",
"runtime": {
"agent_id": "agent-123",
"conversation_id": "conv-123"
},
"recover_approvals": false,
"force_device_status": true
}

The replayed state arrives as normal events. App Server sends sync_response after the replay succeeds or returns an error if the replay fails.

sync_response
{
"type": "sync_response",
"request_id": "sync-1",
"runtime": {
"agent_id": "agent-123",
"conversation_id": "conv-123"
},
"success": true
}

After transport loss, open a new WebSocket and send runtime_start again to restore subscriptions and connection-owned external tools. Disconnect cleanup removes that connection’s pending approvals, tool callbacks, terminals, and queued input. If no other subscribed client can take over an active runtime, App Server requests cancellation of its active turn.

Use abort_message to stop active work or interrupt a pending approval.

abort_message
{
"type": "abort_message",
"request_id": "abort-1",
"runtime": {
"agent_id": "agent-123",
"conversation_id": "conv-123"
}
}
abort_message_response
{
"type": "abort_message_response",
"request_id": "abort-1",
"runtime": {
"agent_id": "agent-123",
"conversation_id": "conv-123"
},
"aborted": true,
"success": true
}

App Server exposes the same management and computer capabilities used by Letta’s own clients. Current command groups include:

  • App Server capability discovery
  • Agent and conversation creation, retrieval, updates, deletion, forking, message listing, and compaction
  • Filesystem, content search, and git branch operations
  • Memory files, history, commit diffs, and MemFS enablement
  • Models, providers, toolsets, experiments, and reflection settings
  • Skills and secrets
  • Terminals and command execution
  • Schedules and run history
  • Channels, accounts, routes, targets, and pairings

Use request_id on commands that return a response. TypeScript clients can import the current WsProtocolCommand and WsProtocolMessage unions from @letta-ai/letta-code/app-server-protocol instead of copying a command list. Keep handlers tolerant of additional fields and unknown message types.

Do not use these legacy listener command names in new App Server clients:

Do not sendUse instead
request_statesync
change_cwdchange_device_state with payload.cwd
change_modechange_device_state with payload.mode
cancel_runabort_message
recover_pending_approvalssync with recover_approvals: true