froog / Integrations

Claude Agent SDK

The Claude Agent SDK runs its built-in Bash, Read and Write tools on the machine it runs on. To run an agent's work in a froog sandbox instead, turn the built-ins off and give it three custom tools, served by the SDK's in-process MCP server, that act on a Linux VM of its own.

Install

Python 3.10+ or Node.js 18.14+. The SDK bundles the Claude Code binary it drives. The froog SDK is one file: download it next to your script, as the SDK docs say.

# Python
$ python3 -m venv .venv && source .venv/bin/activate
$ pip install claude-agent-sdk
$ curl -fsSLO https://froog.tech/sdk/froog.py
# TypeScript
$ npm init -y && npm pkg set type=module
$ npm install @anthropic-ai/claude-agent-sdk zod
$ npm install --save-dev tsx
$ curl -fsSLO https://froog.tech/sdk/froog.ts
$ export ANTHROPIC_API_KEY=...        # your model provider's key
$ export FROOG_API_KEY=froog_...   # dashboard, API keys; or: froog api-key create agents --preset ci

A complete example in Python

Save as agent.py next to froog.py and run python agent.py.

import asyncio
from typing import Annotated, Any

from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, create_sdk_mcp_server, query, tool

from froog import Froog, FroogError

froog = Froog()  # reads FROOG_API_KEY from the environment
LIMIT = 20_000   # characters of output handed back to the model


def open_sandbox(name: str) -> str:
    """Make sure the sandbox `name` is running: create it, or start it if it was stopped."""
    try:
        sb = froog.get_sandbox(name)
    except FroogError as e:
        if e.status != 404:
            raise
        froog.create_sandboxes({
            "name": name,
            "size": "small",          # 2 vCPU, 4 GB
            "ttl_secs": 1800,         # auto-stop 30 minutes after it starts; the disk is kept
            "environment": False,     # none of your account's variables or files
            "labels": {"app": "claude-agent-demo"},
        })
        sb = {"state": "creating"}
    if sb["state"] == "stopping":
        froog.wait_until(name, "stopped")
        sb["state"] = "stopped"
    if sb["state"] == "stopped":
        froog.start_sandbox(name, {})
    froog.wait_until(name, "running")
    return name


def clip(text: str) -> str:
    return text if len(text) <= LIMIT else "[...cut...]\n" + text[-LIMIT:]


def text(s: str) -> dict[str, Any]:
    return {"content": [{"type": "text", "text": s}]}


def froog_server(sandbox: str):
    """An in-process MCP server whose tools act on one froog sandbox."""

    @tool(
        "run_command",
        "Run a bash command in your Linux sandbox (Ubuntu 24.04, passwordless sudo, Docker, internet). "
        "The working directory is /home/user. Returns the exit code, stdout and stderr.",
        {
            "command": Annotated[str, "The bash command to run"],
            "timeout_secs": Annotated[int, "Seconds before it is killed, 1 to 3600 (300 is a good default)"],
        },
    )
    async def run_command(args: dict[str, Any]) -> dict[str, Any]:
        # The froog SDK is synchronous (standard library only), so run it off the event loop.
        r = await asyncio.to_thread(
            froog.exec, sandbox, {"command": args["command"], "timeout_secs": args["timeout_secs"]}
        )
        out = f"exit code {r['exit_code']}" + (" (timed out)" if r.get("timed_out") else "")
        if r["stdout"]:
            out += "\nstdout:\n" + clip(r["stdout"])
        if r["stderr"]:
            out += "\nstderr:\n" + clip(r["stderr"])
        return text(out)

    @tool(
        "write_file",
        "Create or overwrite a text file in the sandbox. Parent folders are created.",
        {"path": Annotated[str, "Absolute, or relative to /home/user"], "content": Annotated[str, "The whole file"]},
    )
    async def write_file(args: dict[str, Any]) -> dict[str, Any]:
        await asyncio.to_thread(froog.write_file, sandbox, args["content"].encode(), args["path"])
        return text(f"wrote {len(args['content'])} characters to {args['path']}")

    @tool("read_file", "Read a text file from the sandbox.", {"path": Annotated[str, "Absolute, or relative to /home/user"]})
    async def read_file(args: dict[str, Any]) -> dict[str, Any]:
        data = await asyncio.to_thread(froog.read_file, sandbox, args["path"])
        return text(clip(data.decode("utf-8", errors="replace")))

    return create_sdk_mcp_server(name="froog", version="1.0.0", tools=[run_command, write_file, read_file])


async def main() -> None:
    sandbox = await asyncio.to_thread(open_sandbox, "chat-demo")  # one sandbox per conversation
    try:
        options = ClaudeAgentOptions(
            mcp_servers={"froog": froog_server(sandbox)},
            tools=[],                          # no built-in tools: nothing runs on this machine
            allowed_tools=["mcp__froog__*"],   # pre-approve the sandbox tools
            system_prompt="You have a Linux sandbox. Use the froog tools to write and run code, and check results by running them.",
            max_turns=20,
        )
        async for message in query(
            prompt="Write primes.py that prints the first 20 primes, run it, and tell me the output.",
            options=options,
        ):
            if isinstance(message, ResultMessage) and message.subtype == "success":
                print(message.result)
    finally:
        await asyncio.to_thread(froog.delete_sandbox, sandbox)  # or stop_sandbox to keep the disk


if __name__ == "__main__":
    asyncio.run(main())

The same in TypeScript

Save as agent.ts next to froog.ts and run npx tsx agent.ts.

import { createSdkMcpServer, query, tool } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
import { Froog, FroogError } from "./froog";

const froog = new Froog({ apiKey: process.env.FROOG_API_KEY });
const LIMIT = 20_000; // characters of output handed back to the model

// Make sure the sandbox `name` is running: create it, or start it if it was stopped.
async function openSandbox(name: string): Promise<string> {
  let state: string;
  try {
    state = (await froog.getSandbox(name)).state;
  } catch (e) {
    if (!(e instanceof FroogError) || e.status !== 404) throw e;
    await froog.createSandboxes({
      name,
      size: "small",       // 2 vCPU, 4 GB
      ttl_secs: 1800,      // auto-stop 30 minutes after it starts; the disk is kept
      environment: false,  // none of your account's variables or files
      labels: { app: "claude-agent-demo" },
    });
    state = "creating";
  }
  if (state === "stopping") state = (await froog.waitUntil(name, "stopped")).state;
  if (state === "stopped") await froog.startSandbox(name, {});
  await froog.waitUntil(name, "running");
  return name;
}

const clip = (s: string) => (s.length <= LIMIT ? s : "[...cut...]\n" + s.slice(-LIMIT));
const text = (s: string) => ({ content: [{ type: "text" as const, text: s }] });

// An in-process MCP server whose tools act on one froog sandbox.
function froogServer(sandbox: string) {
  return createSdkMcpServer({
    name: "froog",
    version: "1.0.0",
    tools: [
      tool(
        "run_command",
        "Run a bash command in your Linux sandbox (Ubuntu 24.04, passwordless sudo, Docker, internet). " +
          "The working directory is /home/user. Returns the exit code, stdout and stderr.",
        {
          command: z.string().describe("The bash command to run"),
          timeout_secs: z.number().int().min(1).max(3600).optional().describe("Seconds before it is killed (default 300)"),
        },
        async ({ command, timeout_secs }) => {
          const r = await froog.exec(sandbox, { command, timeout_secs: timeout_secs ?? 300 });
          let out = `exit code ${r.exit_code}${r.timed_out ? " (timed out)" : ""}`;
          if (r.stdout) out += "\nstdout:\n" + clip(r.stdout);
          if (r.stderr) out += "\nstderr:\n" + clip(r.stderr);
          return text(out);
        },
      ),
      tool(
        "write_file",
        "Create or overwrite a text file in the sandbox. Parent folders are created.",
        { path: z.string().describe("Absolute, or relative to /home/user"), content: z.string().describe("The whole file") },
        async ({ path, content }) => {
          await froog.writeFile(sandbox, content, { path });
          return text(`wrote ${content.length} characters to ${path}`);
        },
      ),
      tool(
        "read_file",
        "Read a text file from the sandbox.",
        { path: z.string().describe("Absolute, or relative to /home/user") },
        async ({ path }) => text(clip(new TextDecoder().decode(await froog.readFile(sandbox, { path })))),
      ),
    ],
  });
}

const sandbox = await openSandbox("chat-demo"); // one sandbox per conversation
try {
  for await (const message of query({
    prompt: "Write primes.py that prints the first 20 primes, run it, and tell me the output.",
    options: {
      mcpServers: { froog: froogServer(sandbox) },
      tools: [],                         // no built-in tools: nothing runs on this machine
      allowedTools: ["mcp__froog__*"],   // pre-approve the sandbox tools
      systemPrompt: "You have a Linux sandbox. Use the froog tools to write and run code, and check results by running them.",
      maxTurns: 20,
    },
  })) {
    if (message.type === "result" && message.subtype === "success") console.log(message.result);
  }
} finally {
  await froog.deleteSandbox(sandbox); // or froog.stopSandbox(sandbox) to keep the disk
}

How it works

  • open_sandbox (openSandbox) gets the conversation's sandbox running: it creates it (a small Ubuntu VM, ready in under a minute), or starts it if it was stopped, and waits for running.
  • Three tools bound to that sandbox: run a command (POST /v1/sandboxes/{name}/exec, up to an hour, output and exit code back), write a file and read a file (PUT and GET /v1/sandboxes/{name}/files/content). Output is cut to the last 20,000 characters so a noisy build doesn't fill the context window.
  • Cleanup in finally: the example deletes the sandbox. In a real app, stop it or let its auto-stop timer stop it (see the notes).
  • Built-ins off. tools=[] (tools: []) takes Claude's own Bash, Read, Edit and Write out of its context, so nothing runs on the machine your app runs on, and mcp__froog__* pre-approves the three sandbox tools.

Notes

  • One sandbox per conversation or user. Name it after the conversation, thread or user id (chat-42, user-7: 1–40 lowercase letters, digits and dashes) so every turn lands in the same machine with the same files. open_sandbox (openSandbox) above creates it the first time and starts it again if it was stopped. Labels ("labels": {"user": "7"}) let you find a user's sandboxes later with GET /v1/sandboxes?label=user=7.
  • Stop instead of delete when the user may come back. The example's ttl_secs: 1800 stops the sandbox 30 minutes after it starts, busy or not (PATCH a new ttl_secs to push it back while the conversation goes on). Its disk is kept for 14 days, a start takes about 15–30 seconds (about a second within 10 minutes of the stop), and installed packages and files are still there. Delete it when the conversation is over for good.
  • Cost. Running time is billed per second, only while the sandbox runs: small (2 vCPU, 4 GB) is $0.0137 an hour, default (4 vCPU, 8 GB) $0.0274. A 10-minute conversation on small costs about a quarter of a cent; the idle time before the TTL stops it counts as running time, so pick a TTL that matches how long users pause. A stopped sandbox costs no running time. See pricing.
  • Templates. Install what your agent needs once (Python packages, a repo, a database), then save it: froog snapshot my-box --name agent-base. Create each conversation's sandbox with "from": "agent-base" and it starts with all of it, instead of the agent spending its first turns on pip install. See snapshots.
  • Keep your secrets out. "environment": false starts the sandbox without your account's environment variables, repos and secret files, since the model runs whatever it likes in there. Pass only what that sandbox needs in env. Use a scoped API key for your backend (froog api-key create agents --preset ci can only touch the sandboxes it created) and never send it to a browser. See API keys.
  • More tools. The same pattern covers the rest of the API: POST /sandboxes/{name}/urls (host_port) gives a running web server a public URL the agent can return to the user, start_job runs servers and long builds in the background, exec_stream streams output as it arrives, and fork_sandbox copies a sandbox to try several approaches at once. Every call is in the HTTP API.
  • Multi-turn. For a conversation, keep one ClaudeSDKClient (or resume the session by id) and one sandbox with the same id.
  • Claude Code inside the sandbox. Claude Code is installed in every froog sandbox. If you want the whole agent loop to run there, not just its tools, call POST /v1/sandboxes/{name}/prompt (or froog prompt NAME "TEXT" --agent claude) instead: see Run Claude Code in the cloud.

Questions

Why turn off the SDK's built-in tools?

Claude's built-in Bash, Read and Write tools act on the computer the SDK runs on, usually your server. With tools=[] and the froog tools instead, every command and file edit happens in the sandbox, a separate VM you can throw away.

Can I run Claude Code itself in the sandbox instead?

Yes. Claude Code is installed in every froog sandbox: POST /v1/sandboxes/{name}/prompt runs it there to completion with your key. Use the SDK with froog tools when your app should own the agent loop, and prompt when the agent should live in the sandbox.

7 days free: 25 hours of running time, 2 sandboxes at once. Then from $0.0137 an hour, billed per second.