Skip to main content

Guardrails

Guardrails classify every tool call an agent makes into an action class — execute autonomously, route to human approval, or block — using deterministic, non-LLM guard expressions.

Overview​

A guardrail is a standalone, versioned resource, separate from IAM policies: a policy decides whether a caller may invoke an endpoint; a guardrail decides whether an agent may take this action, with these arguments, in this context, without a human signing off. It maps tool calls to action classes (A/B/C/D) and gates class-B autonomy behind guard expressions evaluated after the model produces the call and before it executes.

Class-C actions route into the approvals queue, guards read spend from usage metering, and expressions use the JSON Logic evaluator that orchestrations use. Tools, agents and projects each carry a guardrail_ids list, so a guardrail attaches at any of those scopes; the strictest decision wins.

See the Permissions Reference for the IAM action strings for this module.

Data Model​

Guardrail​

FieldTypeDescription
idstringPublic identifier prefixed with guard_
project_idstringID of the owning project
namestringHuman-readable name
descriptionstringOptional description
versionintegerIncremented on every document write; prior versions are archived
documentobjectThe action-class document (see below)
context_tool_idstringOptional tool the platform calls at evaluation time to fetch fresh guardrail context
context_modestring | nullHow tool-fetched context combines with the caller-supplied context: merge (default) or replace. null when explicitly cleared
created_atstringISO 8601 creation timestamp
updated_atstringISO 8601 last-updated timestamp

The document:

FieldTypeDescription
classstring | objectA class literal ("A" | "B" | "C" | "D") or a JSON Logic expression returning one — see Classification
default_classstringApplied when the class expression returns anything other than a valid class (a missing key, null, a typo). Defaults to C (fail-closed)
guardobjectA single JSON Logic expression; when the call classifies as B, it must evaluate truthy to execute autonomously. Compose multiple conditions with { "and": [...] }
escalatebooleanWhen true, a failing guard routes to approval instead of tripping fail-closed

GuardrailVersion​

An immutable archive of a guardrail's configuration at one version; same shape and engine as AgentVersion.

FieldTypeDescription
idstringguard_ver_-prefixed public ID of the archived version
guardrail_idstringThe guard_-prefixed guardrail this version belongs to
versionintegerThe archived version number
configobjectThe versioned surface at that version — today { document } and nothing else
labelstringOptional human tag, e.g. pre-tightening; null when unset
created_bystringPublic ID of the user whose action produced this version; null when there was none
created_atstringISO 8601 timestamp

Only the document is versioned; name, description and the context binding are metadata. One version number denotes one policy, which is what an evaluation record cites.

Key Concepts​

Attachment​

A guardrail attaches through the guardrail_ids list on one of three resources; each scope can carry several:

  • Project — every tool call by every agent in the project.
  • Agent — every tool call the agent makes.
  • Tool — that tool wherever it is used, by any agent.

Adding an id can only tighten the outcome, so it needs the carrying resource's update permission (tools:UpdateTool, agents:UpdateAgent, projects:UpdateProject). Removing an id can loosen posture, so it additionally requires guardrails:DetachGuardrail.

Every applying guardrail evaluates and the strictest decision wins, ordered blocked > tripwire > route_to_approval > execute; where several classify the same call as B, all their guards must pass. Composition is order-independent; A is the identity. One guardrail_evaluation record is written per guardrail evaluated.

Action Classes​

ClassMeaningBehavior
ARead-only / harmlessAlways execute; logged to the activity feed
BAutonomous with a guardExecute iff the guard passes; a failing guard trips fail-closed (or routes to approval — see Tripwires)
CHuman sign-offFiles an ApprovalItem (origin: tool_call); executes only on approval
DForbiddenThe call is blocked at dispatch; the model receives a blocked tool result and continues its turn

A class expression returning anything other than "A" / "B" / "C" / "D" resolves to default_class (default C). Mapping autonomy grades onto classes: Layers are concerns, not autonomy levels.

Class C uses the approvals queue's return-pending mechanics: the tool result is { "status": "pending_approval", "approval_id": …, "expires_at": … } and the turn completes. Approval starts a continuation generation with the frozen (or edited) arguments; rejection executes nothing; expiry ends the chain unless the agent opts in (Agents → Approval Expiry).

The document may carry expires_in (seconds): the sign-off window for a class-C approval it files (default 24h). When several guardrails apply, the strictest guardrail's expires_in wins, in agent tool dispatch and the orchestration tool node alike.

Classification​

class is a literal ({ "class": "C" } always requires sign-off) or a single JSON Logic expression returning the class, evaluated over the same namespaces as guards (args.* / context.* / runtime.*). There is no rule list; anything other than a valid class falls through to default_class.

To gate several tools differently, create a guardrail per tool and attach each to its tool rather than branching on runtime.tools.name. This example classifies a budget update B below a threshold and C at or above it:

{
"default_class": "C",
"class": { "if": [{ "<": [{ "var": "args.amount" }, 500] }, "B", "C"] },
"guard": { "<=": [{ "var": "args.amount" }, { "var": "context.max_daily_budget" }] }
}

Guards and Guardrail Context​

class and guard are single JSON Logic expressions (the evaluator orchestration mappings use); compose with if/and/or/!, not arrays. Every var resolves against three namespaces:

NamespaceSource
args.*The proposed call's arguments (post preset-merge — the same frozen arguments an approval item records)
context.*The effective guardrail context — application-owned, see below
runtime.*Platform-computed values (fixed catalog below); reserved — never writable by the caller or the context tool

The caller passes a free-form guardrail_context object on the generation request or orchestration-run start; the platform never interprets it. A guardrail may also name a context_tool_id, an ordinary tool in its own project (400 TOOL_NOT_FOUND otherwise) the platform calls immediately before classifying each gated call, so long-parked runs read fresh context. context_mode combines the two: merge (default) shallow-merges top-level keys over the caller-supplied object, the tool's value winning; replace substitutes it entirely.

The context tool is called on every gated call, uncached, with the proposed call as its input, nested under one key:

{ "call": { "action": "<action>", "tool": { "id": "tool_…", "name": "…" }, "args": { … } } }

call.args are the guard's args.*: preset parameters applied, approval justification fields removed. The shape is the same on every gate site (agent turn, client tool, orchestration tool node, direct call or pipeline step) and in a dry run. Its only top-level key is call, so the input never supplies the action a builtin context tool reads its operation from: set that in preset_parameters.action. A tool that ignores its input works unchanged. http tools receive the input as the request body.

Use it for rules about the call's target entity, which runtime.* does not key by argument — the context tool reads call.args.optimization_id and returns both figures:

{
"and": [
{ ">=": [{ "var": "context.hours_since_last_change" }, 24] },
{ "<": [{ "var": "context.retunes_7d" }, 3] }
]
}
  • call.args are model output. The tool runs under the calling agent's credentials (same project scoping and secret resolution); validate the arguments before using them in a lookup. Its result never enters the model context.
  • Bind it to the tools it governs, not to a whole agent: every gated call pays one tool call and a fail-closed timeout risk (SOAT_GUARDRAIL_CONTEXT_TIMEOUT_MS, default 5000 ms), and a read-heavy agent makes dozens of reads per turn.
  • Fail-closed. An inaccessible tool, an error, a timeout or a non-object result contributes no context.* keys.

The runtime.* catalog is a grammar, leaves only:

runtime.action the request fact; no module, no window
runtime.<module>.<identity> runtime.tools.name
runtime.<module>.<metric>.<window> runtime.tools.tool_calls.24h

A module answers about the entity of that module in the current call: projects the project, agents the calling agent, tools the tool being called, guardrails the guardrail being evaluated, orchestrations the current orchestration run. Windows 1h / 24h / 7d / 30d are rolling and end at evaluation time; total is the entity's lifetime — all-time for a tool, "this run so far" for orchestrations. The served combinations:

ModuleIdentityMetricsWindows
projectsidtool_calls tokens cost_usd errors1h 24h 7d 30d
guardrailstool_calls1h 24h 7d 30d
agentsidtool_calls tokens cost_usd1h 24h 7d 30d
toolsid nametool_calls errors1h 24h 7d 30d total
orchestrationsnode_attempttool_calls tokens cost_usdtotal

Any other path — a non-leaf (runtime.tools.tool_calls), an unserved combination (runtime.tools.tokens.24h) — is refused at write time.

MetricTypeReads
tool_callsintegertool_execution events: one per outbound tool call, whatever the target answered
errorsintegertool_execution events whose outcome is error or timeout
tokensintegerBillable tokens (input + output + cached) of the entity's usage events
cost_usdnumberCost of the entity's usage events, every meter

Every metric is read live from the usage meter at evaluation time. An entity that has done nothing yet reads 0; a key whose entity the call does not have — agents.* on an orchestration tool node, orchestrations.* outside a run, tools.* on an inline tool — is unresolvable, and so is a failing query.

guardrails.tool_calls.* counts the executions of the calls this guardrail released: every guardrail that applied to a call and let it through is recorded on its tool_execution event. Attach one guardrail to a set of tools (every write, say) and one key caps the set, with no tool list in the expression. A pipeline passes the guardrails that released it to each step.

A cost key resolves to null when its spend cannot be priced. SUM(cost_usd) ignores unpriced events, so a window that metered LLM usage and priced none of it would pass every ceiling. The cost_usd keys report null instead, which fails the guard: the same verdict a cost_usd quota answers with QUOTA_UNENFORCEABLE, cleared once the models carry price book rows. Not triggered by a window with no LLM usage (reads 0), unpriced embeddings (their rate is deployment configuration, not a tenant price row), or a partly priced window (any priced LLM event clears it; unpriced events count as zero). The partly priced gap is reported as a quota_unpriced exception on the project's cost_usd quota naming the rows to price.

Two windows at once, and a failure circuit breaker, in one guard:

{
"class": "B",
"guard": {
"and": [
{ "<": [{ "var": "runtime.tools.tool_calls.24h" }, 50] },
{ "<": [{ "var": "runtime.tools.tool_calls.1h" }, 10] },
{ "<": [{ "var": "runtime.tools.errors.1h" }, 3] }
]
}
}

Fail-closed at both ends. At write time, a var outside the three namespaces, or a runtime.* key outside the catalog, is rejected with 400. At evaluation time, a context.* key absent from the effective context, a context-tool failure or timeout, or an unresolvable runtime.* provider fails closed: in class the result is default_class; in guard it counts as a failed guard and tripwire semantics apply.

Variable casing. guardrail_context (and a dry-run's args) keys pass through verbatim, with no snake↔camel conversion; snake_case is recommended (it matches the runtime.* catalog): { "var": "context.max_daily_budget" } reads a supplied max_daily_budget.

Missing keys. JSON Logic coerces an absent var to a falsy, zero-ish value, so { "<": [{ "var": "args.amount" }, 500] } is true when args.amount is absent. Test presence explicitly when that must not reach the permissive branch: { "and": [{ "var": "args.amount" }, { "<": [{ "var": "args.amount" }, 500] }] }.

Tripwires and escalate​

A failing class-B guard is a tripwire: by default it aborts the action and files an exception. escalate: true routes the call to the approvals queue instead.

escalate is per-guardrail: a failing guard yields tripwire without it and route_to_approval with it; tripwire outranks route_to_approval in the decision ordering.

Per-run spend ceilings​

runtime.orchestrations.tokens.total and runtime.orchestrations.cost_usd.total expose the current orchestration run's cumulative metered spend, live (runtime.orchestrations.tool_calls.total its tool calls), so a ceiling trips mid-run on the call that crosses it, where a project-windowed guard would barely move.

Give the ceiling itself as guardrail_context (or a context tool) so one guardrail serves every run:

soat create-guardrail \
--name "Per-run token ceiling" \
--document '{
"class": "B",
"guard": {
"<": [
{ "var": "runtime.orchestrations.tokens.total" },
{ "var": "context.action_token_ceiling" }
]
}
}'

Attach it to the tools the run dispatches; the guard fails before the tool runs. Swap tokens.total for cost_usd.total to cap dollars.

  • Fail-closed outside a run. The keys are unresolvable when no run is in scope (they do not read as 0), so a per-run ceiling attached at project scope trips on plain agent calls too; attach at tool scope unless that is intended.
  • Metering granularity is the resolution. Counters advance as each provider call is metered (usage coverage), so a ceiling trips on the first gated call after it is crossed; a single over-budget call can still complete.

Client Tools​

Client tools are gated at the requires_action handoff: class A and a passing B hand the call to the client; class C files the approval item first and hands off only on approval; class D blocks the handoff; a tripwire aborts before anything reaches the client. The platform cannot observe what the client does after.

Orchestration tool nodes​

An orchestration tool node is gated at dispatch with no agent in scope, so it composes only the project + tool scopes (agentId/generationId are null on the evaluation identity and audit record). The strictest decision is enacted as:

  • A / passing B — the tool executes with the (cleaned) node inputs.
  • C — the run parks on the node with a requires_action of type: "approval", filing an ApprovalItem (origin: node). On approval the node re-dispatches with the frozen (or edited) arguments without re-evaluating the guardrail; on rejection or expiry the tool never runs and only a matching decision edge (condition: "rejected" / "expired") follows.
  • D / tripwire — a routable blocked outcome, not a run failure: the node records a { status, reason } artifact and branches by label, so an edge conditioned on blocked (or tripwire) routes to a fallback path. An unlabeled success edge does not follow a blocked node.

Direct calls and pipeline steps​

A tool-scoped guardrail also gates the dispatches with no agent and no orchestration graph: POST /api/v1/tools/{tool_id}/call, every step of a pipeline tool, a trigger whose target is a tool, an ingestion rule converter, an eval tool scorer, and a tool_id embedded in a message. These compose project + tool scope only, and none can await a decision:

  • A / passing B — the call runs with the cleaned arguments.
  • C / D / tripwire — refused with 422 TOOL_DISPATCH_FAILED, whose meta carries the tool_id and the outcome that settled it. Inside a pipeline the step's own PIPELINE_STEP_FAILED names the settled step.

A gated pipeline is adjudicated before its first step runs, so a refusal never leaves half a pipeline applied. Reach an approval-gated tool through an agent or an orchestration, which can park on the sign-off.

Not gated: a guardrail's own context fetch, and an approved call (the filing guardrail already classified it and a human signed off on those exact arguments).

Running a tighter posture in one project​

There is no override resource: attach a tighter guardrail at the project scope ({ "class": "C" } forces sign-off on every call its agents make) or at one tool's scope. Stricter-wins means it can only tighten; other projects are untouched.

Versioning​

The policy uses the append-only archive that backs agent versions. Version 1 is written on create; every write that changes the document increments version and archives a GuardrailVersion. Approval items, activity entries and exceptions record the governing version.

Three writes archive nothing: a metadata-only edit (name, description, context_tool_id, context_mode); re-writing the document already held (compared structurally); restoring the version already live. version_label on a create or update annotates the version that write archives and is not itself a change.

A write may name the version it is changing (expected_version, or an If-Match header) and is refused with 409 VERSION_CONFLICT when the resource has moved on — see Concurrent Writes.

OperationEndpoint
List versions, newest firstGET /api/v1/guardrails/{guardrail_id}/versions
Fetch one versionGET /api/v1/guardrails/{guardrail_id}/versions/{version}
Roll back to a versionPOST /api/v1/guardrails/{guardrail_id}/versions/{version}/restore

Restore appends. Restoring v1 of a guardrail at v2 writes v1's document as v3, so records citing v2 still resolve. It runs through the ordinary update path (re-validated), takes an optional label, and rolls back only the policy; name, description and the context binding are untouched.

Attachments reference the id, not a version, so a document edit takes effect everywhere at once; dry-run it first. There is no release/canary layer: splitting traffic across two policies would under-enforce one.

Deletion​

A guardrail cannot be deleted while attached: DELETE /api/v1/guardrails/{guardrail_id} returns 409 listing the tools, agents and projects whose guardrail_ids reference it. Each must be detached first (guardrails:DetachGuardrail, see Attachment). A dangling reference met at evaluation time fails closed as class C.

Dry-run Evaluation​

POST /api/v1/guardrails/{guardrail_id}/evaluate runs the full evaluation pipeline (class, guard, context tool per context_mode, live runtime.*) against caller-supplied args and guardrail_context, and returns the evaluation record a real call would produce. Nothing executes or is filed. Pass an optional tool_id to resolve runtime.tools.*; the context tool receives the same call input a real call sends, built from args and tool_id. An unresolvable runtime.* key fails closed as at runtime. Use it before attaching a document, or before editing a widely attached one.

Evaluation Audit Record​

Every evaluation writes a guardrail_evaluation activity entry and stamps the generation/run record:

{
"kind": "guardrail_evaluation",
"guardrail_id": "guard_V1StGXR8Z5jdHi6B",
"guardrail_version": 3,
"scope": "tool",
"tool": "update-budget",
"action": "update-budget",
"class": "B",
"decision": "execute",
"guard_result": true,
"context_source": "merged",
"context_snapshot": {
"args.amount": 450,
"context.max_daily_budget": 500,
"context.cost_ceiling": 1000,
"runtime.projects.cost_usd.24h": 812.4
},
"agent_id": "agent_V1StGXR8Z5jdHi6B",
"orchestration_run_id": "orch_run_V1StGXR8Z5jdHi6B",
"generation_id": "gen_V1StGXR8Z5jdHi6B"
}
  • tool / action name the call being classified; both are null for a call with no tool in scope.
  • decision is one of execute | route_to_approval | blocked | tripwire.
  • class is the resolved class; when the class expression returned an invalid value it is the applied default_class.
  • scope records where this guardrail was attached: project | agent | tool. One record per applying guardrail; the enacted decision is the strictest across them.
  • context_source records where the effective context came from: caller | tool | merged | none.
  • guard_result is the guard expression's boolean outcome; null when the document has no guard or the call did not classify as B.
  • context_snapshot is a flat map of only the vars the evaluation referenced, keyed by fully-qualified path and frozen at evaluation-time value; unreferenced (possibly sensitive) context is never recorded.

Evaluations that changed the call's outcome (route_to_approval, blocked, tripwire; not execute) are also mirrored into the audit log as a platform-originated entry (action: guardrails:Evaluate, detail.kind: guardrail_evaluation); a route_to_approval entry also carries the filed approval_id.

Formation resource​

A guardrail formation resource (GuardrailResourceProperties) takes name, description, class, default_class, guard, escalate, context_tool_id, context_mode: the fields of Create a guardrail with document flattened to top-level properties. context_tool_id may be a { "ref": "ResourceName" } to a tool resource in the same template, and a tool or agent resource can attach it via guardrail_ids: [{ "ref": "ResourceName" }]. class/default_class/guard/escalate are recombined into one document write on every create/update, so an update omitting one drops it (matching PATCH /api/v1/guardrails/{guardrail_id}'s full-replace semantics for document).

Who may act on a guardrail​

Every route that acts on one guardrail — read it, change it, delete it, dry-run it, or read and restore its versions — is authorized against that guardrail's SRN, srn:<project_id>:guardrail:<guardrail_id>, not against the project. A policy may therefore name the guardrails it covers:

{
"statement": [
{
"effect": "Allow",
"action": ["guardrails:GetGuardrail", "guardrails:EvaluateGuardrail"],
"resource": ["srn:proj_V1StGXR8Z5jdHi6B:guardrail:guard_V1StGXR8Z5jdHi6B"]
}
]
}

Refusals keep the shapes IAM defines: a read the caller may not perform is 404 (a guardrail it may not see does not announce itself), a write or an evaluation is 403, and a credential scoped to another project is 403 API_KEY_PROJECT_SCOPE. A write on a resource in a project none of the caller's policies name is 404 too — the same answer their read would get, so a refusal never confirms existence across a tenant boundary.

Listing guardrails stays project-scoped: GET /api/v1/guardrails asks whether the caller may list guardrails in a project at all, so a policy that names individual guardrails grants no listing.

Examples​

Create a guardrail​

Class B below 500, C at or above; executes autonomously only while 24h spend stays under 1000.

soat create-guardrail \
--name "Budget Update Guardrail" \
--document '{
"default_class": "C",
"class": { "if": [{ "<": [{ "var": "args.amount" }, 500] }, "B", "C"] },
"guard": { "<": [{ "var": "runtime.projects.cost_usd.24h" }, 1000] }
}'

Dry-run a guardrail before attaching​

Preview the decision the guardrail above would make; nothing executes or is filed:

soat evaluate-guardrail \
--guardrail-id guard_V1StGXR8Z5jdHi6B \
--args '{"amount": 450}'

The response is the would-be evaluation record: class B, passing guard, runtime.projects.cost_usd.24h resolved live:

{
"class": "B",
"decision": "execute",
"guard_result": true,
"context_source": "none",
"context_snapshot": {
"args.amount": 450,
"runtime.projects.cost_usd.24h": 812.4
}
}

Attach a guardrail​

Attach to a tool (below), to an agent (soat update-agent --agent-id agent_01 --guardrail-ids …) or to a project (soat update-project --project-id proj_01 --guardrail-ids …); see Attachment.

soat update-tool \
--tool-id tool_01 \
--guardrail-ids guard_V1StGXR8Z5jdHi6B guard_9f3Kd2Lm0PqRsT4u

Pass guardrail context on a generation​

The application supplies the context.* values; a context_tool_id's output is combined over this object per context_mode.

soat create-agent-generation --wait true \
--agent-id agent_01 \
--messages '[{"role":"user","content":"Raise the campaign budget to 450"}]' \
--guardrail-context '{"max_daily_budget": 500, "cost_ceiling": 1000}'

List archived versions​

soat list-guardrail-versions --guardrail-id guard_V1StGXR8Z5jdHi6B