How to give the Claude Agent SDK a code execution sandbox
The Claude Agent SDK is Claude Code packaged as a library: you call query() with a prompt, and it brings the whole harness — the agent loop, context management, subagents, and built-in tools for reading files, editing them, running Bash, and searching the web. For a coding agent running on your own laptop, that's exactly right.
Then you put it behind a web app, and every one of those built-in tools becomes a problem. Bash runs on the machine hosting your process. Read can reach anything that process can read. If the agent's context includes anything a user supplied — a pasted stack trace, a fetched web page, a file from a ticket — you now have a prompt injection with shell access.
The fix isn't to give up the SDK. It's to remove the host-side tools and hand the agent a sandbox instead, through the SDK's own in-process MCP server. I build PandaStack, so the sandbox in these examples is ours; the structure works with any sandbox provider that gives you exec over an API.
Step 1: take the host tools away
The SDK distinguishes availability — whether a tool is in Claude's context at all — from permission, which is whether a given call gets approved. For this you want availability: the tool should not exist, so the model never tries it and never wastes a turn being denied.
Passing a tools array lists exactly which built-ins survive. An empty array removes all of them, leaving only what you provide through MCP:
const options = {
tools: [], // no Bash, no Read, no Write, no Edit
mcpServers: { sandbox: sandboxServer },
allowedTools: ["mcp__sandbox__run_python", "mcp__sandbox__run_shell"],
};Step 2: define the sandbox tool
A custom tool is four things: a name, a description Claude reads to decide when to call it, an input schema, and an async handler. In TypeScript the schema is Zod and the handler's args are typed from it; in Python the @tool decorator takes a dict of names to types.
import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
import { Client } from "@pandastack/sdk";
import { z } from "zod";
const panda = new Client({ apiKey: process.env.PANDASTACK_API_KEY });
// One sandbox + one kernel per agent run.
const sb = await panda.sandboxes.create({
template: "code-interpreter",
ttlSeconds: 1800,
});
const ctx = await sb.createCodeContext("python");
const runPython = tool(
"run_python",
"Execute Python in an isolated sandbox and return stdout, stderr, and the " +
"cell result. Variables and imports persist between calls, so you can " +
"build up work step by step.",
{ code: z.string().describe("Python source to execute") },
async (args) => {
const ex = await ctx.runCode(args.code, { timeoutSeconds: 60 });
if (ex.error) {
return {
content: [{ type: "text", text: ex.error }],
isError: true, // Claude reads this as a failed call
};
}
const out = [ex.stdout, ex.stderr, ex.text].filter(Boolean).join("\n");
return { content: [{ type: "text", text: out || "(no output)" }] };
}
);Two details in there are load-bearing. The description tells the model that state persists — without that sentence, models assume a fresh process and defensively re-import pandas in every cell. And isError is how you signal a failed call: Claude reads the message you compose and usually fixes the code on the next turn, rather than treating a traceback as an odd-looking result.
Step 3: a second tool for the shell
The agent will want to install a package eventually. Rather than one tool with a language flag — which reliably degrades tool selection, because the model now makes two decisions where it made one — give it a separate tool with a description that says what it's for and what it isn't.
const runShell = tool(
"run_shell",
"Run a shell command in the sandbox. Use for installing packages, " +
"inspecting files, and CLI tools — not for data analysis.",
{ command: z.string() },
async (args) => {
const res = await sb.exec(args.command, { timeoutSeconds: 120 });
return {
content: [{ type: "text", text: res.stdout + res.stderr }],
isError: res.exit_code !== 0,
};
}
);
const sandboxServer = createSdkMcpServer({
name: "sandbox",
version: "1.0.0",
tools: [runPython, runShell],
});Both tools hit the same sandbox, so a package installed through the shell tool is importable from the kernel immediately afterwards — which is what a developer would expect, and the reason this split costs you nothing.
Step 4: wire it up
The key in mcpServers becomes the server segment of each tool's fully qualified name, mcp__{server}__{tool}. List those in allowedTools so calls run without a permission prompt.
import { query } from "@anthropic-ai/claude-agent-sdk";
for await (const message of query({
prompt: "Load /workspace/orders.csv and chart revenue by month.",
options: {
tools: [],
mcpServers: { sandbox: sandboxServer },
allowedTools: ["mcp__sandbox__run_python", "mcp__sandbox__run_shell"],
},
})) {
if (message.type === "result" && message.subtype === "success") {
console.log(message.result);
}
}The Python shape is the same, with the decorator instead of the helper:
import asyncio, os, pandastack
from claude_agent_sdk import tool, create_sdk_mcp_server, query, ClaudeAgentOptions, ResultMessage
panda = pandastack.Client(api_key=os.environ["PANDASTACK_API_KEY"])
sandbox = panda.sandboxes.create(template="code-interpreter", ttl_seconds=1800)
ctx = sandbox.create_code_context()
@tool("run_python", "Execute Python in an isolated sandbox. State persists "
"between calls.", {"code": str})
async def run_python(args):
ex = ctx.run_code(args["code"], timeout_seconds=60)
if ex.error:
return {"content": [{"type": "text", "text": ex.error}], "is_error": True}
out = "\n".join(x for x in (ex.stdout, ex.stderr, ex.text) if x)
return {"content": [{"type": "text", "text": out or "(no output)"}]}
sandbox_server = create_sdk_mcp_server(name="sandbox", version="1.0.0",
tools=[run_python])
async def main():
options = ClaudeAgentOptions(
tools=[],
mcp_servers={"sandbox": sandbox_server},
allowed_tools=["mcp__sandbox__run_python"],
)
async for message in query(prompt="Fit a linear trend to [3,7,9,14,20].",
options=options):
if isinstance(message, ResultMessage) and message.subtype == "success":
print(message.result)
asyncio.run(main())Returning charts without wrecking the context
A cell that plots something gives you a base64 PNG. The tempting move is to stuff it into the text result, which puts forty thousand characters of base64 into the model's context on every plotting call — expensive, and useless to the model, which can't read base64 anyway.
The content array accepts an image block, which the SDK forwards as real visual input. Use it when the model genuinely needs to see the chart to reason about it; otherwise, store the bytes yourself and return a reference.
if (ex.png) {
// Model actually needs to look at it:
return {
content: [{ type: "image", data: ex.png, mimeType: "image/png" }],
};
// Or, when it just needs to know a chart was produced:
// const id = await store(ex.png);
// return { content: [{ type: "text", text: `chart rendered (id=${id})` }] };
}The data field takes raw base64 with no data:image/png;base64, prefix — a small thing that costs an hour if you get it wrong.
Annotations, and the one that isn't cosmetic
Tool annotations are metadata about how a tool behaves, and three of the four are purely informational. readOnlyHint is not: it controls whether the SDK will call the tool in parallel with other read-only tools. On an agent that inspects several things before acting, that's a real latency win.
const readFile = tool(
"read_sandbox_file",
"Read a file from the sandbox filesystem.",
{ path: z.string() },
async (args) => ({
content: [{ type: "text", text: (await sb.exec(`cat ${args.path}`)).stdout }],
}),
{ annotations: { readOnlyHint: true } }
);Keep the annotation honest — it's a hint, not enforcement. Marking a tool read-only doesn't stop the handler writing to disk; it just tells the SDK it's safe to batch, and a lie there produces concurrent writes you didn't plan for.
One sandbox per conversation, and clean it up
The module-level sandbox in the examples above is fine for a script and wrong for a server. In a web app, key the sandbox by conversation or session id and tear it down when the conversation ends — otherwise every user shares one kernel, which means every user shares every variable and every file.
People resist this because they assume sandbox startup is expensive, and on platforms where a fresh environment takes ten seconds they're right to. That's the constraint that pushes teams into pooling sandboxes across users, which reintroduces exactly the isolation problem they were trying to solve. Check the number before you architect around it: a snapshot-restore create is a couple of hundred milliseconds, at which point per-conversation isolation is simply the obvious choice.
// Belt: the platform reaps it even if your process dies
const sb = await panda.sandboxes.create({
template: "code-interpreter",
ttlSeconds: 1800,
metadata: { conversation: conversationId },
});
// Braces: explicit teardown when the conversation ends
try {
await runAgent(sb);
} finally {
await sb.kill();
}The short version
- Pass tools: [] to remove the built-in Bash, Read, Write, and Edit — a scoped deny rule is a guardrail, not isolation.
- Define run_python and run_shell as separate tools; one tool with a mode flag selects worse.
- Say in the description that kernel state persists, or the agent re-imports everything every turn.
- Return failures with isError rather than throwing, so the model can self-correct.
- Return images as image blocks or as a reference — never base64 in a text result.
- Set readOnlyHint on genuinely read-only tools to unlock parallel calls.
- One sandbox per conversation, with a TTL at creation and an explicit kill on the happy path.
What you end up with is the Agent SDK doing what it's good at — the loop, the context management, the subagents — with the blast radius of a bad generation contained to a microVM you were going to throw away anyway.
Frequently asked questions
Is the Claude Agent SDK's built-in Bash tool safe to expose to users?
Not on its own. Bash, Read, Write, and Edit all execute in the process hosting your agent, with that process's filesystem access, environment variables, and network position. On a developer's laptop running their own code, that's the entire point of the SDK. Behind a web app it means any content that reaches the model — a pasted error, a fetched page, an uploaded file — is a potential prompt injection with shell access. The SDK gives you the controls to fix it: pass a tools array to remove the built-ins from Claude's context, and supply a sandboxed equivalent through an in-process MCP server.
What's the difference between the tools option and disallowedTools?
They act on different layers. The tools array and bare-name entries in disallowedTools control availability — the tool is removed from Claude's context entirely, so the model never sees it and never attempts a call. allowedTools and scoped rules like Bash(rm *) control permission: the tool stays visible, and only matching calls are denied. For untrusted input you want availability, because a tool the model can see is a tool it will try, and a scoped pattern only blocks the specific shapes you thought of in advance.
Do I need a separate MCP server process for custom tools?
No. createSdkMcpServer and create_sdk_mcp_server build an in-process server that runs inside your application — no subprocess, no transport to configure, no separate deployment. You define tools as ordinary async functions, wrap them in a server, and pass it via mcpServers. A standalone MCP server is worth the extra moving part when the tools need to be shared across several applications, or when you need something the in-process server doesn't forward — structuredContent from a Python tool, for instance.
Should each conversation get its own sandbox, or can I share one?
Its own, in almost every case. A shared kernel means one user's variables, installed packages, and files are visible to the next, which is a privacy problem and a correctness problem — the agent starts reasoning about state it didn't create. Key the sandbox by conversation id, create it lazily on the first code call so conversations that never run code cost nothing, and tear it down at the end. The only real argument for sharing is slow startup; when a sandbox restores from a snapshot in a couple hundred milliseconds, that argument disappears.
How do I stop a runaway cell from hanging the agent?
Set a timeout on every execution call and let the platform enforce it, rather than relying on the model to write terminating code. A model that writes an accidental infinite loop will otherwise hold your request open until something upstream gives up, and the user sees a hang with no explanation. Combine a per-call timeout with a TTL on the sandbox itself, so an abandoned conversation cleans up even if your process dies before it can. When a timeout fires, return that fact to the model as an error result — told the code was stopped for running too long, it generally writes something cheaper on the next turn.
Keep reading
49ms p50 cold start. Fork, snapshot, and scale to zero.