Articles · Artificial intelligence · · 7 min read

Blast Radius Is a Tool-Boundary Problem

An agent's blast radius is set by its tool contracts, not its prompt. How to scope authority, gate writes, and keep the boundary testable rather than hopeful.

By Matthieu de La Mettrie · Founder, Skopura

You have decided to build the agent. The interesting question is no longer whether a model can be trusted to choose actions — it is what the worst reachable outcome looks like when it chooses badly, and whether that outcome is one you can live with at three in the morning without a human on call.

That worst reachable outcome is the blast radius. It is a property of the tool surface, not of the model, and not of the prompt. A prompt is a request. A tool contract is a grant of authority. Anything you can express only in the prompt is a preference; anything you express in the contract is a constraint. Designing an agentic system is mostly the work of moving statements from the first category to the second.

What actually defines the blast radius?

Enumerate every state change reachable from a single turn, then close the set transitively. If send_email can reach an inbox that triggers an automation that writes to a CRM, the CRM is inside the radius. If run_sql accepts arbitrary text against a connection with write privileges, the radius is the connection’s grant, not the query the model happened to emit. If a tool returns identifiers the agent can then pass to a second tool, the composition is part of the surface even though no single contract permits it.

This is why tool inventories written as lists mislead. The surface is a graph. Two individually harmless tools — one that resolves a customer name to an internal ID, one that issues a credit against an ID — compose into an unbounded credit facility.

Agent tool surfaceRead-only, caller-scopedRetrieval over a private corpusMetric lookup through the semantic layerWrites, reversibleDraft creationTag and annotateWrites, externally visibleOutbound messageTicket transitionWrites, irreversible or money-movingRefund or creditSchema or permission change

The classification matters more than the count. A system with many read tools and one irreversible write is easier to reason about than a system with three tools where one quietly spans all four tiers.

Coarse tools or fine tools?

The instinct is to give the agent primitives and let it compose. Composition is exactly what you cannot bound. The alternative is to expose task-shaped tools whose arguments are narrow and whose effects are whole: not update_row, but apply_approved_discount(order_id, tier).

Fine-grained primitivesTask-shaped tools
Reachable state changesDetermined by composition, hard to enumerateDetermined by the contract, enumerable
Flexibility for unforeseen requestsHigh — the agent improvisesLow — unforeseen work falls back to a human
Where correctness livesIn the model's planIn code you can test
Failure signatureNovel each timeRepeats, so it can be fixed once
Audit trailSequence of low-level effectsOne business-meaningful event

Fine-grained primitives are appropriate on read paths, where the cost of a wrong composition is a wasted call and a confused answer. On write paths, prefer the tool that encodes a decision your business already knows how to make. If you cannot name the tool in the language your operations team uses, it is probably too primitive to hand to an agent.

Where should the authority live?

Not in the agent process. Put a broker between the model’s chosen call and the effect handler, and give the broker the only credentials that matter. The agent proposes; the broker decides. This is unglamorous plumbing and it is the single structural change that most reduces blast radius, because it makes every constraint a property of code rather than of instruction-following.

invalid

valid

denied

needs approval

declined

approved

allowed

Model proposes tool call

Schema validation

Reject with typed error

Resolve caller identity

Policy check: scope, tier, quota

Human gate

Effect handler

Append to effect log

Typed result back to model

Two details in that flow do disproportionate work. The first is resolving caller identity at the broker rather than trusting an identifier in the tool arguments — the agent should never be able to name whose data it is acting on. Row-level security filters a model’s data by who is asking, and that principle has to extend past the warehouse into the tool layer, or the agent becomes a confused deputy with a service account. The second is the typed error path: a rejection that returns structured feedback lets the agent adapt within the boundary instead of retrying blindly against it.

Which writes need a human gate?

Gates are expensive in latency and in goodwill, so spend them where reversal is hard. Work through each write tool once:

Does this tool need an approval gate?

Gate it, and log the full proposed payload for review

The last row is the goal state for most write tools, and it is reached by narrowing contracts, not by trusting the model more.

What does a contained contract look like?

The shape to aim for: a schema that admits only well-formed intent, an idempotency key supplied by the broker, an explicit dry-run mode, and a result type that distinguishes refused from failed. Structured outputs constrain a model’s response to a supplied schema, which gets you well-formed arguments; the contract still has to decide what well-formed arguments are permitted to do.

A write tool contract with the authority outside the modelts
123456789101112131415161718192021222324252627
const ApplyCredit = {
name: "apply_credit",
// The model names an order, never an account or an amount source.
input: z.object({
  orderId: OrderId,
  reasonCode: z.enum(["late_delivery", "damaged", "duplicate_charge"]),
  note: z.string().max(280),
  dryRun: z.boolean().default(true),
}),
// Refusals are data, not exceptions, so the agent can replan.
output: z.discriminatedUnion("status", [
  z.object({ status: z.literal("applied"), creditId: z.string() }),
  z.object({ status: z.literal("simulated"), wouldCredit: Money }),
  z.object({ status: z.literal("refused"), reason: RefusalReason }),
]),
};

// The broker, not the agent, supplies identity, idempotency and ceiling.
async function invoke(call: ToolCall, ctx: BrokerContext) {
const args = ApplyCredit.input.parse(call.args);
const order = await orders.get(args.orderId, { asCaller: ctx.principal });
if (!order) return refuse("out_of_scope");
const amount = creditPolicy.resolve(order, args.reasonCode); // policy, not prompt
if (amount.exceedsCeiling(ctx.ceilingFor(ctx.principal))) return refuse("needs_approval");
if (args.dryRun) return { status: "simulated", wouldCredit: amount };
return effects.apply("credit", { order, amount }, { key: ctx.idempotencyKey });
}

Notice what the model cannot express: an amount, an account, a caller. It selects a reason code from a closed set, and policy code turns that into money. The credit ceiling is a property of the principal held by the broker.

How do you make the boundary testable?

Stop testing the model and start testing the contract. Model-level evaluation tells you how often the agent picks a sensible plan; boundary testing tells you what happens when it does not. Both are needed, and they are different harnesses.

For the boundary, write adversarial cases that bypass the model entirely: call each tool directly with arguments the model could plausibly emit under prompt injection or a garbled plan. Hostile identifiers from another tenant. Reason codes that do not match the order state. The same call replayed with the same idempotency key. Two tools composed in the order your design did not anticipate. These tests are deterministic, fast, and they are the ones that hold when the model underneath is swapped.

Then instrument the effect log as a first-class dataset rather than application logging. Every proposal, every refusal, every applied effect, with the principal and the idempotency key. That log is what lets you answer, after an incident, which calls were reachable and which were merely attempted — and it is the input to the quieter, more useful metric: the ratio of refusals to applications per tool, watched over time. A tool whose refusal rate climbs is a contract drifting out of alignment with the work people are actually asking for. A tool that never refuses anything is probably not constraining anything either.

What is the failure mode of doing this well?

Over-narrowed contracts. If every tool is task-shaped and gated, the agent becomes a slow interface to a form, and you have paid for non-determinism without buying flexibility. The judgement call is which parts of the workload genuinely benefit from a planner and which are better expressed as a deterministic path with a model in one step. Build-versus-buy turns on whether the capability is a differentiator or a commodity; agent-versus-workflow turns on whether the variance in the task is real or imagined.

The discipline worth keeping is the sequencing. Decide the reachable set first, then the contracts that bound it, then the gates, then the prompt. Prompts written against a well-bounded surface are short and boring, which is the correct aesthetic for production. Prompts carrying the weight of safety are long, defensive, and one clever input away from being irrelevant.

agentic-ai · system-design · tool-boundaries · platform-security

Share on LinkedIn ↗

Work with Skopura

Facing this bottleneck in your own estate?

Skopura builds production-grade lakehouse pipelines, evaluation harnesses, and provides fractional data leadership. Thirty-minute call, no deck.

Book a 30-min call →Email directly

Further reading

Artificial intelligence · 8 min

Eval Harnesses for Non-Deterministic Systems

How to design an evaluation harness for agentic and LLM systems: hard asserts, graded rubrics, fixture hygiene, judge calibration and what a red run blocks.

Artificial intelligence · 4 min

When a Workflow Beats an Agent

Autonomy is a cost, not a feature. Most tasks that reach for an agent are better served by a fixed sequence of steps you can actually test and reason about.