Skip to content
Letta Platform Letta Platform Letta Docs
Sign up
Tools
Tool execution

Client-side tool execution

Run tools in your application environment and provide results to agents through the approval system.

Client-side tool execution allows you to run tools in your own application environment while maintaining the agent’s reasoning flow. This is useful when tools need access to local resources, user context, or private APIs that shouldn’t be exposed to the server.

flowchart LR
    Agent[Agent] -->|Wants tool| Request[Approval request]
    Request --> Client[Client application]
    Client -->|Executes locally| Tool[Local tool]
    Tool -->|Returns result| Client
    Client -->|Sends result| Server[Letta server]
    Server -->|Continues| Agent

Client-side execution builds on the human-in-the-loop (HITL) approval system. Instead of approving or denying a tool call, you execute the tool locally and provide the result directly. From the agent’s perspective, the tool executes normally — it receives the result and continues running.

Client-side execution is ideal for tools that need to access local resources, and make more sense to run locally than via the agent’s remote server.

One obvious example of this is a coding agent, where you want your Letta agent (running on a remote Letta server) to run tools like bash that execute on your local machine. Client-side tool execution allows you to execute tools locally and return the result back to your agent.

  1. Define tool schema: Define the tool schema (name, description, parameters) in your client.
  2. Pass tools at runtime: Include client_tools in the messages.create() request.
  3. Execution pause: The agent attempts to call the tool; the server sends an approval request.
  4. Local execution: Your application executes the tool with the provided arguments.
  5. Result submission: Send the tool result back as an approval response.
  6. Continuation: The agent receives the result and continues processing.

The key difference from standard approvals is the response type: Instead of approve: true or approve: false, you send type: "tool" with the execution result.

Client-side tools are passed directly in the messages.create() request via the client_tools parameter. No server-side tool registration is required.

Define your tools as an array of tool schemas:

// Define client-side tools
const clientTools = [
{
name: "read_local_file",
description: "Read a file from the local filesystem",
parameters: {
type: "object",
properties: {
file_path: {
type: "string",
description: "Path to the file",
},
},
required: ["file_path"],
},
},
];

Create your agent without attaching the client-side tools — they’re passed at runtime instead:

const agent = await client.agents.create({
name: "local-file-agent",
memory_blocks: [
{ label: "human", value: "User needs access to local files" },
{ label: "persona", value: "You help users read their local files" },
],
});

When sending a message, include your client_tools in the request. When the agent calls a client-side tool, you receive an approval request with the tool name and arguments:

const response = await client.agents.messages.create(
agent.id,
{
messages: [
{
role: "user",
content: "Read the contents of config.json",
},
],
client_tools: clientTools,
}
);
// Check for approval request
for (const msg of response.messages) {
if (msg.message_type === "approval_request_message") {
const toolCall = msg.tool_call;
console.log(`Tool: ${toolCall.name}`);
console.log(`Arguments: ${toolCall.arguments}`);
console.log(`Tool Call ID: ${toolCall.tool_call_id}`);
}
}

Parse the arguments and execute your tool implementation:

import fs from "fs";
// Parse tool arguments
const args = JSON.parse(toolCall.arguments);
const filePath = args.file_path;
// Execute tool locally
let result: string;
let status: "success" | "error";
try {
result = fs.readFileSync(filePath, "utf-8");
status = "success";
} catch (error) {
result = `Error reading file: ${error.message}`;
status = "error";
}

Submit the tool execution result using the approval response format with type: "tool":

// Send tool result back to agent
const response = await client.agents.messages.create(
agent.id,
{
messages: [
{
type: "approval",
approvals: [
{
type: "tool",
tool_call_id: toolCall.tool_call_id,
tool_return: result,
status: status,
},
],
},
],
}
);
// Agent continues with the result
for (const msg of response.messages) {
if (msg.message_type === "assistant_message") {
console.log(msg.content);
}
}

Here’s a full implementation showing the client-side execution of a local file reader:

import fs from "fs";
import Letta from "@letta-ai/letta-client";
const client = new Letta({
apiKey: process.env.LETTA_API_KEY,
});
async function main() {
// 1. Define client-side tools
const clientTools = [
{
name: "read_local_file",
description: "Read a file from the local filesystem",
parameters: {
type: "object",
properties: {
file_path: { type: "string", description: "Path to the file" },
},
required: ["file_path"],
},
},
];
// 2. Create agent (no tools attached — they're passed at runtime)
const agent = await client.agents.create({
name: "local-file-agent",
memory_blocks: [
{ label: "human", value: "User: Alice" },
{ label: "persona", value: "You help users read their local files" },
],
});
// 3. Send user message with client_tools
let response = await client.agents.messages.create(
agent.id,
{
messages: [{ role: "user", content: "Read config.json" }],
client_tools: clientTools,
}
);
// 4. Check for approval request
for (const msg of response.messages) {
if (msg.message_type === "approval_request_message") {
const toolCall = msg.tool_call;
// 5. Parse arguments and execute locally
const args = JSON.parse(toolCall.arguments);
const filePath = args.file_path;
let result: string;
let status: "success" | "error";
try {
result = fs.readFileSync(filePath, "utf-8");
status = "success";
} catch (error) {
result = `Error: ${error.message}`;
status = "error";
}
// 6. Send result back (include client_tools if agent may call them again)
response = await client.agents.messages.create(
agent.id,
{
messages: [
{
type: "approval",
approvals: [
{
type: "tool",
tool_call_id: toolCall.tool_call_id,
tool_return: result,
status: status,
},
],
},
],
client_tools: clientTools,
}
);
// 7. Print agent's response
for (const msg of response.messages) {
if (msg.message_type === "assistant_message") {
console.log(msg.content);
}
}
}
}
}
main();

You can handle multiple tool calls in a single approval response, mixing server-side approvals and client-side executions:

// Agent requests multiple tools
let response = await client.agents.messages.create(
agent.id,
{
messages: [
{ role: "user", content: "Read local config and fetch remote data" },
],
client_tools: clientTools,
}
);
// Collect all approval requests
const approvals = [];
for (const msg of response.messages) {
if (msg.message_type === "approval_request_message") {
const toolCall = msg.tool_call;
if (toolCall.name === "read_local_file") {
// Execute locally
const args = JSON.parse(toolCall.arguments);
const result = fs.readFileSync(args.file_path, "utf-8");
approvals.push({
type: "tool",
tool_call_id: toolCall.tool_call_id,
tool_return: result,
status: "success",
});
} else if (toolCall.name === "fetch_api") {
// Let server execute
approvals.push({
type: "approval",
tool_call_id: toolCall.tool_call_id,
approve: true,
});
}
}
}
// Send all responses at once
response = await client.agents.messages.create(
agent.id,
{
messages: [{ type: "approval", approvals: approvals }],
}
);

When executing tools that produce console output, you can include stdout and stderr in your response:

import { execSync } from "child_process";
// Execute shell command
let stdout: string[] = [];
let stderr: string[] = [];
let result: string;
let status: "success" | "error";
try {
result = execSync("git status", { encoding: "utf-8" });
stdout = result.split("\n");
status = "success";
} catch (error) {
result = error.message;
stderr = error.stderr?.split("\n") || [];
status = "error";
}
// Send result with stdout/stderr
const response = await client.agents.messages.create(
agent.id,
{
messages: [
{
type: "approval",
approvals: [
{
type: "tool",
tool_call_id: tool_call_id,
tool_return: result,
status: status,
stdout: stdout,
stderr: stderr,
},
],
},
],
}
);

Comparison with server-side tool execution

Section titled “Comparison with server-side tool execution”
FeatureClient-sideServer-side
Tool locationYour applicationLetta server sandbox
Execution controlHybrid, server waits for client returnManaged by server
Local resource accessYesNo, must upload to server
Private APIsYesNo, must expose to server
Setup complexityHigherLower
LatencyDepends on local executionMinimal sandbox overhead
SecurityYou manageServer sandboxed

Choose client-side execution when you need control over the execution environment or access to resources that can’t be exposed to the server. Use server-side execution for simpler setups and when tools don’t require local resources.