← All writing

Deep dive

When should an agent write code instead of calling a tool?

Use host-run code for bounded data work, return unfamiliar judgment to the agent, and keep consequential operations as explicit governed calls.

When should an agent write code instead of calling a tool? — SproutPad

We ran into this question while adding code-mode guidance to SproutPad. An agent needed to inspect a large set of service records, combine them with project budgets, and return the few projects that needed attention. Calling a model after every read would have been slow and wasteful. It also would not have made the answer any better.

The obvious alternative was a small program. Let it fetch the records, do the join, and return only the matches. That worked, but it raised a harder question: which operations should be available to that program?

There is a meaningful difference between sorting a list and registering a domain. Both can be expressed as function calls. Only one spends money and creates something that cannot simply be rolled back.

Our rule is straightforward: code can organize information, while consequential actions remain explicit calls.

A three-part decision guide routes bounded mechanical reads to host-run code, unfamiliar results back to the agent, and paid, irreversible, secret-bearing, or approval-bound operations to explicit direct calls.
Use code for bounded data work. Return judgment to the agent. Keep consequential operations explicit. Open full-size diagram

How we choose

This is the table we use in design reviews today:

WorkPreferred pathReason
Search and compare domain optionsCode modeThe reads are bounded and filtering is mechanical
Combine services, costs, and budget headroomCode modeIntermediate rows do not need to enter model context
Summarize drift or conformance evidenceCode modeThe aggregation can be reproduced and tested
Poll a task that is already runningBounded code modeThe host can enforce a deadline and clear stop conditions
Register a domainDirect callIt is paid and sometimes irreversible
Apply a deployment planDirect callThe exact payload deserves to remain visible
Approve spendHuman approval pageThe agent does not hold that authority
Read a one-shot credentialDirect call with special handlingLogs and caches make programmatic use risky
Respond to an unfamiliar errorReturn to the modelThe next step requires interpretation

“Reads in code, writes as direct calls” is a decent shorthand, though it has exceptions. Some reads expose secrets. A task response may contain an approval URL or provider payload that should never enter a generic loop. A program can prepare a deployment plan safely even though applying the plan deserves a separate, visible step.

That last distinction now matters beyond one deployment. An agent can propose a bounded multi-operation plan without executing it. The server validates and freezes the plan for inspection; apply_workflow remains the explicit consequential call. Each node then reaches the same governed operation it would have reached directly. Composition changes the unit a person can inspect. It does not move authority into host-run code.

Polling needs a smaller response

Task APIs tend to accumulate fields. One response eventually contains current state, completed actions, provider output, approval details, callbacks, errors, and the final result. That is useful when an agent is inspecting a particular task. It is far too much material for a loop that only needs to know whether to wait.

SproutPad's programmatic polling view is deliberately plain:

{
  "status": "working",
  "terminal": false,
  "requiresHuman": false,
  "pollAfterMs": 2000
}

The program stops when terminal or requiresHuman becomes true. Control goes back to the agent at that point. The loop never receives an approval capability and never decides how to recover from a new provider error.

That smaller response is useful partly because of what it leaves out.

Let the host run the code

The phrase “code mode” can describe two quite different systems.

In SproutPad's model, the agent's existing host runs a small program inside its own isolated environment. SproutPad operations appear as typed functions through a broker. Every call still passes through SproutPad's normal identity, policy, budget, approval, and idempotency checks.

We could instead run arbitrary customer programs beside our control plane. That would make SproutPad responsible for tenant isolation, packages, resource limits, cancellation, output limits, and the aftermath of a program that stops halfway through several mutations. None of that is required for a host to filter a list before showing it to a model, so we are not taking it on.

Anthropic's description of code execution with MCP uses a similar arrangement: tools become code APIs, the execution environment handles intermediate data, and selected output returns to the model. The MCP client guidance also distinguishes progressive discovery from programmatic calling. Discovery helps an agent find a tool. It does not make a risky tool safe to compose.

Clients get hints; the server keeps authority

SproutPad attaches metadata to operations so a capable host can choose a reasonable calling pattern. An operation may be marked direct_only, for example. That label is useful, but it cannot be the enforcement mechanism.

A client may ignore metadata. A model may overlook a warning in a description. The API still has to reject an unauthorized request. MCP read-only annotations help clients and reviewers in the same way, without replacing server-side checks.

What we measure

The useful evaluation compares one task completed through direct calls with the same task completed through a small program. Token use matters, but it is only one part of the result. We also want to know:

  • whether the agent chose the intended operations;
  • whether both paths produced the same answer;
  • how many tool definitions and result bytes reached model context;
  • whether the program stopped at a human gate;
  • whether retries kept their idempotency keys;
  • whether logs captured a secret, provider payload, or another tenant's data;
  • and whether the host enforced call, time, and output limits.

Here is the conservative policy behind the first implementation:

function callingMode(operation: {
  effect: "read" | "mutation";
  containsOneShotSecret: boolean;
  mayRequireHuman: boolean;
  boundedOutput: boolean;
}) {
  if (operation.containsOneShotSecret) return "direct_only";
  if (operation.effect === "mutation") return "direct_only";
  if (operation.mayRequireHuman) return "bounded_then_stop";
  if (!operation.boundedOutput) return "direct_only";
  return "programmatic_read";
}

It will probably become less restrictive as we gather evaluations. I would rather relax a rule with evidence than discover that a convenient loop hid the one call a person needed to see.