Guide/guides

Durable Agents

A durable agent is a long-lived, stateful process your own application hosts, and it reaches that application only through the agent tools the routes already declare.

Durable Agents

A durable agent is a long-lived, stateful process your own application hosts, and it reaches that application only through the agent tools the routes already declare.

Agent Interface gave your application a surface agents can call. This guide is about the other side: hosting an agent of your own that calls it, such as a triager that wakes every hour, a researcher that accumulates findings over a week, an operations agent that proposes a destructive change and then waits, possibly for days, for a human to say yes.

Getting one running takes these steps, each with its own section below:

  1. Install the packages.
  2. Scaffold the class with make:agent, which also writes its registration in config/agents.ts.
  3. Add agentsPlugin(agents) to src/app.ts.
  4. Adjust the registration: the tools the agent may call and its budget.
  5. Write the agent against this.tools.
  6. Deploy it to Workers.

Human in the loop covers calls that wait for a person's approval, and Testing how to split the test suite.

What a durable agent is

Three properties, none of which a job or a cron entry has:

  • Durable identity. One addressable instance per conversation, tenant or task, reachable by name.
  • Durable state. this.state and a private SQLite database survive deploys and evictions.
  • Per-instance schedules. Cron for a recurring sweep, a delay in seconds for "look at this again in an hour". Both are backed by Durable Object alarms, so an agent wakes with no request touching your Worker.

Each instance is a Cloudflare Durable Object: an addressable instance with its own storage, which Cloudflare evicts (unloads) when idle and wakes again by name or by alarm.

An agent has no privileged access to your app. this.tools.call(name, args) is its only way in, and every call goes through the same checks as a call from any other agent:

  • the scopes its registration declares;
  • the route's own validation and policies;
  • the approval queue, for a tool that requires one;
  • an audit record with secrets masked, under surface: 'durable'.

Treat an in-house agent with less trust than an external one, since it runs unattended.

Durable agents run on Cloudflare Workers only. It is the one platform that gives durable identity, alarms and per-instance state at the edge, and @guren/plugin-agents is built on the Cloudflare Agents SDK that packages them. Under Bun there is no Durable Object emulation, so locally an app's route that talks to its agent answers 503 (see Talking to your agent), which is more honest than a fake.

Install and register

bun add @guren/plugin-agents @guren/plugin-cloudflare

bunx guren plugin <package> installs either of them too, and checks the compatibility range the plugin declares. @guren/plugin-agents is a definePlugin() factory that takes your registry as its argument, so it is registered by hand either way (see Plugins).

It must end up under dependencies, not devDependencies: the generated worker imports it, and wrangler resolves that import at deploy time from a production install. guren cloudflare:build refuses an app that hosts agents and only depends on it for development.

// src/app.ts
import { createApp, EncryptionServiceProvider, EventServiceProvider } from '@guren/core'
import { agentsPlugin } from '@guren/plugin-agents'
import agents from '@/config/agents'
import { registerWebRoutes } from '@/routes/web'

const app = createApp({
  routes: registerWebRoutes,
  providers: [
    EventServiceProvider,
    EncryptionServiceProvider,
    agentsPlugin(agents),
  ],
})

export default app

@/config/agents is the registry make:agent writes (see Scaffolding one). EventServiceProvider is what makes the audit events reachable at all (see the audit trail).

EncryptionServiceProvider and an APP_KEY let an agent retry a call that waited for a human's approval. The call's arguments are kept in the agent's own storage, encrypted with the app key (see The ledger). Without an encrypter, the plugin warns at boot: a call waiting for approval is still reported to the agent with its requestId, but nothing retries it for you. The provider's position in providers does not matter.

Scaffolding one

bunx guren make:agent Triager

It writes the class and everything a fresh app lacks for it. An agent class alone is inert, because nothing loads it, nothing bounds it, the deploy build cannot find it, and it names a type no app defines:

File What it gets
app/Agents/Triager.ts the class: a state shape, a cron schedule, one tool call
config/agents.ts the registration — created if the file does not exist, patched if it does
guren.arch.ts a rule forbidding app/Agents/** from importing app/Models/**, db/**, @guren/orm and @guren/plugin-agents/runtime
config/bindings.ts the Env the class imports: the D1 binding and a commented slot for the agent's Durable Object namespace — created if absent, left alone if it already exports Env. An app whose earlier make:agent put Env in config/env.ts keeps importing it from there
tsconfig.json @cloudflare/workers-types appended to compilerOptions.types, which is where Cloudflare.Env and DurableObject come from

Every existing file is patched in place, and any patch the command cannot make is reported with the text to paste rather than skipped. An app whose agent looks registered and is not would be worse than a message. A tsconfig with no types array, or one carrying comments, gets the line to paste instead.

Two things remain yours, both worth knowing before your first typecheck:

  • It does not touch src/app.ts. Add agentsPlugin(agents) to createApp({ providers }) yourself, as above.
  • The dependency. Run bun add -d @cloudflare/workers-types if the app does not have it; the command says so when that is the case.

config/bindings.ts is hand-written rather than generated by wrangler types because tsc and your Bun test run both read it, and neither can depend on a wrangler invocation having happened first. @cloudflare/workers-types declares Cloudflare.Env, never a bare Env, which is why the class imports the app's own. Widen it as bindings arrive:

// config/bindings.ts
export interface Env {
  /** The D1 binding; `unknown` because only the ORM reads it. */
  DB: unknown
  /** The Durable Object namespace wrangler binds for this class. Absent under Bun. */
  TRIAGER?: {
    idFromName(name: string): unknown
    get(id: unknown): { sweep(): Promise<unknown> }
  }
}

The registry

config/agents.ts is the one file that says which classes are agents, where they live, and what they may call:

// config/agents.ts
import { defineAgentsConfig } from '@guren/plugin-agents'

export default defineAgentsConfig({
  agents: {
    triager: {
      module: 'app/Agents/Triager.ts',
      export: 'Triager',
      scopes: ['tool:tickets_index', 'tool:tickets.close'],
      budget: { callsPerMinute: 30 },
    },
  },
})

Keep the file static. module and export name the class's file and export as strings, because guren cloudflare:build appends a named export per class to the generated worker and a class value carries no source path. guren check reads the file as source to verify those names, so it fails a spread inside agents, a computed key or a re-exported config, each with the reason.

Three more rules the same file carries:

  • The agent name is ^[A-Za-z0-9_-]+$. It is one half of the principal id agent:<name>:<instance>, so a colon or a space in it would let two different agents produce the same id, and an approval granted to one be spent by the other.
  • One class is one agent. Both the runtime registry and the generated worker are keyed on the export name, so a second registration claiming it makes one of the two unreachable.
  • budget.callsPerMinute is a whole number of calls, at least 1. There is no unmetered registration: an agent with no budget gets 60. Infinity and NaN are refused, because both defeat the meter without erroring.

Scopes

Registration scopes are deliberately narrower than the token scopes in Tokens and scopes. Two forms are accepted:

Scope Grants
tool:tickets.close exactly that one tool
tools:read every tool whose resolved readOnlyHint is true

tools:* and prefix grants like tools:tickets.* are legal for an issued token and refused at registration: an agent running unattended must not gain access to tools that do not exist yet.

tools:read is expanded against the app's routes when guren check runs and again when the app boots, rather than written into a generated file. A route that stops being read-only therefore drops out of what the agent may call on its next wake. bunx guren check --json reports the expansion under agentScopes.

A budget refusal comes back as a denied result with reason: 'rate-limit'. The window lives in the in-memory instance, so an eviction resets it: it is a burst floor, not a global quota, and an app that needs a real one still needs a shared store and its own rate-limit middleware.

Where the approval queue goes

config/agents.ts also accepts an approvals key, but the queue's store and notifier belong on the agentsPlugin(...) call instead. Keep the registry to the static registration: guren check reads that file as source and guren cloudflare:build evaluates it on Bun, so a Drizzle store or a notification channel imported there becomes a dependency both of them have to survive.

// src/app.ts
import { AgentApprovalRequested } from '@guren/core'
import { agentsPlugin } from '@guren/plugin-agents'
import agents from '@/config/agents'

agentsPlugin({
  ...agents,
  approvals: {
    store: new DrizzleApprovalStore(db),
    notify: (request) => notifications.sendToMany(admins, new AgentApprovalRequested(request)),
    ttlMs: 60 * 60 * 1000,
  },
})

Writing the agent

GurenAgent extends the SDK's Agent and adds exactly one thing: this.tools. State, this.sql, schedules, queues, fibers and WebSockets pass through untouched.

// app/Agents/Triager.ts
import { GurenAgent } from '@guren/plugin-agents/agent'

import type { Env } from '@/config/bindings'

interface TriagerState {
  lastRunAt: string | null
  declined: number[]
}

export class Triager extends GurenAgent<Env, TriagerState> {
  initialState: TriagerState = { lastRunAt: null, declined: [] }

  async onStart(): Promise<void> {
    // Recurring schedules are idempotent, so re-registering on every wake
    // leaves one row.
    await this.schedule('0 * * * *', 'sweep')
  }

  async sweep(): Promise<void> {
    const listed = await this.tools.call('tickets_index', { status: 'open' })
    if (listed.pending) return          // waiting on a human; nothing ran
    if (!listed.ok || listed.outcome.isError) return

    this.setState({ ...this.#current(), lastRunAt: new Date().toISOString() })

    // Delay form, in seconds.
    await this.schedule(3600, 'sweep')
  }

  #current(): TriagerState {
    return { ...this.initialState, ...this.state }
  }
}

The four answers, and the one trap

const result = await this.tools.call('tickets.close', { id })
Variant Meaning
result.ok the call dispatched — result.outcome carries what the application answered
result.pending an approval: 'required' tool parked the call; result.requestId identifies the request
result.denied a gate refused before any HTTP happened; result.reason is 'auth', 'scope', 'approval' or 'rate-limit'
result.failed the dispatch itself threw

ok is not success. It says the request reached your application; the application's own verdict is result.outcome.isError, with the HTTP status in result.outcome.status. A 403 from a policy and a 422 from a schema both arrive as ok. The other three discriminants are declared as absent on each variant, so if (result.pending) return narrows without a type guard.

this.tools.preflight(name, args) asks the same route for a verdict instead of an execution, as --preflight and guren_preflight do. The scope gate runs and the budget is spent; the approval gate is skipped, because rehearsing an approval-gated tool is exactly when the question is worth asking and a rehearsal executes nothing.

Two rules about state

An agent is durable identity and durable state, not a durable JavaScript stack. An instance is evicted after inactivity and an in-flight method does not survive it. Anything that must outlive the wake is checkpointed into this.setState or this.sql and resumed by a schedule; locals, timers and in-flight fetches are gone. This is why the parked-call ledger below is state plus a schedule rather than an await that sleeps for a week.

State shape evolves; instances do not. initialState seeds a new Durable Object only. An instance that ran an earlier deploy keeps the state it was written with, so a field you add later reads undefined there — which is a Cannot convert undefined or null to object on the first sweep after the deploy, not a type error. Layer the defaults under the stored state on every read, as #current() above does.

The principal

A principal is the identity your app's auth and policies see for a caller. Every agent instance is its own principal, agent:<name>:<instance>, where the instance half is the Durable Object's name. Policies can therefore tell an operator and an agent apart, and an approval granted to one instance can never be spent by another.

The principal is installed in-process and never travels over the wire. It satisfies requireAuthenticated(), Controller.auth and Gate, and deliberately does not satisfy a bearer-token check, which looks for an issued ApiToken the application never granted.

Human in the loop

A route declaring approval: 'required' refuses the first call, files a pending request, notifies your approvers, and hands the agent the id (the mechanism Approval-gated tools describes in full). What a durable agent adds is that it comes back on its own:

export class Ops extends GurenAgent<Env, OpsState> {
  async retire(id: number): Promise<void> {
    const result = await this.tools.call('posts.destroy', { id })
    if (result.pending) return   // parked; the retry is scheduled for you
  }

  async onToolApprovalSettled(event: AgentToolApprovalSettled): Promise<void> {
    if (event.status === 'approved') {
      // event.args is the call a human answered; event.result is the retry's answer.
    }
  }
}

The ledger

Behind that return, this.tools saves { requestId, tool, args } into guren_pending_tool_calls, a framework-owned table in the agent's own Durable Object SQLite, and schedules a check. The approval queue keeps only redacted input and a one-way fingerprint, so the arguments needed for the retry have to live on the agent's side. Three bounds make that acceptable:

  • The table is the instance's private storage, and no API exposes it.
  • Rows are encrypted with the app key.
  • Each row is deleted when its approval is answered or expires, and the expiry is the queue's, which the agent cannot extend.

On each wake the agent asks the queue about every waiting row, through the same pipeline as any tool call, so the check is itself audited. On approved, it repeats the original call with the stored arguments. The queue matches the arguments' fingerprint and uses each approval once, so the retry spends exactly the approval a human granted.

How often the agent checks
  • The first check comes after 30 seconds, and the wait doubles after each check. It never drops below one second, and never runs more than a few seconds past the earliest row's expiry or past an hour.
  • There is only ever one check schedule, and one wake asks about every row, so a newly waiting call does not inherit an older row's long wait.
  • Nothing is held in memory. An eviction between the request and the approval loses nothing, because the rows and the schedule are both durable.

onToolApprovalSettled

Overridable, a no-op unless you override it, and called for every outcome:

status What happened
approved a human approved it. result is the retry's own answer, which can itself be a refusal
rejected a human refused it. Nothing was called
expired the request lapsed, unanswered or approved but not yet spent
unknown the queue holds no record of this request any more
unreadable the ledger row could not be decrypted (the app key was rotated past its previousKeys, or the row is corrupt), so the arguments are gone and nothing can retry it

args carries the arguments the parked call was made with, absent only for 'unreadable'. Since the queue keeps no reversible copy, this is the one place an application learns which call a human answered.

Recovering from an interrupted sweep

One case is worth knowing about because it is the reason status and result are separate: if a sweep is interrupted after the retry ran but before the row was cleared, the next sweep finds the approval already spent and settles with status: 'approved' and no result, calling nothing. Repeating the call there would find no unconsumed approval, file a fresh request, page a human again, and perform the action twice.

The sweep is written not to throw, because the SDK gives a failed scheduled callback three attempts and then drops the schedule. One bad row would otherwise replay the whole sweep and leave every surviving row with no wake at all. Each row is handled on its own, a hook of yours that throws is reported and swallowed, and a retry refused for want of budget keeps its row rather than spending a human's approval on a refusal.

The store and the operator side are yours

There is no default approval store, for the same reason the audit sink has no default: one degrading to process memory would answer "approved" for a record the next isolate never heard of. Implement AgentApprovalStore (the four methods and the two guarantees are in Configuring the queue) and resolve requests through your own routes. examples/agents carries a Drizzle implementation and two operator surfaces over it: a JSON API an operator drives with curl, and a browser console showing the tickets, the pending approvals, and the agent's own report on one page. Copy whichever shape your deployment needs — they are the same rules either way.

Four things those operator surfaces taught, worth repeating in yours:

  • Derive the status; never read the column. A request whose window has closed still reads pending in SQL. Drop it from the answerable listing rather than offering it.
  • Answer a re-answer with 409. A request someone already resolved, and one whose window closed, are both un-answerable now; a 404 should mean only that no request has that id.
  • Retention is a policy question. A settled request is your record of what an agent was allowed to do. Deleting old ones is worth a route an operator calls, not a schedule that decides on your behalf.
  • One rule, however many surfaces. The example's console and JSON API answer approvals through a single shared module, because a second copy of "which rows are answerable" is the copy that hands the agent a grant a human gave once. Only the presentation differs: the console redirects back and flashes the refusal, the API returns it as a status code.

A browser console alongside the bearer API needs less wiring than it looks. CSRF is mounted over the whole app by createApp({ auth }), and the tool routes stay exempt on their own terms (a bearer request that carries no cookies, and a request carrying the principal the pipeline installed, are both skipped), so nothing has to be excluded by hand. Two things do need care: the session store must be database-backed, since on Workers the login redirect and the page it lands on are answered by different isolates, and the routes carrying .agent() metadata must keep returning JSON. guren check warns about an agent route that answers with an Inertia response, because a rendered page is not a tool result.

A durable agent's own status check gets exactly the answer guren_approval_status gives an MCP client, by the same rule and audited under the same tool name, including the part that is a refusal to distinguish: an unknown id and another principal's id are one message, so neither surface can be used to enumerate what your colleagues are waiting on.

Deploying to Workers

Agents ride the ordinary Cloudflare build; Cloudflare Workers Deployment covers the rest of the path (D1, sessions, static assets, secrets).

bunx guren cloudflare:build
bunx wrangler deploy

When the app has a config/agents.ts, the build also generates the Durable Object half of the worker. You never edit it, but it explains what the next sections check.

What the generated worker adds
// .cloudflare/worker.js — generated
const handler = createWorkersHandler(app)
configureAgentRuntime((env) => handler.boot(env))

export { Triager } from '../app/Agents/Triager.ts'

const agentBindings = ["TRIAGER"]

const agentEntry = {
  async fetch(request, env, ctx) {
    await handler.boot(env)
    const routed = await routeGuardedAgentRequest(request, env, agentsConfig.routing, agentBindings)
    if (routed) return routed
    return handler.fetch(request, env, ctx)
  },
}

A named export per registered class, so wrangler has something to point a Durable Object binding at. One boot slot for both entrypoints, because an alarm can wake an agent before any request has booted the application: the agent boots it, and the request that follows joins that boot rather than starting a second. And an explicit binding list, because the SDK's router would otherwise reach every Durable Object in env.

The bindings verifier

The build reads your committed wrangler.jsonc and refuses to continue when a registered class has no SQLite-backed Durable Object binding. That refusal happens before the app build runs, not after minutes of Vite output. Do not hand-write those entries: run the build and paste the JSON it prints.

Three details behind that:

  • Both wrangler forms are accepted. The legacy migrations[].new_sqlite_classes list and the declarative exports map ({ "type": "durable-object", "storage": "sqlite" }). wrangler treats them as mutually exclusive, so use one or the other; a fresh scaffold gets the migrations form, which is what the Agents SDK documents.
  • Every named environment is verified on its own. durable_objects is not inherited by an env.<name> block, so a config that hosts the class at the top level and not in the environment you deploy is caught here.
  • "keep_names": false is refused. An agent class is found at runtime by its own name. With keep_names off, esbuild can rename it, and every tool call then fails with "is not registered" after a deploy that looked fine. The refusal applies to every app, agents or not (Cloudflare Workers Deployment). "minify": true is fine: wrangler keeps names while minifying unless keep_names turns that off.

Who may address an instance

Once agents are registered, the generated worker reserves the whole /agents/ prefix for the SDK's router, and it is deny-all: every request beneath it, and every WebSocket upgrade, is refused with 403 until you say otherwise. The refusal happens before the Durable Object is constructed, so an unauthorized caller does not even cost a cold start.

// config/agents.ts
export default defineAgentsConfig({
  agents: { /* … */ },
  routing: {
    authorize(request, target) {
      // target.agent is the Durable Object *binding* name the SDK resolved the
      // URL segment to (the path carries it kebab-cased), not the key above.
      return ownsInstance(request, target.instance)
    },
  },
})

Return true to let the request through, false for a 403, or a Response of your own to answer it directly. routeAgentRequest is a router, not an auth layer. This is the layer, and it is deliberately a predicate rather than a policy vocabulary while the right shape is unsettled.

Put your own operator routes off the /agents/ prefix. A route registered beneath it is unreachable rather than merely refused. /ops/agents/… is the spelling examples/agents uses.

Talking to your agent

An application talks to its own agent through the binding, never over HTTP:

// app/Http/Controllers/AgentOpsController.ts
import { Controller } from '@guren/core'
import { getWorkersEnv, isWorkersRuntime } from '@guren/plugin-cloudflare/env'

import type { Env } from '@/config/bindings'

export default class AgentOpsController extends Controller {
  async sweep(): Promise<Response> {
    if (!isWorkersRuntime()) {
      return this.json({ error: 'Agents run on Workers. Start this app with `wrangler dev --local`.' }, { status: 503 })
    }
    const namespace = getWorkersEnv<Env>().TRIAGER
    if (!namespace) return this.json({ error: 'No Triager binding.' }, { status: 503 })

    const stub = namespace.get(namespace.idFromName('main'))
    return this.json({ swept: await stub.sweep() })
  }
}

Any public method on the agent class is callable over the stub. Under bun run dev there is no Durable Object namespace, and answering 503 is the honest response: the agent half of the app runs under wrangler dev --local and in production.

Secrets and the database

bunx wrangler secret put APP_KEY

The ledger is encrypted with it. Without one, agentsPlugin warns at boot and retries nothing. Never commit .dev.vars.

The approval store is an ordinary table, so it is D1 like the rest of your schema, created by a migration and applied out of band with wrangler d1 migrations apply, since the app never migrates itself on Workers.

The Free plan

The reference app was deployed to a Workers Free plan account and measured with wrangler tail; its README carries the full table. The alarm fired 30 s after the calls parked, with no request touching the Worker, and the retry closed the ticket. Three numbers frame what the plan can hold: startup was ~100 ms, the Durable Object's whole sweep (boot plus two tool calls plus two approval records) 47 ms of CPU, and the ledger's alarm 14 ms.

Two limits are worth planning against.

10 ms of CPU per Worker invocation. A warm request measured 4 ms; a request that boots the application in a cold isolate measured 20–30 ms and still reported outcome: ok, because Cloudflare enforces the limit with tolerance rather than as a hard cut. But a Worker that stays above it can start failing with error 1102. The Durable Object has its own, far larger budget, so the agent's work is not the exposed part — your operator API's cold boots are. Watch cpuTime in bunx wrangler tail; if 1102s appear, the Paid plan removes the ceiling.

50 D1 queries per Worker invocation (1,000 on Paid), and a whole sweep runs inside one Durable Object invocation. So cap the fresh asks a sweep may make and report the rest as deferred for the next one:

const MAX_ASKS_PER_SWEEP = 10

Do the arithmetic against your own tools. In the reference app the index call is one query and each fresh approval costs findMatch plus create, so a full sweep sits at 1 + 2 × 10 = 21. The same cap serves the per-minute budget: without it a backlog spends the window on its first few items and starves everything behind them. Remember which items are already parked, too, or every sweep re-asks the same question.

The daily allowances are a separate ceiling and are not usually the constraint: an hourly sweep with a handful of pending approvals is a few dozen wakes a day.

Testing

Split the suite the way the runtime splits:

Agent logic on Bun. Everything an agent does through this.tools is the dispatch contract from Testing a tool, so it is driveable against a TestApp with no workerd anywhere. Pull the parts that are pure decisions (which items are stale, which to ask about, what the cap allows) into their own module and test them directly; a Durable Object cannot be exercised on Bun, and that arithmetic should be.

Durable Object behaviour on workerd. Boot-from-alarm, the named exports, the routing guard, and approval retry after eviction are exactly the things a mock cannot exercise. Run them under @cloudflare/vitest-plugin against the generated worker rather than a stand-in, so the build wiring is what is under test. The package ships evictDurableObject, which makes "the retry survives eviction" a real assertion instead of a claim.

Two suites in the Guren repository show the two shapes: the plugin's workerd tests and examples/agents/tests, an application's own.

What the checks enforce

bunx guren check picks the agent registry up automatically when config/agents.ts exists, and contributes nothing when it does not. Not every rule is check's, though, so this is where each one is reported:

Rule Reported by
The registry is a static defineAgentsConfig() literal with valid module, export and scopes guren check, as a failure
A tool: scope names a tool some route declares guren check, as a warning
app/Agents/** imports no models, db/**, @guren/orm or @guren/plugin-agents/runtime guren check (or check --arch alone), as a failure
A non-read-only tool the agent calls requires authorization guren check, as a failure (an agent-route rule)
The agent name matches ^[A-Za-z0-9_-]+$, and budget.callsPerMinute is a whole number of at least 1 agentsPlugin, when the app boots
@guren/plugin-agents is a runtime dependency, every class has a SQLite Durable Object binding, and keep_names is not false guren cloudflare:build, before deploying

The paragraphs below give the detail behind the guren check rows. The registry covers the boot-time rules, and Install and register and The bindings verifier the build's.

check fails on a registry it or the deploy build could not read: an unparseable file, a config that is not a literal defineAgentsConfig({ agents: { … } }), a spread inside agents, a non-literal module or export, a module naming a file that does not exist or that does not export that class as a class declaration, a duplicated agent key or export name, a missing or non-literal scopes array, and a scope outside the registration grammar. It warns on a tool: scope naming a tool no route declares. Such a scope grants nothing, so it points at a typo or a renamed route rather than a security hole.

bunx guren check --arch enforces the boundary the scaffold wrote: an app/Agents/** file importing app/Models/**, db/**, @guren/orm or @guren/plugin-agents/runtime fails. This boundary is a convention the checker enforces, not a sandbox. In-process code shares the isolate and can import whatever it likes, and the checker sees static imports only, so a dynamic import() escapes it. What the boundary buys is that crossing it is visible in review rather than incidental, with the audit trail as the other half.

The routes an agent calls are checked by the ordinary agent-route rules, and the one that matters most here is that a non-read-only tool needs authorization, not merely authentication: see Authentication is not authorization.