Skip to content

Host a Claude Agent SDK app in a sandbox VM

Claude Code as a library, with a whole computer to run on.

Deploy on exe.dev

Shelley handles the setup. About 5 minutes.

Prefer a terminal?
curl -fsSL https://exe-staging.dev/deploy/claude-agent-sdk/prompt.txt | ssh exe.dev new --prompt=/dev/stdin

The Claude Agent SDK is Claude Code as a library: the same agent loop, tools, and context handling, called from your own TypeScript or Python. Every conversation runs a Claude Code process that reads files, edits them, and runs shell commands, so an SDK app needs a computer where that's safe. Anthropic's hosting guide says to run it in a sandbox, with a disk for its state and API keys kept out of reach.

An exe.dev VM is that sandbox, and it stays put: a whole Ubuntu machine with sudo, a disk for files and conversations, a private HTTPS address, and Claude models through exe.dev's LLM integration, so no Anthropic API key sits on the VM. This recipe is a small Claude agent on the SDK: a web chat where Claude works on the VM without asking permission first.

What you get#

  • A Claude Agent SDK app under systemd, with a private web chat at https://<vm>.exe.xyz/ that shows each command Claude runs.
  • Claude working without permission prompts, as a non-root user with sudo, on a VM that holds nothing else.
  • Claude Sonnet through exe.dev's LLM integration. No Anthropic API key on the VM.
  • Conversations that resume after a restart, and turns that finish after you close the tab.
What's running, and where
Private https://my-claude-agent-sdk.exe.xyz/ The chat, for you and anyone you share the VM with
Private https://my-claude-agent-sdk.exe.xyz:3000/ Apps Claude builds, on ports 3000-9999
my-claude-agent-sdk.exe.xyz exe.dev VM
Your Claude agent Claude Agent SDK 0.3 on Node.js 24, managed by systemd :8000~/claude-agent
Workspace Where Claude reads, writes, and runs commands ~/claude-agent/workspace
Conversations Claude Code transcripts, resumed after restarts ~/.claude/projects
Claude Sonnet Anthropic, through exe.dev's LLM integration

Deploy with Shelley#

Click Deploy and Shelley will set up Claude Agent SDK on a new VM, then check that it works. No config-file wrangling required.

Prefer a terminal?
curl -fsSL \
  https://exe-staging.dev/deploy/claude-agent-sdk/prompt.txt \
  | ssh exe.dev new --prompt=/dev/stdin
What Shelley is told
Set up Claude Agent SDK on this VM by following the exe.dev recipe at https://exe-staging.dev/deploy/claude-agent-sdk.md from top to bottom.

The recipe's example VM name is my-claude-agent-sdk. This VM's name is the output of `hostname`: use it wherever the recipe writes my-claude-agent-sdk. Skip the step that creates the VM; you are on it.

You are done when all of these are true. Check each one yourself:
- A Claude Agent SDK app under systemd, with a private web chat at `https://<vm>.exe.xyz/` that shows each command Claude runs.
- Claude working without permission prompts, as a non-root user with sudo, on a VM that holds nothing else.
- Claude Sonnet through exe.dev's LLM integration. No Anthropic API key on the VM.
- Conversations that resume after a restart, and turns that finish after you close the tab.

Read the whole recipe, including the closed details. Follow "Install by
hand" first, skipping VM creation; the walkthrough is for the owner
afterwards. Do not run the Update instructions, add GitHub integrations,
or create sandbox VMs. Keep the VM private: never run share set-public,
because Claude runs commands with sudo and without asking. On an
existing VM, ask before replacing ~/claude-agent or anything that
listens on port 8000.
Verify the pong reply, that the follow-up after the restart answers
kiwi, and that claude-agent.service is enabled.
Finish with https://<vm>.exe.xyz/, using this VM's name, and suggest the
first prompt in "Say hello".

Finish with the URLs I should open.

Also at /deploy/claude-agent-sdk/prompt.txt. The recipe it follows is /deploy/claude-agent-sdk.md.

Say hello#

Open https://my-claude-agent-sdk.exe.xyz/ and sign in to exe.dev. Try:

Look around this VM and describe it in three short lines.

Each command Claude runs shows up in grey above its reply. Then:

Make a one-page to-do list web app and serve it on port 3000. Give me the link.

Claude writes it, starts it as a systemd service, and links https://my-claude-agent-sdk.exe.xyz:3000/, which is private to you like the chat. Ports 3000 to 9999 all work this way through the HTTPS proxy.

Warning

Keep this VM private. Claude runs commands with sudo, without asking, and anyone who can open the page can tell it what to do.

Walk away#

Ask for something that takes a while:

Install PostgreSQL, load a million rows of made-up orders, and find a slow query you can make fast with an index.

Close the tab. Claude keeps working on the VM, and when you come back the page shows how far it got. Conversations are files on disk, so they carry on after a restart or a reboot, too; New chat starts a fresh one.

Hand it a repo#

Link GitHub on the Integrations page, then give this VM one repository. On your computer:

ssh exe.dev integrations add github --name myapp --repository you/myapp --attach vm:my-claude-agent-sdk

Then ask:

Clone https://github.int.exe.xyz/you/myapp.git, fix issue 12, push the fix to a new branch, and open a pull request.

No GitHub token reaches the VM: the GitHub integration adds credentials on the way out, for git and for gh. Commits show up as exe-dev-github-integration[bot] unless you add --act-as-user.

Run Claude Code in a throwaway sandbox#

For a one-off task, or code you'd rather not run anywhere you care about, give it a fresh VM and delete the VM afterwards. Claude Code is preinstalled, and exeuntu configure claude connects it to the LLM integration. On your computer:

ssh exe.dev new --name=my-claude-task --sandbox
ssh my-claude-task.exe.xyz 'exeuntu configure claude &&
  git clone https://github.com/you/myapp.git && cd myapp &&
  claude -p --dangerously-skip-permissions "Find one bug, fix it, and add a test" &&
  git diff'
ssh exe.dev rm my-claude-task

--sandbox makes a standalone VM, billed by the second, that doesn't use your plan's shared capacity; leave it off to use that capacity instead. For a private repo, add --integration=myapp to new and clone from github.int.exe.xyz as above. To start sandboxes from code, one per task, see TypeScript sandboxes.

Make it yours#

The app is in ~/claude-agent on the VM: agent.ts (the SDK options and the web server), public/index.html (the page), and agent.env (the model: sonnet, opus, or haiku). After a change, on the VM, run sudo systemctl restart claude-agent.

Because the SDK runs Claude Code, Claude Code's own files work too: instructions in ~/claude-agent/workspace/CLAUDE.md and skills in ~/claude-agent/workspace/.claude/skills/ apply to every conversation. To change the app, ask the chat (it knows where its code is), or SSH in and run claude in ~/claude-agent.

Share it#

On your computer:

ssh exe.dev share add my-claude-agent-sdk ann@example.com

Ann signs in at the same address and gets her own conversations, in the same workspace. Share only with people you'd trust with a terminal on this VM.

Keep it safe#

Your code and Claude's work are in ~/claude-agent; conversations are in ~/.claude/projects. Copy both off the VM now and then. On your computer:

mkdir -p claude-agent-backup
rsync -av --exclude node_modules my-claude-agent-sdk.exe.xyz:claude-agent my-claude-agent-sdk.exe.xyz:.claude/projects claude-agent-backup/

To restore on a new VM, follow "Install by hand", then put both back. On your computer:

rsync -av claude-agent-backup/claude-agent/ my-claude-agent-sdk.exe.xyz:claude-agent/
rsync -av claude-agent-backup/projects/ my-claude-agent-sdk.exe.xyz:.claude/projects/
ssh my-claude-agent-sdk.exe.xyz sudo systemctl restart claude-agent

Something odd? On the VM, journalctl -u claude-agent -n 50 shows what the app logged.

The nuts and bolts#

Happy clicking Deploy? You can stop here. These are the setup steps Shelley follows, plus the commands for later.

Install by hand

Create a VM#

On your computer (pick another name if my-claude-agent-sdk is taken):

ssh exe.dev new --name=my-claude-agent-sdk
ssh my-claude-agent-sdk.exe.xyz

All remaining installation commands run on the VM, as exedev.

Install Node.js 24#

The TypeScript SDK needs Node.js, and Node 24 runs .ts files as they are:

curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt-get install -y nodejs
node --version

Expect a version starting with v24.

Write the app#

The project, with the SDK pinned to 0.3.304, and its settings. The package brings its own Claude Code binary, separate from the VM's claude:

mkdir -p ~/claude-agent/workspace ~/claude-agent/public
cd ~/claude-agent
cat > package.json <<'EOF'
{
  "name": "claude-agent",
  "private": true,
  "type": "module",
  "dependencies": {
    "@anthropic-ai/claude-agent-sdk": "0.3.304"
  }
}
EOF
npm install --no-audit --no-fund
[ -e agent.env ] || cat > agent.env <<'EOF'
# Read at start: sudo systemctl restart claude-agent after a change.
# Models come from exe.dev's LLM integration; the key is a placeholder it replaces.
ANTHROPIC_BASE_URL=https://llm.int.exe.xyz
ANTHROPIC_API_KEY=implicit
# sonnet, opus, haiku, or a full model name such as claude-sonnet-5-5
CLAUDE_MODEL=sonnet
# gh goes through exe.dev's GitHub integration, if you add one
GH_HOST=github.int.exe.xyz
EOF

The app: each message becomes a query(), Claude works in workspace/ without permission prompts, and every step streams to the browser. Later messages resume the conversation from its transcript on disk. The web server answers only requests that come through exe.dev's HTTPS proxy, which names the signed-in person in X-ExeDev-Email, or from the VM itself:

cat > ~/claude-agent/agent.ts <<'EOF'
// A Claude Agent SDK app: a private web chat with Claude, working in ./workspace on this VM.
// SDK reference: https://code.claude.com/docs/en/agent-sdk/typescript
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { hostname } from "node:os";
import { join } from "node:path";
import { getSessionMessages, query, type Options } from "@anthropic-ai/claude-agent-sdk";

const dir = import.meta.dirname;
const workspace = join(dir, "workspace");
const vm = hostname();

// Every conversation gets these options. The VM is the sandbox, so Claude acts without asking.
const options: Options = {
  cwd: workspace,
  model: process.env.CLAUDE_MODEL || "sonnet",
  permissionMode: "bypassPermissions",
  allowDangerouslySkipPermissions: true,
  maxTurns: 100,
  systemPrompt: {
    type: "preset",
    preset: "claude_code",
    append: `You run on ${vm}, an exe.dev VM of your own, as exedev with sudo. ` +
      `This chat is ${dir}/agent.ts, served on port 8000 by claude-agent.service; restarting it ends your turn. ` +
      `To show the user a web app, run it as a systemd service on 0.0.0.0, port 3000 to 9999, and link ` +
      `https://${vm}.exe.xyz:<port>/. Only the VM's owner and people they share it with can open it.`,
  },
};

const busy = new Set<string>(); // sessions with a turn in progress

createServer(async (req, res) => {
  // Requests come through exe.dev's proxy, which names the signed-in person, or from the VM itself.
  const local = ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(req.socket.remoteAddress ?? "");
  if (!local && !req.headers["x-exedev-email"]) {
    res.writeHead(403).end(`Sign in at https://${vm}.exe.xyz/__exe.dev/login?redirect=/\n`);
    return;
  }
  const url = new URL(req.url ?? "/", "http://localhost");
  try {
    if (req.method === "GET" && url.pathname === "/") {
      res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
      res.end(await readFile(join(dir, "public", "index.html")));
    } else if (req.method === "GET" && url.pathname === "/history") {
      res.writeHead(200, { "content-type": "application/json" });
      res.end(JSON.stringify(await history(url.searchParams.get("session") ?? "")));
    } else if (req.method === "POST" && url.pathname === "/chat") {
      let body = "";
      for await (const chunk of req) body += chunk;
      const { message, session } = JSON.parse(body);
      if (session && busy.has(session)) {
        res.writeHead(409).end("Claude is still working on your last message. Reload in a moment.\n");
        return;
      }
      res.writeHead(200, { "content-type": "application/x-ndjson" });
      await chat(String(message), session, (event) => res.write(JSON.stringify(event) + "\n"));
      res.end();
    } else {
      res.writeHead(404).end("Not found\n");
    }
  } catch (err) {
    console.error(err);
    if (!res.headersSent) res.writeHead(500);
    res.end(`${err}\n`);
  }
}).listen(8000, "0.0.0.0", () => console.log(`Claude agent on port 8000, working in ${workspace}`));

// One turn of a conversation: Claude works until it's done, and every step goes to the browser.
// A new conversation starts without a session; later turns resume it from its transcript on disk.
async function chat(message: string, session: string | undefined, send: (event: object) => void) {
  if (session) busy.add(session);
  try {
    for await (const m of query({ prompt: message, options: { ...options, resume: session } })) {
      if (m.type === "system" && m.subtype === "init") {
        session = m.session_id;
        busy.add(session);
        send({ session });
      } else if (m.type === "assistant") {
        for (const block of m.message.content) {
          if (block.type === "text") send({ text: block.text });
          if (block.type === "tool_use") send({ tool: block.name, input: block.input });
        }
      } else if (m.type === "result") {
        send({ done: m.subtype, cost: m.total_cost_usd });
      }
    }
  } catch (err) {
    send({ error: String(err) });
  } finally {
    if (session) busy.delete(session);
  }
}

// A conversation so far, read back from its transcript in ~/.claude/projects.
async function history(session: string) {
  if (!/^[0-9a-f-]{36}$/.test(session)) return [];
  const items: object[] = [];
  for (const m of await getSessionMessages(session, { dir: workspace }).catch(() => [])) {
    const content = (m.message as any)?.content;
    if (m.parent_tool_use_id) continue;
    if (m.type === "user") {
      const text = typeof content === "string" ? content
        : (content ?? []).filter((b: any) => b.type === "text").map((b: any) => b.text).join("");
      if (text && !text.startsWith("<task-notification>")) items.push({ user: text }); // skip Claude Code's own notes
    } else {
      for (const b of content ?? []) {
        if (b.type === "text") items.push({ text: b.text });
        if (b.type === "tool_use") items.push({ tool: b.name, input: b.input });
      }
    }
  }
  if (busy.has(session)) items.push({ working: true });
  return items;
}
EOF

The page:

cat > ~/claude-agent/public/index.html <<'EOF'
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Claude agent</title>
<style>
  body { font: 16px/1.5 system-ui, sans-serif; max-width: 48rem; margin: 0 auto; padding: 0 1rem; }
  header { display: flex; justify-content: space-between; align-items: center; padding: .75rem 0; }
  #log > div { margin: .6rem 0; white-space: pre-wrap; overflow-wrap: anywhere; }
  .user { background: #f0eee6; padding: .5rem .75rem; border-radius: .5rem; }
  .tool { color: #666; font: 13px/1.4 ui-monospace, monospace; }
  .error { color: #b3261e; }
  form { position: sticky; bottom: 0; display: flex; gap: .5rem; padding: .75rem 0; background: #fff; }
  textarea { flex: 1; font: inherit; padding: .5rem; }
</style>
<header><strong>Claude agent</strong><button id="new">New chat</button></header>
<div id="log"></div>
<form id="form">
  <textarea id="input" rows="3" placeholder="Ask Claude to do something on this VM (Enter to send)"></textarea>
  <button>Send</button>
</form>
<script type="module">
const log = document.getElementById("log");
const input = document.getElementById("input");
const form = document.getElementById("form");
let session = localStorage.getItem("session");

function show(item) {
  const div = document.createElement("div");
  if (item.user) { div.className = "user"; div.textContent = item.user; }
  else if (item.text) div.textContent = item.text;
  else if (item.tool) {
    const i = item.input ?? {};
    const what = String(i.command ?? i.file_path ?? i.pattern ?? i.url ?? i.description ?? "");
    const line = what.split("\n")[0].slice(0, 200);
    div.className = "tool";
    div.textContent = `▸ ${item.tool} ${line}${line.length < what.length ? " …" : ""}`;
  }
  else if (item.done) { div.className = "tool"; div.textContent = `✓ ${item.done}, $${(item.cost ?? 0).toFixed(4)}`; }
  else if (item.working) { div.className = "tool"; div.textContent = "… still working. Reload to see more."; }
  else if (item.error) { div.className = "error"; div.textContent = item.error; }
  else return;
  log.append(div);
  div.scrollIntoView({ block: "end" });
}

if (session) for (const item of await (await fetch(`history?session=${session}`)).json()) show(item);

document.getElementById("new").onclick = () => {
  session = null;
  localStorage.removeItem("session");
  log.replaceChildren();
};
input.onkeydown = (e) => {
  if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); }
};
form.onsubmit = async (e) => {
  e.preventDefault();
  const message = input.value.trim();
  if (!message) return;
  input.value = "";
  show({ user: message });
  const res = await fetch("chat", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ message, session }),
  });
  if (!res.ok) return show({ error: await res.text() });
  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = "";
  for (let chunk; !(chunk = await reader.read()).done;) {
    const lines = (buffer + chunk.value).split("\n");
    buffer = lines.pop();
    for (const line of lines) {
      const item = JSON.parse(line);
      if (item.session) localStorage.setItem("session", (session = item.session));
      show(item);
    }
  }
};
</script>
</html>
EOF

Start it#

systemd starts the app at boot and restarts it whenever it stops:

sudo tee /etc/systemd/system/claude-agent.service >/dev/null <<'EOF'
[Unit]
Description=Claude Agent SDK app
Wants=network-online.target
After=network-online.target

[Service]
User=exedev
WorkingDirectory=/home/exedev/claude-agent
EnvironmentFile=/home/exedev/claude-agent/agent.env
Environment=PATH=/home/exedev/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStart=/usr/bin/node agent.ts
Restart=always
RestartSec=2

[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now claude-agent
until curl -fso /dev/null localhost:8000/; do sleep 1; done
systemctl is-enabled claude-agent

Expect enabled. Now ask Claude something:

curl -sN localhost:8000/chat -H 'Content-Type: application/json' \
  -d '{"message":"Reply with exactly: pong"}'

Expect {"session":"…"}, {"text":"pong"}, and {"done":"success","cost":…}, one per line.

Check it remembers#

Start a conversation, restart the app, and carry on with it:

session=$(curl -sN localhost:8000/chat -H 'Content-Type: application/json' \
  -d '{"message":"Remember the word kiwi. Reply with exactly: ok"}' | head -1 | jq -r .session)
sudo systemctl restart claude-agent
until curl -fso /dev/null localhost:8000/; do sleep 1; done
curl -sN localhost:8000/chat -H 'Content-Type: application/json' \
  -d '{"message":"Which word did I ask you to remember?","session":"'"$session"'"}'

Expect a reply with kiwi in it.

Update

The SDK changes often, and it's pre-1.0, so read the changelog first. Then, on the VM:

cd ~/claude-agent
npm install @anthropic-ai/claude-agent-sdk@latest --no-audit --no-fund
sudo systemctl restart claude-agent

Run the pong check from "Start it" again. To go back, put the old version in package.json and run npm install.

Troubleshooting

On the VM, journalctl -u claude-agent -n 50 shows the app's errors.

  • The page says "Sign in at…": the request didn't come through the exe.dev proxy with a signed-in person. Open the link it gives.
  • Replies fail with an authentication or model error: check that the LLM integration reaches this VM with curl -s https://reflection.int.exe.xyz/integrations, and that CLAUDE_MODEL in agent.env is a model it offers.
  • "Claude is still working on your last message": a turn is still running. Reload the page to see how far it got, and send your message when it's done.

Frequently asked questions#

What's the difference between the Claude Agent SDK and Claude Code?

The SDK is Claude Code as a library: the same agent loop and tools, driven by your code instead of a terminal. The npm package carries its own Claude Code binary and runs it as a subprocess for each conversation. Want Claude Code in a terminal instead? It's preinstalled: see Claude Code on exe.dev.

Do I need an Anthropic API key?

No. Requests go through exe.dev's LLM integration and are billed to your exe.dev account. To use your own key, set ANTHROPIC_API_KEY to it in ~/claude-agent/agent.env, delete the ANTHROPIC_BASE_URL line, and run sudo systemctl restart claude-agent.

Is it safe to let Claude run commands without asking?

On this VM, yes, within limits: that's what the sandbox is for. Claude works as exedev, not root, on a machine with none of your laptop's files or keys, and the LLM integration keeps the model key off it. The worst case is a broken VM. Don't put secrets on it you wouldn't hand Claude, and keep it private.

How is this different from E2B, Daytona, or Vercel Sandbox?

Those hand your code short-lived containers to drive through an API. This VM persists: its disk, its HTTPS address, and the apps Claude starts are still there tomorrow. For short-lived runs, new --sandbox makes a VM billed by the second, and the TypeScript SDK drives them from code.

Can I write the agent in Python?

Yes. The Python package is claude-agent-sdk (uv add claude-agent-sdk), with the same options in snake_case. Ask the chat to port agent.ts; it can see its own code.

What does it cost?

Your VM plan plus model usage, which the page shows after every reply. maxTurns: 100 in agent.ts stops a runaway task; Anthropic suggests about 1 GiB of memory and one CPU per agent working at once.

Can several people use it?

Yes. Share the VM: each browser keeps its own conversations, and everyone's Claude works in the same workspace.

Updated Oct 20, 2026 · tested end to end Oct 20, 2026 · Markdown · manifest.json · Something wrong? Tell us