Blog
Mar 28, 2026-11 MIN READ
Running Coding Agents in Disposable Docker Containers

Running Coding Agents in Disposable Docker Containers

An agent that can edit files and run shell commands needs a blast radius. One container per task, a hard budget cap, and a control server inside each container so you can talk to a running agent — built on the Claude Agent SDK.

By Baljeet Singh

Give an agent Read, Edit, Write and Bash and it becomes genuinely useful. It also becomes something running shell commands on your machine, in a loop, at whatever pace the model decides.

Most agent demos run in your terminal, in your repo, with your credentials. That's fine for a demo. It is not fine for something you leave running while you make tea, or for a task you want three of at once.

So: one Docker container per task. An orchestrator that spawns them, a hard budget cap inside each one, and a small HTTP server in every container so you can talk to a running agent instead of only watching it.

Here is the shape.

The Smallest Version

The Claude Agent SDK is an async iterator. This is a complete agent:

import { query } from "@anthropic-ai/claude-agent-sdk";

for await (const message of query({
  prompt: "Read /project/index.js and add error handling to the routes.",
  options: {
    tools: ["Read", "Edit", "Write", "Glob", "Bash"],
    allowedTools: ["Read", "Edit", "Write", "Glob", "Bash"],
    permissionMode: "acceptEdits",
    model: "claude-sonnet-4-6",
    maxTurns: 50,
    maxBudgetUsd: 1.0,
    cwd: "/project",
  },
})) {
  if (message.type === "assistant" && message.message?.content) {
    for (const block of message.message.content) {
      if ("text" in block) console.log(block.text);
      if ("name" in block) console.log(`Tool: ${block.name}`);
    }
  } else if (message.type === "result") {
    console.log("\n--- Done ---");
  }
}

That is the whole loop. The SDK handles tool execution — you don't write a dispatch switch, you declare which tools it may use and it runs them.

Two of those options are easy to get wrong, and I had both wrong to start with.

tools restricts; allowedTools only auto-approves. They read like synonyms and they are not. allowedTools is the list that runs without stopping to ask permission — leaving a tool out of it does not take the tool away, it just means the agent gets prompted for it. The option that decides what exists at all is tools. If you are trying to hand an agent a smaller surface, allowedTools alone will not do it, and nothing will tell you.

maxTurns and maxBudgetUsd are where the caps actually live. Setting them in the environment and reading them into a config object does nothing; they have to reach query(). When either is hit the SDK stops the run and ends the stream with a result whose subtype is error_max_turns or error_max_budget_usd. It does not throw — so if your loop only watches for exceptions, a run that burned through its budget looks exactly like one that finished, and you will believe a cap is holding when you have never once seen it fire. Check message.subtype, and record message.total_cost_usd while you are there.

Note permissionMode: "acceptEdits". The agent edits files without asking. In your terminal that is a decision you should think hard about. Inside a throwaway container with one directory mounted, it's just how the thing works.

Two Processes, Not One

The architecture is an orchestrator that is always up, and agent containers that are not.

The orchestrator serves a web UI and manages containers:

POST   /tasks              → docker run, one new agent container
POST   /tasks/:id/message  → proxy a follow-up to that container
GET    /tasks/:id/events   → poll the agent, push to the browser over SSE
DELETE /tasks/:id          → shut the agent down, stop and remove

It needs the Docker socket to do that, which is the one genuinely sharp edge in the whole design:

volumes:
  - /var/run/docker.sock:/var/run/docker.sock

Mounting the Docker socket into a container gives that container control of your Docker daemon. It's how the orchestrator creates siblings, and it is effectively root on the host. Fine for a local tool on your own machine. Not something to expose to a network without thinking about it properly.

Each agent container runs the loop plus a tiny Express control server on port 8080:

GET    /health     → is it up
GET    /status     → events since cursor N
POST   /message    → queue a follow-up
POST   /pause      → stop after the current turn
DELETE /shutdown   → exit the loop cleanly

The control server and the agent loop share one in-process state module — status, an event list, a message queue. That's the entire coordination mechanism. No Redis, no database.

Why a Server Inside the Container

This is the part that took me longest to appreciate.

A one-shot agent is easy: pass a prompt, wait, read the output. But agents get stuck, or go the wrong way, or finish and leave you wanting one more thing. Without a way in, your only options are watch or kill.

The queue changes that. A follow-up arrives on POST /message while query() is mid-run. It goes into messageQueue. When the current query finishes, the loop drains the queue and starts another query() with the follow-up as the prompt. The container stays alive between turns.

So a task is a conversation with a process, not a single invocation. The container lives until you delete it.

The Caps Nobody Adds

Two environment variables that should be in every agent you write:

environment:
  - MAX_BUDGET_USD=${MAX_BUDGET_USD:-1.0}
  - MAX_TURNS=${MAX_TURNS:-50}

An agent loop is a while loop with an API bill attached. The failure mode is not that it breaks, it's that it doesn't — it keeps trying, politely, until you notice.

A dollar cap and a turn cap cost nothing to add and they are the difference between a bad afternoon and a bad invoice. Add them before you add anything else.

Coding Mode and General Mode

The same container runs two kinds of agent, chosen at task creation. The difference is the system prompt and the tool set.

Coding mode gets the filesystem and shell tools, plus git. It is pointed at a mounted project directory and expected to leave changes in it.

General mode gets a narrower set. It answers questions, fetches things, produces text. It doesn't need Edit and shouldn't have it.

Alongside the built-in tools, both get MCP tools for talking back:

  • send_update — post progress to the event stream
  • request_clarification — ask the user something and wait
  • git_status — what have I actually changed

request_clarification is the interesting one, because it makes the agent a participant rather than a batch job. It writes a question to the event stream and blocks. The user answers through the same message queue a follow-up uses. The agent continues.

Splitting modes is not about capability, it's about the smallest tool set that can do the job. An agent with Bash will find a way to use Bash.

Which is exactly why the split has to happen in tools and not only in the prompt. Mine did not, for longer than I would like: both modes were handed the same hardcoded tool array and only the system prompt and the MCP tools varied, so "general mode" had Edit, Write and Bash the whole time. The mode was a suggestion. If you build this, branch the tools array on the mode and confirm it from the outside — ask general mode to write a file and watch it refuse, rather than trusting that it won't try.

Hooks, for Output Discipline

The SDK fires hooks around every tool call. Two are worth wiring up immediately:

  • PreToolUse — log the call. This is your entire audit trail, and you'll want it the first time an agent does something surprising.
  • PostToolUse — truncate output over ~5,000 characters.

The truncation matters more than it sounds. One cat of a large file, or a grep across a monorepo, and the result goes straight into the context window. A few of those and you have burned the budget on tool output nobody reads. Truncating at the boundary is much easier than teaching the model to be careful.

Streaming It to a UI

The orchestrator polls each agent's /status?since=N roughly once a second and pushes new events to the browser over Server-Sent Events.

Polling inside, SSE outside. It isn't elegant, and it's completely fine: the cursor makes the poll cheap and idempotent, SSE is one line in the browser, and the whole thing is debuggable with curl. Reach for websockets when you have a reason.

What Here Is Claude-Specific, and What Isn't

Everything above is built on the Claude Agent SDK, and the obvious question is how much of it survives a change of SDK. I went and looked, because I assumed the answer was "you'd have to write the tools yourself" and that turned out to be wrong.

The container half is not Anthropic-specific at all. One container per task, the mounted project directory, the disposable blast radius, the control server, the message queue, the event cursor, the SSE fan-out — none of that knows what model is inside. That is the part worth stealing, and it is most of the design.

Every major SDK ships coding tools. This is the assumption I had backwards. It is not the case that Anthropic gives you a filesystem agent and everyone else leaves you to build one:

file + shell toolscustom tools
Claude Agent SDKRead, Edit, Write, Glob, Grep, Bashan MCP server via createSdkMcpServer
OpenAI Agents SDKbuilt-in shell, apply-patch and computer-use tools, plus sandbox tools bound to an isolated workspacetool() with a Zod schema
Google ADKExecuteBashTool (a validated bash command inside a workspace dir), EnvironmentToolset for shell and file I/Opass a plain function; it gets wrapped as a FunctionTool

So the tools port conceptually. Your own tool bodies port literally — mine are ordinary functions that shell out to git or fetch a URL, and nothing about them is Anthropic-shaped. What does not port is the wrapper: createSdkMcpServer is an MCP server, tool() wants Zod, ADK wants a typed Python function. Same logic, three adapters.

Which points at the actual lever. The more of an agent's capability lives in tools you defined, the less any SDK's built-ins matter. A model will use a tool you hand it whether or not its SDK ships an equivalent — so a run_tests or an apply_patch of your own is portable in a way that depending on Edit is not. The built-ins are where these three ecosystems differ most; the tools you wrote are where they differ least. If you expect to move, write more of your own than you strictly need to.

What actually doesn't port is the control surface, and that is where the work is:

  • The restrict-versus-approve split. tools limits what exists and allowedTools skips the permission prompt — two separate ideas in one SDK. OpenAI puts approval on the tool itself with needsApproval. Get this mapping wrong and you will believe you removed a tool when you only stopped it asking, which is the exact mistake earlier in this post.
  • The caps. maxTurns and maxBudgetUsd are Anthropic's, and I would not assume an equivalent exists elsewhere. If it doesn't, the cap moves into your loop — you accumulate cost per turn and break. Your container is the right place for that anyway, since it is the thing that can be killed.
  • How a run reports that it stopped. This SDK ends the stream with a result whose subtype says error_max_budget_usd, and does not throw. Another SDK may throw, may return a finish reason, may just stop. Whatever it does, that is the signal your orchestrator has to read before it marks a task complete.

The useful way to think about it: the container is the portable abstraction and the SDK contract is not. Keep the boundary at the HTTP control server — prompt in, events out, killable — and the thing inside becomes swappable. I have not done that swap yet, so treat this as a map rather than a report.

What This Does Not Solve

The Docker socket is real. The orchestrator can create and destroy containers on your host. Local-only.

Containers are isolation, not a sandbox. A container with your project mounted and network access can reach your network. For untrusted prompts you want no network by default and a stricter runtime than plain Docker.

Secrets still flow in. The API key goes into the container as an environment variable, and a mounted .env is readable by whatever runs inside. An agent with Bash can read it. Mount what the task needs and nothing else.

Publish the control port carefully, and put a token on it. The control server is what makes the follow-up loop work, and it has no authentication of its own. POST /message and DELETE /shutdown will do what they are told by whoever reaches them, and what they are told goes to an agent running in acceptEdits mode with Bash and a writable project mount.

Docker's -p 8080:8080 binds every interface by default, which is the part that catches people out — including me. Write -p 127.0.0.1:8080:8080 and a published port stops being a network service. Better still, put the orchestrator and the agents on one Docker network, address containers by name, and never publish the port to the host at all. Then add the shared secret anyway, because loopback is not a trust boundary on a machine you also run a browser on.

The same applies to the orchestrator itself. It mounts the Docker socket and POST /tasks creates containers, so it is the one thing on this list that absolutely must not be reachable from the network.

Why Bother

Because the container is the unit that makes the rest of it tractable. One task, one container, one mounted directory, one budget. Delete it and the state is gone.

Three of them can run at once without fighting over your working tree. The agent can be given permissionMode: "acceptEdits" without you flinching, because you can throw the whole thing away. And a hard budget cap turns "I left an agent running" from a story about money into a non-event.

The agent loop itself is twenty lines. Everything else here is deciding what it's allowed to touch, and how you get it back.

© 2019-2026 Baljeet Singh. All rights reserved.