Classic painting used as the article cover
← Back to blog

GETTING STARTED

Building Your First Production Agent: A Guide That Doesn't Skip Authorization

A working support-triage agent built on the Anthropic Messages API tool-use loop, with identity, scoped authorization, and an audit record wired in from the first commit rather than retrofitted later.

Paulina XuAug 14, 202618 min
EngineeringIdentityBest Practices

TL;DR

The tool boundary you build on day one is the one you are stuck with.

Most "build an agent" tutorials wrap an API call in a function, hand the model a bare API key, and call it done in forty lines. That works, right up until more than one person uses the agent. At that point every tool call is really two questions: what can this agent ever do, and what can this particular caller do right now.

Skipping that split doesn't save the work; it defers it, and the bill comes due as a rewrite of every tool boundary already shipped, not a patch. This is not a security nice-to-have added after the demo lands. Without a distinct, governed identity of its own, OWASP finds an agent operates in an "attribution gap" that makes enforcing least privilege structurally impossible.

None of this is an argument for building an approval bureaucracy around a support-triage bot.

What it actually takes is a context object carrying two identities, one authorize() call, and a credential fetched at the moment of use, wired in from the first commit. Everything else in this guide follows from that.

Overview

Most "build an agent" tutorials follow the same arc. Import the SDK, define a tool that wraps an API client, put the API key in an environment variable, run the loop, watch the model call your tool. It works, and it takes about forty lines. Anthropic's own walkthrough is honest about this: its introductory loop is a bare while response.stop_reason == "tool_use" with no iteration counter, no timeout, and no error recovery, because those are not what the tutorial is teaching.

The problem is that the forty lines encode three decisions that are very hard to undo. The agent holds a credential directly, so nothing between the model and the API can narrow what a call may do. The tool set is defined once for all callers, so the agent's reach is the union of every user's reach rather than the intersection. And nothing records who asked for what, so the first time someone asks "why did the agent close that ticket," nobody knows. We think retrofitting identity and authorization onto an agent built without them is not a refactor. It is a rewrite of every tool boundary you have.

OWASP named this precisely in its 2026 agentic top ten. "Identity and Privilege Abuse" (ASI03) arises from "the architectural mismatch between user-centric identity systems and agentic design," and its term for the resulting failure mode is an "attribution gap." That gap is not created by a security oversight later. It is created in the first commit, by the shape of the tool layer.

This guide builds the same simple agent and wires those things in from the start: an identity for the agent, an identity for the person it acts for, an authorization decision at the tool boundary, credentials injected at call time, and an audit event per tool call. The substrate is the Anthropic Messages API tool-use loop in Python, because it is the smallest complete thing that exercises every one of these concerns. Current model IDs are claude-opus-5, claude-sonnet-5, and claude-fable-5.

Adjacent posts here cover different ground. "A Developer's Guide to Thinking in Agents, Not Apps" is mental models; "Agent Design Patterns" and "A Field Guide to Planning Algorithms for Agents" are architecture. None of them is a build guide, and none wires in authorization. Credential hygiene is covered separately, in "Managing Secrets for AI Agents."

The habit to build: an agent does not hold permissions. It presents two identities and asks a boundary for a decision on every call. If that boundary exists in your first commit, everything you add afterward inherits it for free.

The Scenario

Take a concrete scenario, entirely illustrative: a mid-sized company's IT service desk, fielding a few hundred tickets a week. Most are password resets, VPN certificate problems, and questions already answered in the internal knowledge base. A support-triage agent reads an incoming ticket, searches the knowledge base for a matching article, and posts a reply on the ticket and tags it for review when it finds a confident match.

Three tools, deliberately different in kind:

  • get_ticket: a read of a single record the requesting user is already entitled to see. Cheap, safe, idempotent.
  • search_knowledge_base: retrieval across a corpus. Read-only, but the result set depends on who is asking. An article in the security team's private space should not surface for a helpdesk contractor.
  • post_ticket_reply: a write the requester sees. Not idempotent, not reversible in any way that matters, and the only tool in the set where a wrong argument produces a real consequence.

That split is the right axis to design around, and there is evidence for it: τ-bench, the tool-agent-user benchmark, reports that in its retail domain task success declines monotonically as the number of database writes a task requires goes up. Side effects, specifically, are where agents come apart. Almost every governance decision falls out of the difference between the first two tools and the third, and a design that treats all three alike will eventually post something embarrassing to a customer.

The agent runs as a service. A helpdesk engineer triggers it from a queue view, so every run has a human on whose behalf it is acting. That's the normal enterprise case, and the one tutorials skip.

The Agent Loop

The loop itself is unglamorous, which is a good sign. Send messages and tool definitions to the model. While stop_reason == "tool_use", execute the requested tools, append the results, and send again. The loop exits on any other stop reason.

python
# agent.py
import anthropic

client = anthropic.Anthropic()      # ANTHROPIC_API_KEY, or an `ant auth login` profile
MODEL = "claude-opus-5"
MAX_STEPS = 12                      # hard ceiling on loop iterations

SYSTEM = """You triage IT service-desk tickets.
Read the ticket, search the knowledge base, and post a reply only when an
article clearly answers the question. If nothing matches, say so and stop."""

The wire format is stricter than it looks. Tool results must immediately follow the assistant message that requested them, with nothing interleaved, and within the user message carrying them, every tool_result block must come before any text block; text first is a 400. Keep every result for one turn in a single user message, too. Split them across messages and the model quietly learns to stop issuing parallel calls, which roughly halves throughput on multi-lookup tasks.

python
def run(user_message: str, ctx: "CallContext") -> str:
    messages = [{"role": "user", "content": user_message}]

    for _step in range(MAX_STEPS):
        response = client.messages.create(
            model=MODEL,
            max_tokens=8192,
            system=SYSTEM,
            tools=tool_schemas_for(ctx),      # scoped per caller — see below
            messages=messages,
        )

        if response.stop_reason == "pause_turn":
            # A server-side tool hit its own loop cap. Echo the turn back as-is.
            messages.append({"role": "assistant", "content": response.content})
            continue
        if response.stop_reason != "tool_use":
            return summarize(response)        # end_turn, max_tokens, refusal, ...

        messages.append({"role": "assistant", "content": response.content})
        results = [
            dispatch(block, ctx)              # never raises; see Recoverable Errors
            for block in response.content
            if block.type == "tool_use"
        ]
        messages.append({"role": "user", "content": results})   # one message

    return "Step budget exhausted before the task completed."

MAX_STEPS exists to stop a stuck agent from billing you all night. Anthropic's guidance on building effective agents names a maximum-iterations cap as a standard stopping condition, and the SDK tool runners expose max_iterations with no default: an unbounded loop is what you get unless you ask otherwise.

Check stop_reason before reading response.content, too. Safety classifiers can decline a request and return refusal with an HTTP 200 and an empty or partial content array; code that indexes content[0] breaks on it.

pause_turn is narrower than it's usually described. It refers to the server-side sampling loop that executes hosted tools such as web search hitting its own iteration limit, ten by default, not a client-loop budget signal. The response may carry a server_tool_use block with no matching result, and the fix is to send the turn back unchanged. Adding a "please continue" message is the common mistake.

If you want the model to pace itself rather than simply be cut off, the API exposes a task budget: a token ceiling the model is aware of, injected as a server-side countdown, distinct from max_tokens. It's a soft hint. The documentation is explicit that Claude may exceed it rather than abandon an action mid-flight, and the remaining budget appears nowhere in the response, so this is a steering mechanism, not an observability one. The floor is 20,000 tokens, and the documented sizing method is to measure first and start at the p99 of per-task spend.

end_turn / refusal

pause_turn

tool_use

deny

allow

yes

no

Request + agent identity + user identity

Model call

stop_reason

Return

AUTHORIZE: agent scopes ∩ user scopes

Structured denial as tool_result

Resolve credential, execute

Emit audit event

step < budget?

Halt

Figure 1 — The loop, with the authorization checkpoint on the only path that reaches a real system.

Tool Definitions

A tool definition is a name, a description written for a reader who cannot see your codebase, and a JSON Schema for the inputs. The model selects tools almost entirely from those three fields; they are prompt engineering with a different file extension. Anthropic's documentation calls detailed descriptions "by far the most important factor in tool performance."

python
GET_TICKET = {
    "name": "get_ticket",
    "description": (
        "Fetch one service-desk ticket by numeric ID. Returns subject, body, "
        "requester, status, and priority. Use this before anything else so "
        "you are reasoning about the real ticket text rather than a "
        "paraphrase of it. It does not return attachments or audit history."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "ticket_id": {"type": "integer", "description": "e.g. 48213."}
        },
        "required": ["ticket_id"],
        "additionalProperties": False,
    },
    "strict": True,
}

strict: True is worth the two seconds it costs. It constrains token sampling to schema-valid outputs, turning "the input should match the schema" into a guarantee rather than a hope: the difference between receiving ticket_id: 48213 and occasionally receiving "48213". It requires additionalProperties: False, which is good schema hygiene anyway.

The write tool is where the schema starts carrying safety semantics rather than just types.

python
POST_REPLY = {
    "name": "post_ticket_reply",
    "description": (
        "Post a public reply the requester will see, and tag the ticket for "
        "human review. Use only when a knowledge-base article directly "
        "answers the question. Never use it to ask for more information."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "ticket_id": {"type": "integer"},
            "body": {"type": "string", "description": "Plain prose, no markdown."},
            "kb_article_id": {
                "type": "string",
                "description": "ID of the article this reply is based on.",
            },
        },
        "required": ["ticket_id", "body", "kb_article_id"],
        "additionalProperties": False,
    },
    "strict": True,
}

Requiring kb_article_id is a governance control disguised as a parameter: the agent cannot post a reply it cannot attribute to a source, which makes an ungrounded answer structurally awkward rather than merely discouraged. What's missing from the schema is just as deliberate: no idempotency key. The boundary derives that one itself, and the Write Tool section explains why.

Two Identities

Every tool call in an enterprise agent carries two identities, and both matter, which is the part the tutorials omit.

The agent identity answers "which piece of software is this?" It is stable, registered, and carries a scope set bounding what this agent may ever do. The triage agent may read tickets and post replies, and it may not delete anything, for anyone, ever. The user identity answers "on whose behalf?" It is per-request and bounds what this particular call may do.

The authorization decision is the intersection. A helpdesk engineer who can read every ticket in the estate does not get an agent that can delete them, because the agent's scope set excludes deletion. An administrator running the same agent gets no more from it than the agent is permitted to do. And the agent, however capable, cannot reach a queue the current user has no business seeing.

OAuth 2.0 Token Exchange (RFC 8693) models exactly this shape at the token layer, and we think its vocabulary is worth borrowing even if you never implement the protocol. Section 4.1 defines the act claim, which "provides a means within a JWT to express that delegation has occurred and identify the acting party to whom authority has been delegated." The user remains the token's subject while the agent appears as a nested actor. Section 4.4 defines may_act, which "makes a statement that one party is authorized to become the actor and act on behalf of another party." Section 1.1 draws the distinction that matters most: under impersonation, the acting party "is given all the rights that [the subject] has within some defined rights context and is indistinguishable from [the subject] in that context." Delegation keeps both identities legible. An agent that simply holds the user's token has chosen impersonation, and has thereby chosen to be unauditable.

A context object carrying both scope sets pays for itself almost immediately.

python
from dataclasses import dataclass

@dataclass(frozen=True)
class CallContext:
    agent_id: str                     # stable identity of this agent
    agent_scopes: frozenset[str]
    user_id: str                      # the human this run acts for
    user_scopes: frozenset[str]
    request_id: str                   # correlates every event in this run

    @property
    def effective_scopes(self) -> frozenset[str]:
        return self.agent_scopes & self.user_scopes

The presented tool list becomes per-caller. There is no reason to describe a tool the caller could not invoke; offering it produces confident calls that get denied. This also shrinks the tool set, which is independently good: selection accuracy degrades as candidate counts grow, and definitions are a fixed token cost on every request.

python
TOOL_SCOPES = {
    "get_ticket":            "tickets:read",
    "search_knowledge_base": "kb:read",
    "post_ticket_reply":     "tickets:reply",
}
ALL_TOOLS = {t["name"]: t for t in (GET_TICKET, SEARCH_KB, POST_REPLY)}

def tool_schemas_for(ctx: CallContext) -> list[dict]:
    return [s for n, s in ALL_TOOLS.items() if TOOL_SCOPES[n] in ctx.effective_scopes]

The presented list is only ever a convenience, though, never a control. The model can name a tool it was never given, and a prompt injection arriving through a ticket body can try to talk the loop into one. So the boundary re-checks on execution.

python
class Denied(Exception): ...

def authorize(tool_name: str, ctx: CallContext) -> None:
    required = TOOL_SCOPES.get(tool_name)
    if required is None:
        raise Denied(f"Unknown tool '{tool_name}'.")
    if required not in ctx.agent_scopes:
        raise Denied(f"Agent {ctx.agent_id} is not granted '{required}'.")
    if required not in ctx.user_scopes:
        raise Denied(f"User {ctx.user_id} is not granted '{required}'.")

Separating the two denial reasons is deliberate. "The agent cannot do this at all" and "you cannot do this" are different problems with different fixes; collapsing them costs an afternoon every time someone investigates.

The temptation to write the rules into SYSTEM instead of code is strong, and the evidence argues against giving in to it. τ-bench ablated the domain policy out of the system prompt and found the effect wildly inconsistent: in its airline domain task success collapsed from 33.2 to 10.8, while in retail it barely moved, from 61.2 to 56.8. The authors read the retail result as evidence that successful runs "mostly stem from using tools in an intuitive and common sense way, and that they may not actually be leveraging the policy documents to the extent possible." A rule the model may or may not be consulting is not a control. OWASP makes the same point: planner output should be treated as untrusted and checked at an enforcement point before each action executes, with per-action authorization rather than one check at the start of a workflow.

That last clause names a bug that is easy to write. OWASP lists time-of-check-to-time-of-use among ASI03's vulnerability classes, with a scenario that reads like a postmortem: a procurement agent validates approval at the start of a purchase sequence, the user's spending limit is reduced hours later, and the workflow proceeds on the stale authorization to complete a now-unauthorized transaction. Authorizing once per run is natural and wrong. Authorize per call.

Credentials at Call Time

The agent needs a service-desk token to do anything useful. It must never see one.

Anything in the model's context can be extracted from the model's context. A tool description, a system prompt, a tool result, a retrieved knowledge-base article: all of it is text the model reads, and all of it is reachable by a well-crafted injection in a ticket body. Anthropic's documentation carries the warning: tool results "often carry content from sources outside your control," should be treated as untrusted, and should be kept inside tool_result blocks rather than promoted into system prompts. No amount of prompt-writing changes that. The credential has to live somewhere the reasoning loop cannot address, and it should join the outbound request only after that request has been authorized.

python
import httpx
from vault import fetch      # returns a short-lived token for (user, provider)

def service_desk_client(ctx: CallContext) -> httpx.Client:
    """Per-user credential, resolved at call time, never returned to the model."""
    token = fetch(user_id=ctx.user_id, provider="service_desk")
    return httpx.Client(
        base_url="https://servicedesk.internal/api/v2",
        headers={
            "Authorization": f"Bearer {token}",
            "X-Request-Id": ctx.request_id,
        },
        timeout=httpx.Timeout(10.0, connect=3.0),
    )

The credential is keyed by user, not by agent. With a single service account, the downstream system's own logs attribute every action to the agent, and you lose the ability to answer "on whose behalf" at the only layer that is genuinely authoritative: the system that was actually changed. Per-user credentials mean the service desk's audit trail agrees with yours, and short lifetimes mean a captured token is a much smaller thing to lose than a standing key.

Service deskVaultTool boundaryModelService deskVaultTool boundaryModeltool_use(post_ticket_reply, args)authorize(agent scopes ∩ user scopes)resolve credential ref (user, provider)short-lived tokenPOST /tickets/48213/replies201 Createdemit audit eventtool_result (no token, no headers)

Figure 2 — Both identities travel with the request; the credential joins it only after authorization and never returns to the model.

The Write Tool

The read tools and the write tool should not share a code path, because they do not share a failure profile. A wrong get_ticket costs a step. A wrong post_ticket_reply is visible to a requester forever, and the handler earns its extra weight because of that.

It derives the idempotency key itself rather than accepting one from the caller, because the model cannot be trusted to supply a stable one across retries. The Idempotency-Key header is a de facto standard rather than a live one; the IETF draft specifying it expired without becoming an RFC, so build against your provider's contract instead. Stripe's is representative: keys up to 255 characters, accepted on all POST requests, the first response replayed for subsequent identical requests, an error if the same key arrives with different parameters, and pruning after roughly 24 hours. Stripe even replays saved 500s, which is exactly the behavior you want and exactly what a naive client-side retry defeats.

It validates the grounding claim, too. The cited article has to be readable by this user and has to have appeared in a search result earlier in the same run, or the agent has invented its source.

It consults an approval policy, returning a structured "awaiting approval" result so the run parks rather than fails. And it writes the audit event before returning, because if the process dies between the API call and the log line, the log line should be the thing that already happened.

python
import hashlib

def handle_post_reply(args: dict, ctx: CallContext) -> dict:
    authorize("post_ticket_reply", ctx)

    if not article_seen_in_run(args["kb_article_id"], ctx):
        raise Denied("That article did not appear in this run's search results.")
    if not article_visible_to(args["kb_article_id"], ctx):
        raise Denied("Article not readable by this user; cannot cite it.")

    # Derived, not supplied: stable across retries by construction.
    key = hashlib.sha256(
        f"{ctx.agent_id}:{args['ticket_id']}:{args['kb_article_id']}".encode()
    ).hexdigest()

    if requires_approval(args, ctx):
        approval = request_approval(args, ctx)          # non-blocking
        return {
            "status": "awaiting_approval",
            "approval_id": approval.id,
            "message": (
                "This reply needs human approval before it is sent. "
                "Do not retry; stop and report that approval is pending."
            ),
        }

    with service_desk_client(ctx) as api:
        r = api.post(
            f"/tickets/{args['ticket_id']}/replies",
            json={"body": args["body"], "public": True},
            headers={"Idempotency-Key": key},
        )
        r.raise_for_status()

    return {"status": "posted", "reply_id": r.json()["id"]}

The "Do not retry" sentence is not decoration. The documentation notes Claude will typically retry an invalid tool call two or three times with corrections before giving up. Spend that budget on a fix, not a duplicate write.

Design the write tool as if the model will call it twice. Derived idempotency keys, server-side deduplication, and a result string that states whether a retry is appropriate are cheaper than a reconciliation script.

Recoverable Errors

Tool results are the model's only feedback channel. An error returned as a stack trace tells the model something is broken; an error returned as an instruction tells it what to do instead. Anthropic's guidance is unambiguous on this: instead of generic errors like "failed", "include what went wrong and what Claude should try next." The mechanic for that is a tool_result block carrying is_error: true.

One rule that is easy to miss: if you decide not to execute one of several requested tools, you must still return a result for it. A tool_use block with no matching tool_result is a protocol error, so a skipped call becomes an explicit is_error result saying why.

python
import json

def dispatch(block, ctx: CallContext) -> dict:
    """Never raises. Every outcome becomes a tool_result the model can act on."""
    def result(content: str, is_error: bool = False) -> dict:
        return {
            "type": "tool_result",
            "tool_use_id": block.id,
            "content": content,
            "is_error": is_error,
        }
    try:
        payload = HANDLERS[block.name](block.input, ctx)
        audit(ctx, block, outcome="ok")
        return result(json.dumps(payload))
    except Denied as e:
        audit(ctx, block, outcome="denied", reason=str(e))
        return result(
            f"Not permitted: {e} Do not retry this tool. Continue with the "
            f"tools you have, or explain what you could not do.",
            is_error=True,
        )
    except httpx.HTTPStatusError as e:
        audit(ctx, block, outcome="upstream_error", status=e.response.status_code)
        return result(classify_http(e), is_error=True)
    except Exception:
        audit(ctx, block, outcome="internal_error")
        return result(
            "The tool failed for an internal reason. Do not retry. Report "
            "that this step could not be completed.",
            is_error=True,
        )

Never leak an internal exception message into a tool result. It's uninformative to the model, and it's a small disclosure to anyone who can influence the transcript. Always state whether a retry is appropriate, too, because the model has no way to infer that on its own.

python
def classify_http(e: httpx.HTTPStatusError) -> str:
    s = e.response.status_code
    if s == 404:
        return "That record does not exist. Check the ID or try a different one."
    if s == 409:
        return "Already applied (idempotent replay). Treat this as success."
    if s == 422:
        return f"Arguments rejected: {e.response.text[:200]}. Fix and retry once."
    if s == 429:
        return "Rate limited. Do not retry; report that the system is busy."
    if s >= 500:
        return "The upstream system is failing. Do not retry; stop and report."
    return "The call was refused. Do not retry."

The audit event is emitted on every branch, including denials, which is the branch people forget. A record of what an agent tried to do and was stopped from doing is the single most useful thing in the log when you are establishing whether a boundary works.

python
def audit(ctx: CallContext, block, **fields) -> None:
    emit({
        "ts": now_iso(),
        "request_id": ctx.request_id,
        "agent_id": ctx.agent_id,
        "on_behalf_of": ctx.user_id,
        "tool": block.name,
        "arguments": redact(block.input),
        "effective_scopes": sorted(ctx.effective_scopes),
        **fields,
    })

Both identities appear in every record. An audit trail naming only the agent tells you a robot did something; one naming the agent and the person answers the question anyone will actually ask. OWASP makes observability a first-class principle for this reason, arguing alongside "least agency" that "without clear visibility into what agents are doing, why they are doing it, and which tools they are invoking, unnecessary autonomy can quietly expand the attack surface." Regulated deployments make it concrete: under the EU AI Act, Article 12 requires high-risk systems to "technically allow for the automatic recording of events (logs) over the lifetime of the system" and Article 19 requires providers to retain them for "at least six months." Those obligations apply to standalone Annex III systems from 2 December 2027, following the deferral adopted in the Digital Omnibus on AI in June 2026. That's a reason to build the logging in now, not a reason to defer it: the systems facing that deadline are the ones being written today. A log that cannot attribute an action to a human principal satisfies nobody's reading of it.

What Breaks in Production

The agent above works. Production finds the parts a demo never exercises.

Runaway loops. The classic shape is a tool that returns something the model reads as almost-success, so it adjusts one argument and tries again indefinitely. MAX_STEPS catches it, but the better instrument is a repeated-call detector: hash tool name plus arguments per run, and if the same hash recurs three times, say so in the tool result. Models are good at changing strategy when told they are repeating themselves, and bad at noticing unaided.

Retries on non-idempotent writes. Your HTTP client retries on timeout. The write already succeeded; the response was lost. Now there are two replies on the ticket. A timed-out request is genuinely ambiguous, and no care at the call site resolves it. That's why the answer has to be server-side deduplication keyed on something stable, and why the boundary derives the key itself rather than trusting whatever the model produced on this attempt.

Partial failures mid-workflow. The agent posts the reply, then fails to tag the ticket. No transaction spans two API calls, so the honest options are to make each step independently idempotent and re-runnable, or to record intent before acting so a reconciliation pass can finish the job. Pretending the workflow is atomic is not an option, and this is the failure mode that most often turns a demo into an incident.

Context growth. Every tool result stays in the transcript, and quality degrades before the window does. Anthropic's context-engineering work calls this context rot: the observation that recall accuracy falls as token count rises. The tool-side mitigations are yours: return the fields that matter, paginate, cap result sizes. Platform-side, context editing clears stale tool results and compaction summarizes old turns; the announcement reported a 29% improvement from context editing alone, 39% combined with a memory tool, and an 84% reduction in token consumption on a 100-turn web-search evaluation. Those figures come from the blog post rather than the reference documentation, and the harness is unpublished, so treat them as directional.

The right tool with subtly wrong arguments. This is the failure authorization catches and testing usually does not. The model correctly decides to post a reply, correctly composes it, and supplies the ticket ID of the previous ticket it looked at. Schema satisfied, scopes satisfied, wrong record. τ-bench quantifies the shape: in a hand-examined sample of retail failures roughly a fifth were wrong-argument errors, and its gpt-4o agent averaged 0.46 tool calls per task referencing user, product, order, or item IDs that did not exist at all. Defenses are narrow and specific: require arguments to be internally consistent, require approval where blast radius justifies it, and log the arguments so the mistake surfaces in minutes rather than at quarter end.

None of these are exotic. They are the ordinary failure modes of distributed systems, arriving through a component that generates its arguments probabilistically. That is precisely why the boundary matters: it is the one place where a non-probabilistic system gets to decide.

Conclusion

The working agent here is not much longer than the tutorial version. What it has extra is a context object carrying two identities, an authorize call at the top of every handler, a credential resolved at call time rather than held in the process, a write tool deliberately unlike the read tools, error strings written for a model rather than a log aggregator, and one audit event per tool call including the denied ones.

Every one of those is easy on day one and expensive on day two hundred, because by then the tool layer has assumed their absence. The unscoped tool list gets copied into three more agents before anyone revisits it. The credential the process holds ends up baked into a deployment manifest, then into two more. By the time someone tries to fix the exception handling, it's called from a dozen places that all expect it to raise. We don't think authorization is a feature you bolt on; it is a property of where your tool boundary sits, and that gets decided in the first commit whether you think about it or not.

Build the boundary before you need it. An agent whose every tool call already names an agent, names a user, resolves a decision, borrows a credential, and leaves a record is an agent you can safely put more capability behind. One that skipped those is an agent you will eventually have to turn off.

Sources