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
Overview
Section titled “Overview”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.
When to Use Client-Side Execution
Section titled “When to Use Client-Side Execution”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.
How It Works
Section titled “How It Works”- Tool Configuration: Mark tools as requiring approval (same as HITL)
- Execution Pause: Agent attempts to call the tool, server sends approval request
- Local Execution: Your application executes the tool with the provided arguments
- Result Submission: Send the tool result back as an approval response
- 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.
Setting Up Client-Side Tools
Section titled “Setting Up Client-Side Tools”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-sideconst 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")`,});# Create a tool that will execute client-sidefrom letta_client import Letta
client = Letta(token=os.environ["LETTA_API_KEY"])
def read_local_file(file_path: str) -> str: """Read a file from the local filesystem.""" raise Exception("This tool executes client-side only")
tool = client.tools.upsert_from_function( func=read_local_file, default_requires_approval=True,)curl --request POST \ --url https://api.letta.com/v1/tools \ --header 'Authorization: Bearer $LETTA_API_KEY' \ --header 'Content-Type: application/json' \ --data '{ "name": "read_local_file", "default_requires_approval": true, "json_schema": { "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"] } } }, "source_code": "def read_local_file(file_path: str):\n \"\"\"Read a file from the local filesystem.\"\"\"\n raise Exception(\"This tool executes client-side only\")" }'Step 2: Attach Tool to Agent
Section titled “Step 2: Attach Tool to Agent”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" }, ],});agent = 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"} ])curl --request POST \ --url https://api.letta.com/v1/agents \ --header 'Authorization: Bearer $LETTA_API_KEY' \ --header 'Content-Type: application/json' \ --data '{ "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"} ] }'Executing Tools Client-Side
Section titled “Executing Tools Client-Side”Step 1: Receive Approval Request
Section titled “Step 1: Receive Approval Request”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 requestfor (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}`); }}response = client.agents.messages.create( agent_id=agent.id, messages=[{ "role": "user", "content": "Read the contents of config.json" }])
# Check for approval requestfor msg in response.messages: if msg.message_type == "approval_request_message": tool_call = msg.tool_call print(f"Tool: {tool_call.name}") print(f"Arguments: {tool_call.arguments}") print(f"Tool Call ID: {tool_call.tool_call_id}")curl --request POST \ --url https://api.letta.com/v1/agents/$AGENT_ID/messages \ --header 'Authorization: Bearer $LETTA_API_KEY' \ --header 'Content-Type: application/json' \ --data '{ "messages": [{ "role": "user", "content": "Read the contents of config.json" }] }'
# Response includes approval request{ "messages": [ { "message_type": "reasoning_message", "reasoning": "The user wants to read a file. I should use read_local_file..." }, { "message_type": "approval_request_message", "id": "message-abc123", "tool_call": { "name": "read_local_file", "arguments": "{\"file_path\": \"config.json\"}", "tool_call_id": "call-xyz789" } } ], "stop_reason": "requires_approval"}Step 2: Execute Tool Locally
Section titled “Step 2: Execute Tool Locally”Parse the arguments and execute your tool implementation:
import fs from "fs";
// Parse tool argumentsconst args = JSON.parse(toolCall.arguments);const filePath = args.file_path;
// Execute tool locallylet 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";}import jsonimport os
# Parse tool argumentsargs = json.loads(tool_call.arguments)file_path = args["file_path"]
# Execute tool locallytry: with open(file_path, "r") as f: result = f.read() status = "success"except Exception as e: result = f"Error reading file: {str(e)}" status = "error"# Parse arguments (example using jq)FILE_PATH=$(echo "$TOOL_ARGUMENTS" | jq -r '.file_path')
# Execute your toolRESULT=$(cat "$FILE_PATH")STATUS="success"
# If execution fails, capture errorif [ $? -ne 0 ]; then STATUS="error" RESULT="File not found: $FILE_PATH"fiStep 3: Send Result to Agent
Section titled “Step 3: Send Result to Agent”Submit the tool execution result using the approval response format with type: "tool":
// Send tool result back to agentconst 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 resultfor (const msg of response.messages) { if (msg.message_type === "assistant_message") { console.log(msg.content); }}# Send tool result back to agentresponse = client.agents.messages.create( agent_id=agent.id, messages=[{ "type": "approval", "approvals": [{ "type": "tool", "tool_call_id": tool_call.tool_call_id, "tool_return": result, "status": status }] }])
# Agent continues with the resultfor msg in response.messages: if msg.message_type == "assistant_message": print(msg.content)curl --request POST \ --url https://api.letta.com/v1/agents/$AGENT_ID/messages \ --header 'Authorization: Bearer $LETTA_API_KEY' \ --header 'Content-Type: application/json' \ --data '{ "messages": [{ "type": "approval", "approvals": [{ "type": "tool", "tool_call_id": "call-xyz789", "tool_return": "{\n \"api_key\": \"...\",\n \"endpoint\": \"...\"\n}", "status": "success" }] }] }'
# Agent continues with the result{ "messages": [ { "message_type": "tool_return_message", "tool_return": "{\"api_key\": \"...\", \"endpoint\": \"...\"}", "status": "success" }, { "message_type": "reasoning_message", "reasoning": "I successfully read the config file..." }, { "message_type": "assistant_message", "content": "I've read the config file. Here's what it contains..." } ], "stop_reason": "end_turn"}Complete Example
Section titled “Complete Example”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();import jsonimport osfrom letta_client import Letta
client = Letta(token=os.environ["LETTA_API_KEY"])
# 1. Create client-side tooldef read_local_file(file_path: str) -> str: """Read a file from the local filesystem.""" raise Exception("This tool executes client-side only")
tool = client.tools.upsert_from_function( func=read_local_file, default_requires_approval=True,)
# 2. Create agentagent = 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 messageresponse = client.agents.messages.create( agent_id=agent.id, messages=[{"role": "user", "content": "Read config.json"}])
# 4. Check for approval requestfor msg in response.messages: if msg.message_type == "approval_request_message": tool_call = msg.tool_call
# 5. Parse arguments and execute locally args = json.loads(tool_call.arguments) file_path = args["file_path"]
try: with open(file_path, "r") as f: result = f.read() status = "success" except Exception as e: result = f"Error: {str(e)}" status = "error"
# 6. Send result back response = client.agents.messages.create( agent_id=agent.id, messages=[{ "type": "approval", "approvals": [{ "type": "tool", "tool_call_id": tool_call.tool_call_id, "tool_return": result, "status": status }] }] )
# 7. Print agent's response for msg in response.messages: if msg.message_type == "assistant_message": print(msg.content)Advanced Patterns
Section titled “Advanced Patterns”Parallel Tool Execution
Section titled “Parallel Tool Execution”You can handle multiple tool calls in a single approval response, mixing server-side approvals and client-side executions:
// Agent requests multiple toolslet response = await client.agents.messages.create( agent.id, { messages: [ { role: "user", content: "Read local config and fetch remote data" }, ], });
// Collect all approval requestsconst 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 onceresponse = await client.agents.messages.create( agent.id, { messages: [{ type: "approval", approvals: approvals }], });# Agent requests multiple toolsresponse = client.agents.messages.create( agent_id=agent.id, messages=[{"role": "user", "content": "Read local config and fetch remote data"}])
# Collect all approval requestsapprovals = []for msg in response.messages: if msg.message_type == "approval_request_message": tool_call = msg.tool_call
if tool_call.name == "read_local_file": # Execute locally args = json.loads(tool_call.arguments) result = open(args["file_path"]).read() approvals.append({ "type": "tool", "tool_call_id": tool_call.tool_call_id, "tool_return": result, "status": "success" }) elif tool_call.name == "fetch_api": # Let server execute approvals.append({ "type": "approval", "tool_call_id": tool_call.tool_call_id, "approve": True })
# Send all responses at onceresponse = client.agents.messages.create( agent_id=agent.id, messages=[{"type": "approval", "approvals": approvals}])Including Stdout/Stderr
Section titled “Including Stdout/Stderr”When executing tools that produce console output, you can include stdout and stderr in your response:
import { execSync } from "child_process";
// Execute shell commandlet 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/stderrconst 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, }, ], }, ], });import subprocess
# Execute shell commandprocess = subprocess.run( ["git", "status"], capture_output=True, text=True)
# Send result with stdout/stderrresponse = client.agents.messages.create( agent_id=agent.id, messages=[{ "type": "approval", "approvals": [{ "type": "tool", "tool_call_id": tool_call_id, "tool_return": process.stdout, "status": "success" if process.returncode == 0 else "error", "stdout": process.stdout.splitlines(), "stderr": process.stderr.splitlines() }] }])Comparison with Server-Side Tool Execution
Section titled “Comparison with Server-Side Tool Execution”| Feature | Client-Side | Server-Side |
|---|---|---|
| Tool location | Your application | Letta server sandbox |
| Execution control | Hybrid, server waits for client return | Managed by server |
| Local resource access | Yes | No, must upload to server |
| Private APIs | Yes | No, must expose to server |
| Setup complexity | Higher | Lower |
| Latency | Depends on local execution | Minimal sandbox overhead |
| Security | You manage | Server 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.