---
title: Client tools | Letta Docs
description: Run tools that execute in your local execution environment
---

If you are building an agent that makes heavy use of client-side tools, consider using the [Letta Agent SDK](/letta-agent-sdk/quickstart/index.md), which has pre-built tools for computer use and coding (`Bash`, `Write`, `Read`, etc).

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 tools vs server tools

**Client tools** (or “client-side tools”) are tools that run on the local execution environment of the client (the code calling the Letta API). For example, a `Bash` tool that executes code on your local machine is a client tool, because in order for it to work, it must execute on the local machine, rather than the Letta server’s remote sandbox.

**Server tools** (or “server-side tools”) are tools that run in the Letta server’s remote sandbox. The [built-in tools](/guides/core-concepts/tools/builtin-tools/index.md) for searching the web and fetching webpage content are server tools, since the API calls made to search the web happen on a remote execution environment.

### 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.

To see a reference implementation of an agent using the client-side tool execution in the Letta TypeScript SDK, check out [Letta Code](https://github.com/letta-ai/letta-code).

### How it works

Client-side execution builds on the [human-in-the-loop (HITL) approval system](/guides/core-concepts/tools/human-in-the-loop/index.md). 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.

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.

## Setting up client-side tools

### Define client tools

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:

- [TypeScript](#tab-panel-122)
- [Python](#tab-panel-123)
- [curl](#tab-panel-124)

```
// 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"],
    },
  },
];
```

```
# Define client-side tools
client_tools = [
    {
        "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"]
        }
    }
]
```

Terminal window

```
# Client tools are passed in the messages.create request body
# See the "Receive the approval request" section below
```

### Create an agent

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

- [TypeScript](#tab-panel-125)
- [Python](#tab-panel-126)
- [curl](#tab-panel-127)

```
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" },
  ],
});
```

```
agent = 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"}
    ]
)
```

Terminal window

```
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",
    "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

### Receive the approval request

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:

- [TypeScript](#tab-panel-128)
- [Python](#tab-panel-129)
- [curl](#tab-panel-130)

```
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}`);
  }
}
```

```
response = client.agents.messages.create(
    agent_id=agent.id,
    messages=[{
        "role": "user",
        "content": "Read the contents of config.json"
    }],
    client_tools=client_tools
)


# Check for approval request
for 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}")
```

Terminal window

```
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"
    }],
    "client_tools": [{
      "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"]
      }
    }]
  }'


# 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"
}
```

Client tools are always treated as requiring approval since they execute outside the server. The agent will pause and return an `approval_request_message` whenever it wants to call a client-side tool.

### Execute the tool locally

Parse the arguments and execute your tool implementation:

- [TypeScript](#tab-panel-131)
- [Python](#tab-panel-132)
- [curl](#tab-panel-133)

```
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";
}
```

```
import json
import os


# Parse tool arguments
args = json.loads(tool_call.arguments)
file_path = args["file_path"]


# Execute tool locally
try:
    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"
```

Terminal window

```
# Parse arguments (example using jq)
FILE_PATH=$(echo "$TOOL_ARGUMENTS" | jq -r '.file_path')


# Execute your tool
RESULT=$(cat "$FILE_PATH")
STATUS="success"


# If execution fails, capture error
if [ $? -ne 0 ]; then
  STATUS="error"
  RESULT="File not found: $FILE_PATH"
fi
```

### Send the result to the agent

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

- [TypeScript](#tab-panel-134)
- [Python](#tab-panel-135)
- [curl](#tab-panel-136)

```
// 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);
  }
}
```

```
# Send tool result back to agent
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
        }]
    }]
)


# Agent continues with the result
for msg in response.messages:
    if msg.message_type == "assistant_message":
        print(msg.content)
```

Terminal window

```
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

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

- [TypeScript](#tab-panel-137)
- [Python](#tab-panel-138)

```
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();
```

```
import json
import os
from letta_client import Letta


client = Letta(api_key=os.environ["LETTA_API_KEY"])


# 1. Define client-side tools
client_tools = [
    {
        "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)
agent = 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
response = client.agents.messages.create(
    agent_id=agent.id,
    messages=[{"role": "user", "content": "Read config.json"}],
    client_tools=client_tools
)


# 4. Check for approval request
for 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 (include client_tools if agent may call them again)
        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
                }]
            }],
            client_tools=client_tools
        )


        # 7. Print agent's response
        for msg in response.messages:
            if msg.message_type == "assistant_message":
                print(msg.content)
```

## Advanced patterns

### Parallel tool execution

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

- [TypeScript](#tab-panel-139)
- [Python](#tab-panel-140)

```
// 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 }],
  }
);
```

```
# Agent requests multiple tools
response = client.agents.messages.create(
    agent_id=agent.id,
    messages=[{"role": "user", "content": "Read local config and fetch remote data"}],
    client_tools=client_tools
)


# Collect all approval requests
approvals = []
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 once
response = client.agents.messages.create(
    agent_id=agent.id,
    messages=[{"type": "approval", "approvals": approvals}]
)
```

### Including `stdout` and `stderr`

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

- [TypeScript](#tab-panel-141)
- [Python](#tab-panel-142)

```
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,
          },
        ],
      },
    ],
  }
);
```

```
import subprocess


# Execute shell command
process = subprocess.run(
    ["git", "status"],
    capture_output=True,
    text=True
)


# Send result with stdout/stderr
response = 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

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