External tools
Register controller-owned tools in runtime_start and handle App Server tool callbacks
External tools let an App Server controller expose application-specific abilities to the agent without adding new built-in tools or asking the agent to shell out to a CLI.
Register external tools in runtime_start.external_tools. When the agent calls one, App Server sends external_tool_call_request on the control channel. Your controller executes the tool and responds with external_tool_call_response.
When to use external tools
Section titled “When to use external tools”Use external tools for application-owned actions:
- Look up tickets, documents, CRM records, or internal resources
- Dispatch tasks to another runtime or queue
- Update a dashboard or task registry
- Call APIs that should stay inside the controller process
- Implement integration-specific commands without changing Letta Code itself
Do not use external tools for generic local computer use. Letta Code already provides local tools such as Read, Grep, Bash, and file editing tools.
Register tools at runtime start
Section titled “Register tools at runtime start”External tools are bound to the resolved runtime. This prevents tools from leaking across agents or conversations.
{ "type": "runtime_start", "request_id": "runtime-1", "agent_id": "agent-123", "conversation_id": "conv-123", "external_tools": [ { "tools": [ { "name": "lookup_ticket", "label": "Lookup ticket", "description": "Fetch a support ticket by ID.", "parameters": { "type": "object", "properties": { "id": { "type": "string", "description": "Ticket ID, for example LET-123" } }, "required": ["id"], "additionalProperties": false } } ] } ]}Each tool definition has:
| Field | Required | Meaning |
|---|---|---|
name | Yes | Model-facing tool name |
description | Yes | Clear instruction for when and how to use the tool |
parameters | Yes | JSON schema for tool input |
label | No | Human-readable label for UI surfaces |
Handle tool calls
Section titled “Handle tool calls”Use the client helper to register a callback:
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.onExternalToolCall(async (request) => { if (request.tool_name !== "lookup_ticket") { throw new Error(`Unknown external tool: ${request.tool_name}`); }
const id = String(request.input.id ?? ""); const ticket = await lookupTicket(id);
return { content: [ { type: "text", text: JSON.stringify(ticket, null, 2), }, ], };});The helper automatically sends the matching external_tool_call_response. If the handler throws, the helper sends an error response.
Raw callback protocol
Section titled “Raw callback protocol”A raw tool call request looks like this:
{ "type": "external_tool_call_request", "request_id": "external-tool-1", "runtime": { "agent_id": "agent-123", "conversation_id": "conv-123" }, "tool_call_id": "call-123", "tool_name": "lookup_ticket", "input": { "id": "LET-123" }}Respond on the control channel with the same request_id:
{ "type": "external_tool_call_response", "request_id": "external-tool-1", "result": { "content": [ { "type": "text", "text": "Ticket LET-123 is assigned to Support." } ] }}For tool failures, return either a protocol error:
{ "type": "external_tool_call_response", "request_id": "external-tool-1", "error": "Ticket system unavailable"}Or a model-visible tool result error:
{ "type": "external_tool_call_response", "request_id": "external-tool-1", "result": { "is_error": true, "content": [ { "type": "text", "text": "Ticket LET-123 was not found." } ] }}Use protocol errors for transport or controller failures. Use is_error: true when the tool ran successfully but the result should be interpreted as a tool-level failure.
Scoped tools
Section titled “Scoped tools”Use scope_id when a tool should only be exposed on selected turns.
{ "type": "runtime_start", "request_id": "runtime-1", "agent_id": "agent-123", "conversation_id": "conv-123", "external_tools": [ { "tools": [ { "name": "lookup_ticket", "description": "Fetch a support ticket by ID.", "parameters": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] } } ] }, { "scope_id": "dispatch", "tools": [ { "name": "dispatch_task", "description": "Dispatch a task to another teammate runtime.", "parameters": { "type": "object", "properties": { "target": { "type": "string" }, "task": { "type": "string" } }, "required": ["target", "task"] } } ] } ]}Unscoped tools are visible on ordinary turns. Scoped tools are hidden unless the turn selects their scope:
{ "type": "input", "runtime": { "agent_id": "agent-123", "conversation_id": "conv-123" }, "payload": { "kind": "create_message", "messages": [ { "role": "user", "content": "Split this task across the team." } ], "external_tool_scope_ids": ["dispatch"] }}Use scoped tools when a controller wants to expose powerful or context-specific abilities only for one interaction.
Tool name isolation
Section titled “Tool name isolation”App Server internally keys runtime-owned tools by runtime identity and scope. Two runtimes can register the same model-facing tool name without overwriting each other.
Within one runtime and one visible toolset, avoid duplicate model-facing names. The model sees a flat set of names after runtime and scope filtering.
Design guidance for agents
Section titled “Design guidance for agents”When building an App Server integration, prefer external tools over new protocol commands for product-specific behavior.
Good external tools:
- Have narrow names:
lookup_ticket,dispatch_task,mark_task_done - Use strict JSON schemas
- Return compact, model-readable text
- Keep durable product state in the controller database
- Include IDs the controller can correlate later
Avoid external tools that:
- Mirror every internal API endpoint one-to-one
- Return huge unfiltered payloads
- Require hidden global state not represented in the input
- Mutate important state without a clear name and schema
Full example
Section titled “Full example”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.onExternalToolCall(async (request) => { switch (request.tool_name) { case "lookup_ticket": { const id = String(request.input.id ?? ""); const ticket = await lookupTicket(id); return { content: [{ type: "text", text: JSON.stringify(ticket) }], }; } default: throw new Error(`Unknown external tool: ${request.tool_name}`); }});
const started = await client.runtimeStart({ agent_id: "agent-123", create_conversation: { body: {} }, external_tools: [ { tools: [ { name: "lookup_ticket", description: "Fetch a support ticket by ID.", parameters: { type: "object", properties: { id: { type: "string" }, }, required: ["id"], additionalProperties: false, }, }, ], }, ],});
if (!started.success || !started.runtime) { throw new Error(started.error ?? "Failed to start runtime");}
await client.runTurn({ runtime: started.runtime, payload: { kind: "create_message", messages: [ { role: "user", content: "Look up LET-123 and summarize the next action.", }, ], },});
async function lookupTicket(id: string) { return { id, title: "Customer cannot connect Slack", status: "open", priority: "high", };}