Skip to content

Agent SDK vs Messages API, Tool Runner, Claude Code, Managed Agents

CoreConceptJuly 31, 202613 min read

A team building an internal tool that reviews pull requests, runs the test suite, and posts a summary comment reaches for "Claude" and finds five different starting points: the raw Messages API, a beta tool_runner helper, the Claude Agent SDK, the Claude Code CLI itself, or a hosted product called Managed Agents. They all call the same models. None of the marketing copy makes it obvious which one saves you six weeks of infrastructure work and which one quietly limits what you can customize later. Anthropic's own developer docs actually draw this comparison directly — a table titled "Compare the Agent SDK to other Claude tools" — because the confusion is common enough to earn its own doc section.

This article works through that comparison mechanistically rather than as a feature checklist: for each layer, what does Anthropic's code run for you, what do you still have to write yourself, and who owns the process the agent actually executes in. One example — an internal PR-review agent we'll call DiffBot, working against pull request #482 in a billing-service repository — runs through every layer, including one intentional failure (DiffBot tries to read a secrets file it shouldn't) and how each layer would catch or miss it. By the end you should be able to place a new project on this stack from one question: what am I refusing to build myself?

Five layers of Claude developer tooling, from raw prompting to a fully hosted agent
Five layers of Claude developer tooling, from raw prompting to a fully hosted agent

Map the developer tool surface

Every option here calls the same models and produces the same kind of output: text, or a request to use a tool. What actually differs is two separable questions. First, who runs the agentic loop — the repeated cycle of send a request, read whether the model asked for a tool, run that tool, send the result back, and decide whether to continue. Second, who owns the execution environment the tools run in — your laptop, your server, your CI runner, or a sandbox Anthropic provisions and holds open for you. Every layer in this article answers both questions differently, and that pair of answers is a better decision axis than "which one is more powerful," because more built-in behavior always means less of your own control over exactly how it behaves.

Running example for this article: DiffBot, an internal agent whose job is to review a pull request in billing-service, run the existing test suite, and post one comment summarizing risk. Input: PR #482 adds POST /v1/refunds. Expected success: DiffBot reads the diff, runs pytest, and posts a comment that names the endpoint, the tests that ran, and any concern. Intentional failure: the same commit also touches billing-service/secrets/rotation.yaml — an unrelated file caught in the PR by accident — and while investigating context, DiffBot reasons it needs to read that file too. We'll trace what happens to that read request at each layer, because it's the cleanest way to see what each one actually enforces versus what it merely permits.

Five ways to build with Claude, and what each one runs for you
LayerWho runs the loopWho owns the sandboxBest for
Messages APIYour code, one call at a timeWherever your code runsOne-off calls or a fully custom loop
Tool RunnerSDK helper, in your processWherever your code runsAutomate the loop, keep full control of tools
Agent SDKSDK harness, in your processWherever your process runsFull agent behavior, embedded in your app
Claude CodeSame harness, as a CLI productYour terminal / CI runnerInteractive dev, one-off tasks, headless scripts
Managed AgentsAnthropic's infrastructureAnthropic-managed or self-hosted sandboxLong-running or async agents, no infra to host

Quick reference

  • "Agent loop" here means specifically: request → check for tool_use → execute → append result → request again, until the model replies with plain text.
  • The five layers are not mutually exclusive in a company: a CI script might use the Messages API for a single classification call while a separate internal tool runs on the Agent SDK.
  • Claude Code and the Agent SDK share the same underlying agent loop, tools, and permission system — Claude Code is that harness shipped as a terminal product, not a different implementation.
  • For the broader vocabulary these layers sit inside — prompt vs context vs harness as concepts — see Prompt vs Context vs Harness.

Remember this

Rank the five layers by two questions, not one: who executes the loop, and who owns the sandbox the tools run in — power and lock-in both increase together as Anthropic takes over more of each.

The Messages API: you own the loop

The Messages API is direct model access: you send a list of messages and a tool schema, and the model replies with text or a tool_use block naming a tool and its input. That's the entire contract. There is no built-in concept of "keep going until done" — if the model asks for a tool, your code has to notice, execute the tool yourself, format a tool_result block, append it to the message list, and call the API again. If DiffBot needs to read the diff, then run tests, then post a comment, that's three separate tool_use/tool_result round trips your code drives explicitly, one while loop iteration at a time.

This is the right layer when the agentic loop is not actually the hard part of the problem. A serverless function that makes exactly one tool call per invocation, a system with an unusual retry or cost-control policy that a generic loop would fight against, or a codebase that already has its own orchestration (a workflow engine, a state machine) and just needs Claude to fill in one step — all of these want the Messages API directly, because a helper loop would be one more layer to work around rather than a saving. The cost is that context management, error shaping, and stopping conditions are entirely yours: nothing compacts a growing transcript, nothing retries a truncated response, and nothing stops DiffBot from looping forever if you forget a termination check.

For DiffBot's intentional failure — the attempted read of secrets/rotation.yaml — the Messages API enforces nothing. It has no concept of a permission system; read_file is just a tool name in a schema, and if your read_file function opens whatever path it's given, DiffBot reads the secret. Any safety here is a check your own tool-execution code has to write, every single time, for every tool.

Messages API: your code drives every step of the loop
Messages API: your code drives every step of the loop

Quick reference

  • Nothing loops automatically — a single-call app can stop after one response; a multi-step agent needs you to write the while has tool_use loop yourself.
  • Context compaction, exponential backoff on rate limits, and truncated-response retries are all your responsibility at this layer.
  • Path/permission checks like is_allowed_path above exist only because you wrote them into the tool-execution function — the API has no opinion.
  • This is the layer every other option in this article is built on top of; reading the [manual tool-use loop docs](https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls) is worthwhile even if you never use it directly, since it's what the higher layers are automating.
Claude requests a tool
1response = client.messages.create(2    model="claude-opus-5",3    max_tokens=1024,4    tools=[read_file_schema, run_tests_schema],5    messages=[{"role": "user", "content": "Review PR #482 in billing-service."}],6)7# response.content may include a tool_use block:8# {"type": "tool_use", "name": "read_file", "input": {"path": "secrets/rotation.yaml"}}
Your loop runs the tool and continues
1for block in response.content:2    if block.type == "tool_use":3        if not is_allowed_path(block.input["path"]):   # you write this check4            result = {"error": "denied: path outside review scope"}5        else:6            result = read_file(block.input["path"])7        messages.append({"role": "assistant", "content": response.content})8        messages.append({"role": "user", "content": [9            {"type": "tool_result", "tool_use_id": block.id, "content": str(result)}10        ]})11        response = client.messages.create(model="claude-opus-5", max_tokens=1024,12                                           tools=[...], messages=messages)  # call again

Remember this

The Messages API gives you exactly the model call and nothing else — every loop iteration, retry, and safety check is code you write and can therefore get wrong.

Tool Runner: the loop without the harness

client.beta.messages.tool_runner() (TypeScript: client.beta.messages.toolRunner()) is a beta SDK helper, available in the Python, TypeScript, C#, Go, Java, PHP, and Ruby SDKs, that automates exactly the loop from the previous section: it runs your tools when Claude calls them, handles the request/response cycle, and manages conversation state, so you stop writing the while loop and the manual tool_result formatting by hand. You still define the tools — a read_file function, a run_tests function — using SDK helpers like Python's @beta_tool decorator or TypeScript's betaZodTool(), and the runner calls them for you when the model asks.

What it does not add is anything about what those tools are allowed to do, where they run, or who approves a risky call before it executes. There's no permission system, no built-in file/bash/web tools, no subagents, and no sandbox — DiffBot's read_file tool in the Tool Runner is exactly the same function you'd write for the raw Messages API, just invoked by the SDK's loop instead of your own. If that function opens secrets/rotation.yaml because you didn't add a path check, the Tool Runner runs it exactly as readily as it runs a legitimate diff read; the runner automates calling your tool, not judging it. The one place it gives you a hook is generate_tool_call_response() (Python) / generateToolResponse() (TypeScript), which lets you inspect a tool's result — including whether it errored — before the runner sends it back to Claude, which is where you'd bolt on an ad hoc approval check if you wanted one.

Tool Runner: the SDK drives the loop, you still own the tools
Tool Runner: the SDK drives the loop, you still own the tools

Quick reference

  • Iterating the runner (for message in runner) gives you every intermediate turn; calling until_done()/await runner skips straight to the final message when you don't need the play-by-play.
  • A thrown exception inside your tool becomes a tool_result with is_error: true automatically — Claude sees the failure and can retry or change approach without your loop crashing.
  • Client-side compaction in the Python/TypeScript/Ruby runners is deprecated in favor of server-side [context editing](https://platform.claude.com/docs/en/build-with-claude/context-editing), available across every SDK regardless of the runner.
  • For patterns on making the tools themselves resilient to malformed or repeated calls — a concern the runner doesn't solve — see Tool Calling Reliability Patterns.
Define tools with the SDK helper
1from anthropic import Anthropic, beta_tool2 3client = Anthropic()4 5@beta_tool6def read_file(path: str) -> str:7    """Read a file from the billing-service repo checkout.8 9    Args:10        path: Repo-relative path to read11    """12    if not path.startswith(("src/", "tests/")):13        raise ValueError(f"path outside review scope: {path}")14    return open(f"./billing-service/{path}").read()
Let the runner drive the loop
1runner = client.beta.messages.tool_runner(2    model="claude-opus-5",3    max_tokens=1024,4    tools=[read_file, run_tests],5    messages=[{"role": "user", "content": "Review PR #482 in billing-service."}],6)7final_message = runner.until_done()  # runner appends tool_use/tool_result for you8# read_file("secrets/rotation.yaml") raises ValueError -> runner catches it,9# returns it to Claude as a tool_result with is_error: true

Remember this

Tool Runner removes the loop bookkeeping, not the trust boundary — a tool you write for it is exactly as safe or unsafe as the same function called by hand, because the runner never sees your tool's internals.

Claude Agent SDK: the harness in your process

The Claude Agent SDK (@anthropic-ai/claude-agent-sdk for TypeScript, claude-agent-sdk for Python) is a different kind of layer than the previous two: instead of giving you a loop to run your own tools through, it gives you the actual agent loop, built-in tools, and permission system that power Claude Code, as a library you embed in your own process. Both SDKs bundle a native Claude Code binary and drive it over a local protocol, so Read, Write, Edit, Bash, Glob, Grep, WebSearch, and WebFetch all exist out of the box — DiffBot doesn't need a hand-written read_file function; it gets a real filesystem tool with the same semantics Claude Code itself uses.

The part that matters for DiffBot's intentional failure is the permission system, and specifically its evaluation order: a request to use a tool is checked against hooks first, then deny rules, then ask rules, then the active permission mode, then allow rules, and only then falls through to your own canUseTool callback. Deny rules win regardless of mode — a disallowed_tools=["Read(secrets/**)"] rule blocks DiffBot's read of secrets/rotation.yaml even if the session is running in acceptEdits mode, and even under bypassPermissions, which otherwise auto-approves everything else. That ordering is a real design decision, not an implementation detail: it means the one rule you write to keep an agent out of a directory holds even when you've deliberately loosened everything else for speed, which is exactly the property you want from a permission system and exactly the property the Tool Runner and Messages API don't give you for free.

Subagents, added through AgentDefinitions, and MCP client support round out what the SDK adds versus Tool Runner: a bounded, separately-scoped sub-task (see Subagents Explained) and a standard way to plug in external tool servers (see MCP vs A2A vs ACP) both come for free. What you still own is everything about deployment: the SDK runs inside your process, on your infrastructure, and only ships for Python and TypeScript — driving it from another language means running the CLI as a subprocess with -p --output-format json rather than calling a native library.

One tool call, six checks: Read(secrets/rotation.yaml) is denied at step 2 before the permission mode ever runs
One tool call, six checks: Read(secrets/rotation.yaml) is denied at step 2 before the permission mode ever runs

Quick reference

  • Permission modes span a real range: default requires your canUseTool callback for anything unmatched, plan never auto-approves edits, acceptEdits auto-approves file writes, and bypassPermissions approves almost everything except explicit deny/ask rules and hooks.
  • A bare deny entry like disallowed_tools=["Bash"] removes the tool from the model's context entirely; a scoped one like Bash(rm *) keeps Bash available and blocks only matching calls — the two behave differently, not just at different granularity.
  • Subagents inherit the parent's permission mode by default; a parent running bypassPermissions grants that same unrestricted mode to every subagent it spawns unless the subagent's own definition overrides it.
  • Sessions, skills, commands, and memory load from .claude/ and ~/.claude/ exactly as they do in Claude Code — an SDK agent and a Claude Code session can share the same project configuration.
Options: deny the secret, accept edits elsewhere
1from claude_agent_sdk import query, ClaudeAgentOptions2 3options = ClaudeAgentOptions(4    allowed_tools=["Read", "Bash", "Glob"],5    disallowed_tools=["Read(billing-service/secrets/**)"],6    permission_mode="acceptEdits",   # fast iteration on non-secret paths7)
What DiffBot sees when it tries the secret
1# Claude requests Read(billing-service/secrets/rotation.yaml)2# -> hooks: none configured, passes through3# -> deny rules: "Read(billing-service/secrets/**)" matches -> BLOCKED4# -> (permission mode, allow rules, canUseTool never evaluated for this call)5#6# tool_result: {"is_error": true, "content": "permission denied: secrets/**"}7# DiffBot reads the denial like any other observation and asks a human8# to confirm rotation.yaml separately, instead of retrying the read.

Remember this

The Agent SDK's permission system checks deny rules before it checks the permission mode — a single deny rule holds even when you've turned everything else to auto-approve for speed.

Claude Code: the SDK as a finished product

Claude Code is not a separate implementation of the agent loop — it's the same harness the Agent SDK exposes as a library, packaged as a terminal product with its own UX on top: interactive sessions you can resume, /compact and other slash commands, and a config-loading convention (CLAUDE.md, .claude/settings.json) the SDK reuses. Anthropic's own comparison puts it plainly: reach for Claude Code when you're doing interactive development or running one-off tasks from a terminal, and reach for the Agent SDK when you're building an agent without implementing the tool loop yourself, as a library inside your own application. The internals of that loop, its context compaction, and its permission modes are covered in depth in How Claude Code Works; the session lifecycle — resuming, branching, and worktrees — is covered in Managing Sessions in Claude Code.

For DiffBot specifically, this is often the layer people skip past without noticing it's an option: if the review doesn't need to be embedded in another application — no custom UI, no webhook trigger from your own service — you don't need SDK code at all. A CI step can run Claude Code headless (claude -p "Review PR #482 in billing-service: read the diff, run pytest, post a summary" --output-format json) and parse the JSON result, getting the exact same permission system, built-in tools, and deny-rule enforcement as the SDK example above, with zero lines of Python or TypeScript to maintain. The trade is UX and branding, not capability: Claude Code's terminal interface and its name are fixed, whereas a product built on the Agent SDK is expected to carry its own branding — Anthropic's SDK terms explicitly prohibit calling a third-party product "Claude Code," reserving that name for the terminal product itself.

The decision between the two, in practice, rarely comes down to the agent loop — it's identical either way. It comes down to whether a human is meant to sit in a terminal (Claude Code) or whether the review needs to be triggered by your own code and its result consumed programmatically inside your own product (Agent SDK).

Same agent loop, two different products around it
Same agent loop, two different products around it

Quick reference

  • Headless mode (-p + --output-format json) is also the documented way to drive the same agent loop from a language the SDK doesn't ship for — you shell out to the CLI as a subprocess instead of calling a library.
  • Config loading is shared: CLAUDE.md, .claude/settings.json permission rules, skills, and slash commands work the same whether the session started from claude in a terminal or query() in an SDK script.
  • Branding is a real constraint, not just style: SDK-based products may say "Claude Agent" or "{YourAgentName} Powered by Claude," but not "Claude Code" or Claude Code's visual identity.
  • If DiffBot's only trigger is "a person types a command," building it on the Agent SDK instead of just running Claude Code interactively is added engineering effort with no corresponding capability gain.

Remember this

Claude Code and the Agent SDK run the identical loop — the choice between them is who or what triggers the session, a human in a terminal versus your own application code, not what the agent can do.

Managed Agents: when Anthropic also hosts the sandbox

Claude Managed Agents (beta, managed-agents-2026-04-01) is a separate hosted REST API, not a mode of the Agent SDK — Anthropic's docs list it alongside the Messages API as one of "two ways to build with Claude," precisely because it removes the thing every earlier layer still required: a process of yours that stays running. It's built around four resources: an Agent (model, system prompt, tools, MCP servers, skills), an Environment (a cloud sandbox Anthropic provisions, or a self-hosted sandbox on your own infrastructure for data-residency or compliance needs), a Session (one running task inside that environment), and Events (the messages exchanged with it, delivered over server-sent events). You create each resource with a REST call, then stream a session's events instead of holding a socket or a worker process open yourself.

For DiffBot, this layer earns its keep specifically for long or asynchronous work: a review that has to wait on a slow CI run, gets steered mid-execution ("also check the migration file"), or runs on a schedule rather than on demand. Anthropic provisions the sandbox, runs bash and file tools inside it, persists the filesystem and conversation history across the session, and streams agent.message / agent.tool_use / session.status_idle events back to you — none of it is your infrastructure to keep alive. Because sessions are stateful and long-running by design, Managed Agents is a genuine trade, not a strict upgrade: it's currently outside Zero Data Retention and HIPAA BAA eligibility, which matters if DiffBot ever touches anything more sensitive than a public repo's diff, and self-hosting the sandbox is the documented way to keep execution inside your own network boundary while still using the managed session/event layer.

Managed Agents: four REST resources, no process of yours stays up
Managed Agents: four REST resources, no process of yours stays up

Quick reference

  • The four resources map cleanly onto the earlier layers: Agent ≈ your system prompt and tool config, Environment ≈ the sandbox the Agent SDK would otherwise run in your own process, Session ≈ one query() call, Events ≈ the streamed messages you'd iterate in the SDK.
  • Self-hosted sandboxes let you keep tool execution inside your own network for data that can't leave it, while still using Anthropic's session and event infrastructure — a middle point between fully managed and fully self-run.
  • Scheduled deployments run a Managed Agent on a cron schedule, which is the closest match for "review every PR opened overnight" without a worker process of yours polling for new PRs.
  • Because it's stateful server-side by design (persisted history, sandbox state, outputs), Managed Agents is not currently eligible for Zero Data Retention or a HIPAA BAA — check current data-retention scope before routing anything regulated through it.
Create the agent and environment (curl)
1curl -sS https://api.anthropic.com/v1/agents \2  -H "x-api-key: $ANTHROPIC_API_KEY" \3  -H "anthropic-version: 2023-06-01" \4  -H "anthropic-beta: managed-agents-2026-04-01" \5  -H "content-type: application/json" \6  -d '{7    "name": "DiffBot",8    "model": "claude-opus-5",9    "system": "Review pull requests in billing-service. Never read secrets/**.",10    "tools": [{"type": "agent_toolset_20260401"}]11  }'12# -> {"id": "agent_...", "version": 1}13 14curl -sS https://api.anthropic.com/v1/environments \15  -H "x-api-key: $ANTHROPIC_API_KEY" -H "anthropic-version: 2023-06-01" \16  -H "anthropic-beta: managed-agents-2026-04-01" -H "content-type: application/json" \17  -d '{"name": "diffbot-env", "config": {"type": "cloud", "networking": {"type": "unrestricted"}}}'18# -> {"id": "env_..."}
Start a session, send the review event
1curl -sS https://api.anthropic.com/v1/sessions \2  -H "x-api-key: $ANTHROPIC_API_KEY" -H "anthropic-version: 2023-06-01" \3  -H "anthropic-beta: managed-agents-2026-04-01" -H "content-type: application/json" \4  -d '{"agent": "agent_...", "environment_id": "env_...", "title": "Review PR #482"}'5# -> {"id": "session_..."}6 7curl -sS https://api.anthropic.com/v1/sessions/session_.../events \8  -H "x-api-key: $ANTHROPIC_API_KEY" -H "anthropic-version: 2023-06-01" \9  -H "anthropic-beta: managed-agents-2026-04-01" -H "content-type: application/json" \10  -d '{"events": [{"type": "user.message",11       "content": [{"type": "text", "text": "Review PR #482: read the diff, run pytest, summarize risk."}]}]}'12# stream /sessions/session_.../stream for agent.message / agent.tool_use / session.status_idle

Remember this

Managed Agents trades a process of yours (server, worker, sandbox) for a REST session you stream — the honest cost of that trade today is reduced data-retention eligibility, not a capability gap.

Key takeaway

Every layer in this article runs the same kind of model call underneath; what actually separates them is how much of the loop, the tool implementations, the permission decisions, and the hosting Anthropic writes for you versus what you write yourself — and each step up that ladder trades some of your control for less code to maintain. Start from the Messages API only when the loop genuinely isn't the hard part. Reach for Tool Runner when you want the loop automated but every tool is bespoke to your system. Reach for the Agent SDK when you need built-in tools, a real permission system, and subagents inside your own application. Use Claude Code as-is when a human or a CI step triggering it interactively is enough. Reach for Managed Agents when the task outlives a request/response cycle and you'd rather not host the sandbox yourself.

Practice (25 min): Build DiffBot at two layers and compare what each one enforces. First, with the Agent SDK: write a ClaudeAgentOptions with allowed_tools=["Read", "Bash", "Glob"], disallowed_tools=["Read(secrets/**)"], and permission_mode="acceptEdits", then ask it to review a small local repo with a secrets/ folder present. Expected result: DiffBot reads source files and runs tests freely, and a transcript entry shows a denied Read(secrets/...) call. Now intentionally break it by removing the disallowed_tools entry and rerunning the same prompt — confirm the read now succeeds, proving the deny rule (not the model's judgment) was what blocked it. Recover by re-adding the rule and adding a PreToolUse hook that also blocks the pattern, so the same protection holds even under bypassPermissions. Pass when you can point to the specific line — the deny rule or the hook — that would have to change for DiffBot to read the secret, rather than trusting the model to decline on its own.

Share:

Related Articles

When developers migrate from prototyping with raw LLM API calls to embedding autonomous agent workflows into production

Read

Debugging web applications solely from backend source code often misses critical client-side failures: CORS errors, unha

Read

An AI coding assistant confined strictly to local file editing misses half of a modern software engineer's environment:

Read

Explore this topic

Keep learning

Follow a structured path or browse all courses to go deeper.