How to Make Tool Calling Safe, Secure and With Guard Rails

James Phoenix
James Phoenix

Every developer building an agentic system hits the same moment. The agent calls a tool it should not have called. Delete. Mutate. Send. Something irreversible happens on behalf of the wrong user, with no business logic justification at all.

The reflex is to open the system prompt and add a rule. “Do not call update_account unless the user confirms.” You ship it, it behaves for a week, and three weeks later a slightly different phrasing slips past it and a production account gets corrupted. The rule did not fail because it was poorly written. A prompt is a probabilistic suggestion, not an enforcement mechanism. Stochastic systems cannot be your access control layer.

The goal of this article is to show two concrete architectures that actually work, when to reach for each one, and the real trade-offs between them.


The Core Problem: Identity, IDOR, and Authority

Before the code, three ideas worth locking in.

Authentication is not authorization. Knowing who someone is tells you nothing about what they are allowed to touch. An authenticated agent can still call update_account with an account ID that belongs to a different tenant. This is IDOR (insecure direct object reference), and agents make it significantly worse than a CLI does. A human typing commands almost never knows another tenant’s IDs. An agent infers resource identifiers from model output, conversation history, and user-controlled prompt text. A malicious document the agent is reading can instruct it to call a tool with a hostile ID, and the connection is authenticated the whole time.

The model must never supply identity. If tenantId is a field in a tool’s inputSchema, then the model can set it. A prompt-injected payload can set it to someone else’s tenant. The model is an untrusted caller that happens to have access to your runtime. Identity must flow through a side channel the model cannot see or touch.

Agents should get less authority than humans. The same user interacting through a CLI and through an agent should not have identical capability sets. An agent can be manipulated into exercising authority a human would never consciously grant. Narrowing capability per surface is a structural defense, not a UX decision.

Leanpub Book

Read The Meta-Engineer

A practical book on building autonomous AI systems with Claude Code, context engineering, verification loops, and production harnesses.

Continuously updated
Claude Code + agentic systems
View Book

Approach One: Plain TypeScript with the Vercel AI SDK

The cleanest implementation in plain TypeScript separates two concerns the AI SDK conflates by default: what a tool does and how it runs safely.

The ToolSpec: Separating Logic from Rails

A ToolSpec describes what a tool is. It is a plain object with the tool’s description, its input schema, the capability required to call it, and the actual logic. The cross-cutting rails (authorization, timeout, tracing) live nowhere in here.

type Capability = "billing:read" | "usage:read" | "tariff:write"

interface Principal {
  userId: string
  tenantId: string   // branded as TenantId in production
  capabilities: ReadonlySet<Capability>
}

interface ExecutionContext {
  principal: Principal
  trace: (event: ToolEvent) => void
  signal: AbortSignal
}

interface ToolSpec<A, R> {
  name: string
  description: string
  inputSchema: z.ZodType<A>
  requiredCapability: Capability
  timeoutMs?: number
  run: (args: A, ctx: ExecutionContext) => Promise<R>
}

The run function receives a verified ExecutionContext. It never receives identity as a raw argument the model could have filled in.

const getBillSpec: ToolSpec<{ period: string }, Bill | { status: "not_found" }> = {
  name: "getBill",
  description: "Fetch the current customer's bill for a billing period.",
  inputSchema: z.object({
    period: z.string().describe("Billing period as YYYY-MM"),
  }),
  // No tenantId field. The model has no slot to put a hostile ID into.
  requiredCapability: "billing:read",
  timeoutMs: 2_000,
  run: async ({ period }, ctx) => {
    const bill = await db.findBill(ctx.principal.tenantId, period, { signal: ctx.signal })
    return bill ?? { status: "not_found" }
  },
}

The Thin Wrapper: Three Rails Applied Once

makeToolRuntime takes a verified context and returns a build function. Every tool built through it gets authorization, timeout, and tracing automatically. It requires a context to be constructed at all, so an unbound tool cannot exist.

const makeToolRuntime = (ctx: ExecutionContext) => ({
  build: <A, R>(spec: ToolSpec<A, R>) =>
    tool({
      description: spec.description,
      inputSchema: spec.inputSchema,
      execute: async (args: A, { abortSignal }) => {
        const t0 = performance.now()

        // Rail 1: authorization
        if (!ctx.principal.capabilities.has(spec.requiredCapability)) {
          ctx.trace({ tool: spec.name, decision: "deny" })
          return { status: "forbidden" as const }
        }

        // Rail 2: timeout merged with the request's abort signal
        const deadline = AbortSignal.timeout(spec.timeoutMs ?? 5_000)
        const signal = abortSignal ? AbortSignal.any([abortSignal, deadline]) : deadline

        // Rail 3: trace and error boundary
        try {
          const result = await spec.run(args, { ...ctx, signal })
          ctx.trace({ tool: spec.name, decision: "allow", ms: performance.now() - t0 })
          return result
        } catch (err) {
          ctx.trace({ tool: spec.name, decision: "error", ms: performance.now() - t0 })
          throw err
        }
      },
    }),
})

Two Ways to Inject Context: Trade-Offs

Once you have this structure, there are two ways to get the verified context into the tools.

Closure factory (build on demand). The handler builds the context from the verified request, constructs the runtime, and stamps out the tools. The tenantId is trapped lexically inside run via the captured ctx.

app.post("/chat", async (req, res) => {
  const principal = req.principal!  // set by auth middleware from the verified token

  const ctx: ExecutionContext = {
    principal,
    trace: (e) => logger.info({ ...e, reqId: req.header("x-request-id") }),
    signal: req.signal,
  }

  const rt = makeToolRuntime(ctx)
  const tools = {
    getBill: rt.build(getBillSpec),
    getUsage: rt.build(getUsageSpec),
  }

  const result = await generateText({
    model: openai("gpt-5"),
    messages: req.body.messages,
    tools,
    stopWhen: stepCountIs(8),
  })
  res.json({ text: result.text })
})

The security property is lexically visible: the principal that run reads from ctx cannot have been set by the model, because it came from req.principal, which came from the auth middleware, which came from the verified token. A reader can follow this chain without knowing anything about the SDK.

SDK context channel (define tools once). The alternative is to define tools as module-level constants and inject context per request through experimental_context (AI SDK v4/5) or toolsContext with a typed contextSchema (AI SDK v7). Tools read this from the second argument of execute.

// Defined once, globally. No principal in scope at definition time.
const getBill = tool({
  inputSchema: z.object({ period: z.string() }),
  execute: async ({ period }, { experimental_context }) => {
    const ctx = experimental_context as ExecutionContext  // SDK v4/v5: requires a cast
    if (!ctx.principal.capabilities.has("billing:read")) return { status: "forbidden" }
    return db.findBill(ctx.principal.tenantId, period, { signal: ctx.signal })
  },
})

// In the handler, inject per request:
await generateText({
  model: openai("gpt-5"),
  tools: { getBill },
  experimental_context: buildCtx(req),  // same verified context, different delivery
  messages: req.body.messages,
  stopWhen: stepCountIs(8),
})

The security property is identical: the context travels through a server-controlled side channel, not through inputSchema. The difference is operational.

Closure factory SDK context channel
Type safety Full. No casts. Requires as cast in v4/5. Typed in v7 via contextSchema.
Version coupling Version-proof. Standard TypeScript closure. Field names changed across SDK versions.
Tools defined Per request. Slight overhead. Once at module load.
Security boundary Lexically obvious. Invisible without knowing the SDK convention.
Best for Teams who want the security property to be readable in the code. Teams with many tools who prefer a global definition and are on v7.

I default to the closure factory. The overhead of building tools per request is negligible, and the security property does not require SDK knowledge to audit. If you are on AI SDK v7 and defining twenty tools, the toolsContext + contextSchema approach is the clean alternative. The one pattern to avoid in any version is experimental_context with a cast: if the cast is wrong, the compiler will not tell you.


Approach Two: Effect TS

If you are already using Effect, the same guarantees can be encoded at the type level rather than enforced at runtime. The ideas are identical: identity as context, typed errors for authorization failures, capability narrowing per edge. What changes is that violations are caught by the compiler rather than by runtime checks.

Identity as a typed requirement. CurrentUser is a Context tag. A function that declares it in its type signature R cannot be run without it being provided. There is no way to call the function and accidentally skip the identity requirement.

class CurrentUser extends Context.Tag("CurrentUser")<
  CurrentUser,
  { userId: string; tenantId: string; capabilities: Set<Capability> }
>() {}

Authorization as a typed combinator. The authorize function reads CurrentUser from context and returns it if the capability is present, or fails with a typed Forbidden error.

class Forbidden extends Data.TaggedError("Forbidden")<{ capability: Capability }> {}

const authorize = (cap: Capability) =>
  Effect.flatMap(CurrentUser, (user) =>
    user.capabilities.has(cap)
      ? Effect.succeed(user)
      : Effect.fail(new Forbidden({ capability: cap }))
  )

A use-case whose type is the security contract. The signature of getBill tells you everything about what it requires and what can go wrong.

const getBill = (period: string) =>
  Effect.gen(function* () {
    const user = yield* authorize("billing:read")  // fails to Forbidden if not allowed
    return yield* BillingRepo.findBill(user.tenantId, period)
    //                                 ▲ tenantId from the verified context, never from args
  })
// Type: Effect<Bill, Forbidden | NotFound, CurrentUser | BillingRepo>

Capability narrowing per edge. The HTTP edge and MCP edge each provide CurrentUser with a different capability set. The same getBill Effect runs at both. A human through HTTP can write billing if the capability set includes it. An agent through MCP provably cannot if billing:write is absent from the MCP set.

// Each edge provides a different CurrentUser layer.
const httpLayer = CurrentUserFromToken(bearerToken, HTTP_CAPABILITIES)
const mcpLayer  = CurrentUserFromToken(mcpSession,  MCP_CAPABILITIES)

// The same use-case, run through different edges:
getBill("2026-05").pipe(Effect.provide(httpLayer))  // full human authority
getBill("2026-05").pipe(Effect.provide(mcpLayer))   // narrowed agent authority

The compiler enforces exhaustive error handling. Because Forbidden is in the E channel, any caller that does not handle it will not compile. You cannot write a tool adapter that forgets authorization can fail.

Effect vs Plain TypeScript: Trade-Offs

Plain TypeScript + ToolSpec Effect
Authorization enforcement Runtime check in the wrapper. Forgetting requiredCapability is a logic error. Compile-time. A function that touches CurrentUser data cannot typecheck without the requirement declared.
Error handling Return a { status: "forbidden" } value. Caller can ignore it. Forbidden in E channel. Caller cannot compile without handling it.
Learning curve Minimal. Any TypeScript developer can read it. Significant. Effect has its own runtime, mental model, and idioms.
Typed error composition Manual. Each tool decides what to return on failure. Automatic. Errors accumulate in E and are visible in every callsite’s type.
Transport flexibility Works with any HTTP framework. Works with any HTTP framework, and in-process MCP calls preserve the full E channel, where HTTP would flatten it to a status code.
Best for Most teams. The closure factory gives you 90% of the safety with none of the overhead. Teams already using Effect, or systems where the typed error channel is load-bearing (credit ledgers, billing mutations, anything where silently swallowing an authorization failure is a serious incident).

The honest summary: if your team does not already know Effect, the ToolSpec wrapper with the closure factory gives you the security properties that matter (identity never in the schema, capability checks applied uniformly, timeout and tracing centralized) with zero new dependencies and code any TypeScript developer can read. If you are already in Effect, the typed approach closes the one remaining gap: you cannot forget to declare a capability requirement, because the type system will not let you call an Effect that needs CurrentUser without providing it.


The Scoped Query: Where IDOR Dies

Both approaches share one structural requirement that no wrapper can enforce on your behalf: every database query that returns user data must be scoped to the tenant in the query itself, not checked afterward.

// Weak: fetches the row, then compares. Two problems:
// 1. Leaks existence via timing/error shape. 
// 2. Relies on you remembering to add the comparison every time.
const account = await db.findById(accountId)
if (account.tenantId !== principal.tenantId) throw new Forbidden()

// Strong: scope is in the WHERE clause. A cross-tenant ID returns nothing.
const account = await db.findByIdAndTenant(accountId, principal.tenantId)
//                                                      ▲ wrong tenant = NotFound

The scoped query is not a runtime check that might be forgotten. It is a fact about the database call. Back it with Postgres row-level security as a backstop, and write one integration test that attempts a cross-tenant read and asserts it fails. The linter catches lazy authors early; RLS catches the mistakes that slip through; the test proves both are working.


The Rule to Keep

Both approaches, all the complexity, reduces to one thing worth keeping: what the model chooses goes in inputSchema; whose data it is comes from context the model cannot see.

The wrapper enforces the second half by construction. The scoped query makes the first half irrelevant even if a bug slips through. And neither the prompt nor the README tells the model whose data it is, because neither of those is enforcement.

The model proposes. The code disposes.

Topics
Agent ReliabilityAi AgentsPrompt InjectionQuality GatesVerification

Newsletter

Become a better AI engineer

Weekly deep dives on production AI systems, context engineering, and the patterns that compound. No fluff, no tutorials. Just what works.

Join 306K+ developers. No spam. Unsubscribe anytime.


More Insights

Cover Image for Three Execution Modes: When Your Agent Needs Temporal

Three Execution Modes: When Your Agent Needs Temporal

The most common mistake I see in agentic system design is treating Temporal as either the answer to everything or a scary add-on you defer until later. The reality is there are three distinct executio

James Phoenix
James Phoenix
Cover Image for Closed-Loop Agent Observability: IDs + Injected Prompts

Closed-Loop Agent Observability: IDs + Injected Prompts

The typical approach to improving your local coding agent observability when generating features is to add logging and hope the agent reads it after the fact. That is backwards. **The right pattern is

James Phoenix
James Phoenix