Skip to content
Back to Blog
Building the runtime
May 5, 2026
·by Piyush·5 min read

Build the Tool Gateway: The Boundary That Actually Stops a Bad Action

ContextOS
Harness Engineering
Tool Gateway
Approval Mode
Adapters
Idempotency
Share:XHN

The first time we audited a production agent stack, we found the tool gateway was a switch statement in a 240-line file. Every adapter call passed through it; nothing was filtered, classified, or audited; and the system prompt was the only thing telling the model not to call payments.refund outside business hours. Two months later, the agent called payments.refund outside business hours.

The gateway is the boundary. Treating it as glue is the same as not having one.

This post is the build-along — adapter manifest, resolver, typed envelopes, destructive-path handshake — for a Tool Gateway that earns its keep. The full spec lives in Adapter Mesh and the Tool Manager component; this post is the operator’s compressed version with code you can run.

The shape of the boundary

Three contracts the gateway enforces, none of which the model can violate:

Registry ∩ Permissions − Prohibitions   →   Surface
Surface ∩ ApprovalMode(safety_mode)     →   Callable
Callable + Schema + ApprovalGate        →   Executed

A tool that is in the registry but not in the caller’s permissions is invisible. A tool that is callable in read_only runs is invisible to a local_write request. A tool that is destructive does not execute until an approval gate signs the call. Everything else is auditing.

The adapter manifest

An adapter declares the highest mode any of its capabilities can produce. Policy can downgrade within bundle priority, but the manifest is the ceiling. This is the file the gateway loads at boot and re-reads on hot config reload:

harness/tools/adp_payments.yaml
adapter_id: adp_payments
type: OPENAPI
endpoint_ref: internal://payments
owner: team-platform
schema_ref: ./schemas/payments.openapi.yaml
default_idempotency: required
default_timeout_ms: 4000
capabilities:
  - id: lookup
    operation: GET /v1/payments/{id}
    side_effect_class: observe
    approval_mode: read_only
 
  - id: issue_refund
    operation: POST /v1/payments/{id}/refund
    side_effect_class: write
    approval_mode: destructive
    requires_approver: true
    requires_evidence:
      - refund_window_evidence
      - order_lookup
    reversal_op: POST /v1/payments/{id}/reversal
    idempotency_header: Idempotency-Key

Three things to note before we write code that reads this.

The approval_mode is one of five canonical tiers — read_only, local_write, network, delegated, destructive. The gateway refuses to load a manifest whose mode is not in the set. There are no off-tier modes, ever. The taxonomy is documented in Approval-Mode Tiers.

The requires_evidence array is the contract that frozen-evidence snapshots in the destructive path satisfy. If a refund fires without refund_window_evidence resolved, the gateway refuses the call before any HTTP traffic.

The reversal_op is paired with the destructive op. There is no destructive capability without a declared reversal — that is the contract that makes rollback meaningful.

The typed envelopes

Every call through the gateway passes typed envelopes in both directions. The envelopes are what gets logged, signed, and replayed:

harness/tools/types.ts
export type ApprovalMode =
  | "read_only" | "local_write" | "network" | "delegated" | "destructive"
 
export type ToolCall = {
  call_id: string                     // tc_117 — unique per attempt
  trace_id: string
  run_id: string
  adapter_id: string
  capability_id: string
  approval_mode: ApprovalMode         // resolved at the gateway, not declared
  args: Record<string, unknown>       // typed against the capability's schema
  evidence_refs: string[]             // pinned evidence the call depends on
  idempotency_key?: string            // required for non-read_only modes
  approval_gate?: {
    gate_id: string                    // GATE_HIGH_VALUE
    approver: string                   // user_id of the signer
    evidence_snapshot_hash: string     // sha256 of frozen evidence at sign time
    signed_at: string
  }
  reversal_token?: string              // issued for destructive; redeem on rollback
  initiated_at: string
}
 
export type ToolResult = {
  call_id: string
  status: "ok" | "error"
  data?: Record<string, unknown>
  error?: { kind: string; message: string; retryable: boolean }
  duration_ms: number
  cost_usd?: number
  finished_at: string
}

The approval_mode field is resolved by the gateway, not copied from the manifest. Why: a tool can be downgraded by policy (“adp_orders.lookup is read_only for support agents”), so the effective mode for this call is what gets logged, not the manifest’s declared ceiling. Replay needs the resolved mode to reproduce the run; that means the gateway has to write it.

The resolver

The resolver is the function the runtime calls before any tool execution. It either returns a ToolCall ready to dispatch or refuses with a typed reason:

harness/tools/gateway.ts
import type { RunContext, ToolCall, ApprovalMode } from "./types"
import { loadManifest, loadPolicyBundle, resolvePolicyDecision } from "@/runtime"
 
const MODE_RANK: Record<ApprovalMode, number> = {
  read_only: 0,
  local_write: 1,
  network: 2,
  delegated: 3,
  destructive: 4,
}
 
export type GatewayDenial = {
  ok: false
  kind: "not_in_registry" | "not_permitted" | "prohibited"
       | "mode_above_safety_mode" | "missing_evidence"
       | "missing_idempotency_key" | "missing_approval_gate"
  detail: string
}
export type GatewayAccept = { ok: true; call: ToolCall }
export type GatewayResult = GatewayAccept | GatewayDenial
 
export async function resolveToolCall(
  ctx: RunContext,
  proposed: {
    adapter_id: string
    capability_id: string
    args: Record<string, unknown>
    evidence_refs: string[]
    idempotency_key?: string
    approval_gate?: ToolCall["approval_gate"]
    reversal_token?: string
  },
): Promise<GatewayResult> {
  const manifest = await loadManifest(proposed.adapter_id)
  if (!manifest) {
    return { ok: false, kind: "not_in_registry",
             detail: `adapter ${proposed.adapter_id} not registered` }
  }
  const cap = manifest.capabilities.find((c) => c.id === proposed.capability_id)
  if (!cap) {
    return { ok: false, kind: "not_in_registry",
             detail: `${proposed.adapter_id}.${proposed.capability_id} not declared` }
  }
 
  // 1. Permissions / prohibitions from the active context pack
  const perm = ctx.permissions.find(
    (p) => p.adapter_id === manifest.adapter_id && p.capability === cap.id,
  )
  if (!perm || perm.allow !== true) {
    return { ok: false, kind: "not_permitted",
             detail: `no permission grants ${manifest.adapter_id}.${cap.id}` }
  }
  if (ctx.prohibitions?.some((pr) => pr.adapter_id === manifest.adapter_id
                                   && pr.capability === cap.id)) {
    return { ok: false, kind: "prohibited",
             detail: `${manifest.adapter_id}.${cap.id} is prohibited in this run` }
  }
 
  // 2. Mode resolution: manifest ceiling, possibly downgraded by policy.
  // The effective mode must not exceed the run's safety_mode.
  const policy = await resolvePolicyDecision(ctx, manifest.adapter_id, cap.id)
  const effective: ApprovalMode = policy.downgrade_to ?? cap.approval_mode
  if (MODE_RANK[effective] > MODE_RANK[ctx.safety_mode]) {
    return { ok: false, kind: "mode_above_safety_mode",
             detail: `effective mode ${effective} exceeds safety_mode ${ctx.safety_mode}` }
  }
 
  // 3. Evidence requirements (declared on the capability)
  const evidenceClasses = new Set(proposed.evidence_refs.map(classOf))
  const missing = (cap.requires_evidence ?? []).filter((c) => !evidenceClasses.has(c))
  if (missing.length > 0) {
    return { ok: false, kind: "missing_evidence",
             detail: `missing evidence classes: ${missing.join(", ")}` }
  }
 
  // 4. Idempotency required for anything beyond read_only
  if (effective !== "read_only" && !proposed.idempotency_key) {
    return { ok: false, kind: "missing_idempotency_key",
             detail: `idempotency_key required for mode=${effective}` }
  }
 
  // 5. Destructive requires a signed approval gate with a frozen-evidence hash
  if (effective === "destructive") {
    if (!proposed.approval_gate || !proposed.approval_gate.evidence_snapshot_hash) {
      return { ok: false, kind: "missing_approval_gate",
               detail: `destructive call requires a signed approval_gate` }
    }
    const liveHash = await freezeEvidence(proposed.evidence_refs)
    if (liveHash !== proposed.approval_gate.evidence_snapshot_hash) {
      return { ok: false, kind: "missing_approval_gate",
               detail: `evidence drift: gate signed hash ${proposed.approval_gate.evidence_snapshot_hash}, current ${liveHash}` }
    }
  }
 
  return {
    ok: true,
    call: {
      call_id: `tc_${shortid()}`,
      trace_id: ctx.trace_id,
      run_id: ctx.run_id,
      adapter_id: manifest.adapter_id,
      capability_id: cap.id,
      approval_mode: effective,
      args: proposed.args,
      evidence_refs: proposed.evidence_refs,
      idempotency_key: proposed.idempotency_key,
      approval_gate: proposed.approval_gate,
      reversal_token: effective === "destructive" ? `rev_${shortid()}` : undefined,
      initiated_at: new Date().toISOString(),
    },
  }
}
 
function classOf(ref: string): string {
  // ref form: "kg:order:ord_881#snapshot_kg_2026_05_09_T0930"
  return ref.split(":")[1] ?? "unknown"
}

Three properties of this code earn their keep.

The resolver returns a typed denial with a kind, not a thrown error. The denial lands in the DecisionRecord as a policy_decision, which means replay reproduces it. Throwing exceptions makes the denial invisible to audit; typing it makes it part of the record.

The evidence hash check on destructive calls catches the most-missed correctness bug in production agent stacks. The approval gate signs a snapshot of the evidence at sign time. If the world changes between signing and execution — the order shipped, the customer cancelled — the snapshot hash diverges, and the gateway refuses the call. No silent staleness.

The reversal token is issued at the gateway, not by the adapter. The token is what the rollback path redeems against the reversal_op. Issuing it at the gateway keeps the adapter dumb; the gateway is the single owner of the rollback contract.

The dispatch

Once a GatewayAccept returns, dispatch is a thin wrapper that calls the adapter, times it, captures errors, and writes both envelopes to the trace store:

harness/tools/dispatch.ts
import type { ToolCall, ToolResult } from "./types"
import { writeToolEnvelope } from "@/store"
import { adapterClient } from "@/adapters"
 
export async function dispatch(call: ToolCall): Promise<ToolResult> {
  const started = Date.now()
  await writeToolEnvelope("tool_call", call)
 
  try {
    const client = adapterClient(call.adapter_id)
    const data = await client.invoke(call.capability_id, {
      args: call.args,
      headers: call.idempotency_key
        ? { "Idempotency-Key": call.idempotency_key }
        : {},
      timeout_ms: client.timeoutFor(call.capability_id),
    })
 
    const result: ToolResult = {
      call_id: call.call_id,
      status: "ok",
      data,
      duration_ms: Date.now() - started,
      finished_at: new Date().toISOString(),
    }
    await writeToolEnvelope("tool_result", result)
    return result
  } catch (err) {
    const result: ToolResult = {
      call_id: call.call_id,
      status: "error",
      error: classifyError(err),
      duration_ms: Date.now() - started,
      finished_at: new Date().toISOString(),
    }
    await writeToolEnvelope("tool_result", result)
    return result
  }
}

classifyError(err) is where typed FailureVerdict lives — the dispatcher mapping retry-with-backoff vs. escalate vs. reverse is a separate post worth its own walkthrough; see Failure Playbooks: The Typed Verdict Map. What matters here is that dispatch never decides the rollback path; it produces a typed result and hands it back.

A worked refund

Putting the pieces together for a high-value refund call:

runtime/refund-flow.ts
const proposed = {
  adapter_id: "adp_payments",
  capability_id: "issue_refund",
  args: { id: "pay_8861", amount_inr: 24500 },
  evidence_refs: [
    "kg:order:ord_881#snapshot_kg_2026_05_09_T0930",
    "kg:refund_window:rw_881#snapshot_kg_2026_05_09_T0930",
  ],
  idempotency_key: "refund-ord_881-attempt-1",
  approval_gate: {
    gate_id: "GATE_HIGH_VALUE",
    approver: "user_finance_lead_77",
    evidence_snapshot_hash: "sha256:b2a1...",
    signed_at: "2026-05-09T09:31:30Z",
  },
}
 
const verdict = await resolveToolCall(runCtx, proposed)
if (!verdict.ok) {
  // typed denial — write into the DecisionRecord, do not throw
  return finalizeWithDenial(runCtx, verdict)
}
const result = await dispatch(verdict.call)
return finalizeWithResult(runCtx, verdict.call, result)

The agent never sees the resolution logic. It proposes a call; the gateway either gives it a ToolCall or a typed denial. Both paths land in the same DecisionRecord, both are replayable, both are auditable. The model has no way to bypass the boundary, because it is the boundary.

What this changes

Three things shift the day this is in place.

The “the agent called a tool it shouldn’t have” class of bug becomes structurally impossible. The model can ask for any call; the gateway is the only thing that runs them. If the call is not in the surface, it does not run, period.

Audit becomes typed. Every call has an envelope, every denial has a kind, every destructive action has a snapshot hash and a reversal token. Replay reproduces all of it. The audit story is what the gateway emitted, not what the team thinks happened.

Adapters get dumber, not smarter. The gateway owns mode, evidence, idempotency, approval, reversal. Adapters call HTTP. That separation is what lets the team add adapters in hours instead of days.

The boundary is the harness’s most opinionated piece. Build it as glue and you have the agent stack equivalent of running production behind try/except: pass. Build it with typed envelopes and a refusal-first resolver, and the rest of the harness has something to anchor on.

Pull the resolver into your stack this week. Pick one destructive capability — refund, post, send. Wire its manifest, its gate, its reversal. The first one is the hard one; the next ten cost a day each.

Found this useful? Share it.

Share:XHN
Analytics consent

We use Google Analytics to understand site usage. You can opt in or decline.