Skip to content
  • Auto
  • Light
  • Dark
DiscordForumGitHubSign up
View as Markdown
Copy Markdown

Open in Claude
Open in ChatGPT

Client-side tool execution

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 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 executed 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. Tool Configuration: Mark tools as requiring approval (same as HITL)
  2. Execution Pause: Agent attempts to call the tool, server sends approval request
  3. Local Execution: Your application executes the tool with the provided arguments
  4. Result Submission: Send the tool result back as an approval response
  5. Continuation: 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.

Step 1: Create Tool with Approval Requirement

Section titled “Step 1: Create Tool with Approval Requirement”

Mark your tool as requiring approval when creating it. This is identical to the HITL setup:

// Create a tool that will execute client-side
const tool = await client.tools.upsert({
name: "read_local_file",
defaultRequiresApproval: true,
jsonSchema: {
type: "function",
function: {
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"],
},
},
},
sourceCode: `def read_local_file(file_path: str):
"""Read a file from the local filesystem."""
raise Exception("This tool executes client-side only")`,
});

Add the tool to your agent like any other tool:

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

When the agent calls your tool, you’ll 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",
},
],
}
);
// 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 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. Create client-side tool
const tool = await client.tools.upsert({
name: "read_local_file",
defaultRequiresApproval: true,
jsonSchema: {
type: "function",
function: {
name: "read_local_file",
description: "Read a file from the local filesystem",
parameters: {
type: "object",
properties: {
file_path: { type: "string" },
},
required: ["file_path"],
},
},
},
sourceCode: `def read_local_file(file_path: str):
"""Read a file from the local filesystem."""
raise Exception("This tool executes client-side only")`,
});
// 2. Create agent
const agent = await client.agents.create({
name: "local-file-agent",
tools: ["read_local_file"],
memory_blocks: [
{ label: "human", value: "User: Alice" },
{ label: "persona", value: "You help users read their local files" },
],
});
// 3. Send user message
let response = await client.agents.messages.create(
agent.id,
{
messages: [{ role: "user", content: "Read config.json" }],
}
);
// 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
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,
},
],
},
],
}
);
// 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" },
],
}
);
// 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: toolCallId,
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.