Skip to content
Discord
Platform
App server

Quickstart

Start the Letta App Server, connect WebSocket channels, and run a turn

For most application integrations, start with the Letta Agent SDK quickstart. The SDK manages App Server and provides high-level APIs for agents, sessions, turns, streaming, and approvals.

Continue with this guide when you need to operate App Server separately or connect to its protocol directly. The examples use the local backend so agent state and the execution environment remain on the App Server machine.

Terminal window
npm install -g @letta-ai/letta-code

Run App Server on a fixed local port during development:

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

The process prints the base URL and the two channel URLs:

Listening on ws://127.0.0.1:4500
Control: ws://127.0.0.1:4500/ws?channel=control
Stream: ws://127.0.0.1:4500/ws?channel=stream

To bind to an available loopback port, omit the URL:

Terminal window
letta server --backend local --listen

App Server prints the selected URL at startup.

For a non-loopback listener, configure websocket auth. For example, capability-token auth reads a token from an absolute path and requires clients to send it as a bearer token:

Terminal window
letta server \
--backend local \
--listen ws://0.0.0.0:4500 \
--ws-auth capability-token \
--ws-token-file /absolute/path/to/app-server-token

You can also use signed bearer tokens with --ws-auth signed-bearer-token and --ws-shared-secret-file /absolute/path/to/secret.

Use the exported App Server client for TypeScript integrations.

Terminal window
npm install @letta-ai/letta-code ws
app-server-client.ts
import WebSocket from "ws";
import { createAppServerClient } from "@letta-ai/letta-code/app-server-client";
const client = await createAppServerClient({
url: "ws://127.0.0.1:4500",
WebSocket,
}).connect();
client.onMessage((message, channel) => {
console.log(channel, message.type);
});

The client opens both /ws?channel=control and /ws?channel=stream. Treat messages from both channels as the runtime event stream.

If the server uses capability-token auth, pass the token to the helper when your WebSocket implementation supports headers:

const authToken = process.env.APP_SERVER_TOKEN;
if (!authToken) throw new Error("APP_SERVER_TOKEN is required");
const client = await createAppServerClient({
url: "ws://example.internal:4500",
authToken,
WebSocket,
}).connect();

Start a runtime for an existing agent and conversation:

const started = await client.runtimeStart({
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",
},
});
if (!started.success || !started.runtime) {
throw new Error(started.error ?? "Failed to start runtime");
}
const runtime = started.runtime;

Create a new conversation for an existing agent by omitting conversation_id and passing create_conversation:

const started = await client.runtimeStart({
agent_id: "agent-123",
create_conversation: {
body: {},
},
cwd: "/Users/me/project",
});

Create a new agent and conversation in one call:

const started = await client.runtimeStart({
create_agent: {
body: {
name: "App Server Agent",
memory_blocks: [],
},
},
create_conversation: {
body: {},
},
cwd: "/Users/me/project",
mode: "acceptEdits",
});

runtime_start_response.runtime is the canonical scope for future commands. Store and reuse it.

Use runTurn when you want a promise that resolves when the turn completes:

const result = await client.runTurn({
runtime,
payload: {
kind: "create_message",
messages: [
{
role: "user",
content: "Inspect this repository and summarize the test setup.",
},
],
},
});
console.log(result.stopReason);

Use input when you want to fire the turn and manage completion from events yourself:

client.input({
runtime,
payload: {
kind: "create_message",
messages: [
{
role: "user",
content: "Run the relevant tests.",
},
],
},
});

The main streamed output is stream_delta:

client.onMessage((message) => {
if (message.type !== "stream_delta") return;
const delta = message.delta;
if ("message_type" in delta && delta.message_type === "loop_error") {
console.error(delta.message);
return;
}
console.log(delta);
});

stream_delta.delta can be either a standard Letta streaming message delta or an App Server lifecycle event such as tool starts/ends, command output, retries, status messages, and terminal stop reasons. Handle unknown delta shapes defensively and preserve them in logs while the protocol evolves.

Call sync after reconnecting or when a UI needs a fresh snapshot:

await client.sync({
runtime,
recover_approvals: false,
force_device_status: true,
});

recover_approvals: false is useful for lightweight UI refreshes. Leave it unset when you want App Server to probe backend state for stale pending approvals.

Cancel an active turn with abort_message:

const aborted = await client.abort({ runtime });
console.log(aborted.aborted);

If you do not use the helper, send native JSON frames over the control socket.

runtime_start
{
"type": "runtime_start",
"request_id": "runtime-1",
"agent_id": "agent-123",
"conversation_id": "conv-123",
"cwd": "/Users/me/project",
"mode": "standard"
}
input
{
"type": "input",
"runtime": {
"agent_id": "agent-123",
"conversation_id": "conv-123"
},
"payload": {
"kind": "create_message",
"messages": [
{
"role": "user",
"content": "Summarize this project."
}
]
}
}