Skip to content
Letta Code Letta Code Letta Docs
Sign up
App Server

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.

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.

External tools are bound to the resolved runtime. This prevents tools from leaking across agents or conversations.

runtime_start with external tools
{
"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:

FieldRequiredMeaning
nameYesModel-facing tool name
descriptionYesClear instruction for when and how to use the tool
parametersYesJSON schema for tool input
labelNoHuman-readable label for UI surfaces

Use the client helper to register a callback:

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

A raw tool call request looks like this:

external_tool_call_request
{
"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:

external_tool_call_response
{
"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:

Protocol error
{
"type": "external_tool_call_response",
"request_id": "external-tool-1",
"error": "Ticket system unavailable"
}

Or a model-visible tool result error:

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.

Use scope_id when a tool should only be exposed on selected turns.

Scoped external tools
{
"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:

Expose scoped tools for one turn
{
"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.

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.

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
tickets-controller.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.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",
};
}