Gate a Dangerous Tool with Guardrails
A guardrail classifies every gated tool call into an action class — A (always execute), B (execute if a guard passes), C (human sign-off), D (forbidden) — with deterministic JSON Logic, no LLM in the evaluation path. See Guardrails for the full model.
You will build one guardrail over a budget-update tool, dry-run it, drive it from an orchestration tool node to see all three outcomes (autonomous execute, park for sign-off, tripwire), read the governance trail (approvals, exceptions, audit log), and finally tighten the whole project with a second guardrail.
Everything here is deterministic — no AI provider is required.
Prerequisites
- SOAT running locally. Follow the Quick Start guide to bring the stack up with Docker Compose.
- New to SOAT? Read Key Concepts to understand projects, tools, and runs first.
- CLI installed and configured, or SDK set up. See CLI or SDK.
- For production hardening (secrets, env vars), see Configuration.
- Server is at
http://localhost:5047.
- CLI
- SDK
- curl
export SOAT_BASE_URL=http://localhost:5047
import { SoatClient } from '@soat/sdk';
export SOAT_BASE_URL=http://localhost:5047
Step 1 — Log in as admin
Admin is the built-in superuser role. See Users for authentication details.
- CLI
- SDK
- curl
ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN
const soat = new SoatClient({ baseUrl: 'http://localhost:5047' });
const { data: login } = await soat.users.loginUser({
body: { username: 'admin', password: 'Admin1234!' },
});
const adminSoat = new SoatClient({
baseUrl: 'http://localhost:5047',
token: login.token,
});
ADMIN_TOKEN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/users/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"Admin1234!"}' | jq -r '.token')
Step 2 — Create a project
Every resource lives inside a project. The project is also the broadest guardrail attach scope — you will use it in Step 12.
- CLI
- SDK
- curl
PROJECT_ID=$(soat create-project --name "Guardrails Demo" | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"
const { data: project } = await adminSoat.projects.createProject({
body: { name: 'Guardrails Demo' },
});
const PROJECT_ID = project.id;
PROJECT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/projects" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Guardrails Demo"}' | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"
Step 3 — Create the tool the guardrail gates
The gate sits at the tool-execution boundary, so the guardrail needs a Tool to govern. Create a read-only builtin tool named update-budget so the tutorial needs no external services — in a real system this would be the tool that actually moves money.
- CLI
- SDK
- curl
BUDGET_TOOL_ID=$(soat create-tool \
--project-id "$PROJECT_ID" \
--name "update-budget" \
--type "builtin" \
--description "Stand-in for the sensitive action the guardrail gates" \
--actions '["get-project"]' \
--preset-parameters '{"project_id": "'"$PROJECT_ID"'"}' | jq -r '.id')
echo "BUDGET_TOOL_ID: $BUDGET_TOOL_ID"
const { data: budgetTool } = await adminSoat.tools.createTool({
body: {
project_id: PROJECT_ID,
name: 'update-budget',
type: 'soat',
description: 'Stand-in for the sensitive action the guardrail gates',
actions: ['get-project'],
preset_parameters: { projectId: PROJECT_ID },
},
});
const BUDGET_TOOL_ID = budgetTool.id;
BUDGET_TOOL_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/tools" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"update-budget\",\"type\":\"soat\",\"description\":\"Stand-in for the sensitive action the guardrail gates\",\"actions\":[\"get-project\"],\"preset_parameters\":{\"projectId\":\"$PROJECT_ID\"}}" \
| jq -r '.id')
echo "BUDGET_TOOL_ID: $BUDGET_TOOL_ID"
Step 4 — Write the guardrail
A guardrail document has three parts — class, guard, and the fail-closed default_class. Here: class B below 500, C at or above, with a guard requiring the amount under 200. The three amounts 150, 900, and 450 exercise every outcome.
- CLI
- SDK
- curl
GUARDRAIL_ID=$(soat create-guardrail \
--project-id "$PROJECT_ID" \
--name "Budget Update Guardrail" \
--description "Autonomous under 200, gated under 500, sign-off above" \
--document '{
"default_class": "C",
"class": { "if": [{ "<": [{ "var": "args.amount" }, 500] }, "B", "C"] },
"guard": { "<": [{ "var": "args.amount" }, 200] }
}' | jq -r '.id')
echo "GUARDRAIL_ID: $GUARDRAIL_ID"
const { data: guardrail } = await adminSoat.guardrails.createGuardrail({
body: {
project_id: PROJECT_ID,
name: 'Budget Update Guardrail',
description: 'Autonomous under 200, gated under 500, sign-off above',
document: {
default_class: 'C',
class: { if: [{ '<': [{ var: 'args.amount' }, 500] }, 'B', 'C'] },
guard: { '<': [{ var: 'args.amount' }, 200] },
},
},
});
const GUARDRAIL_ID = guardrail.id;
GUARDRAIL_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/guardrails" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"Budget Update Guardrail\",\"description\":\"Autonomous under 200, gated under 500, sign-off above\",\"document\":{\"default_class\":\"C\",\"class\":{\"if\":[{\"<\":[{\"var\":\"args.amount\"},500]},\"B\",\"C\"]},\"guard\":{\"<\":[{\"var\":\"args.amount\"},200]}}}" \
| jq -r '.id')
echo "GUARDRAIL_ID: $GUARDRAIL_ID"
The document is validated on write: every var must resolve to the args.*, context.*, or runtime.* namespaces, and an out-of-catalog runtime.* key is rejected with 400 rather than silently reading null at runtime.
Step 5 — Dry-run every decision before attaching
Dry-run evaluation runs the real evaluation pipeline against arguments you supply and returns the exact record a real call would produce — nothing executes, no approval is filed.
- CLI
- SDK
- curl
soat evaluate-guardrail --guardrail-id "$GUARDRAIL_ID" --tool-id "$BUDGET_TOOL_ID" \
--args '{"amount": 150}' | jq '{class, decision, guard_result, context_snapshot}'
Expected output — under both thresholds, so it runs on its own:
{
"class": "B",
"decision": "execute",
"guard_result": true,
"context_snapshot": {
"args.amount": 150
}
}
Now the other two amounts:
soat evaluate-guardrail --guardrail-id "$GUARDRAIL_ID" --tool-id "$BUDGET_TOOL_ID" \
--args '{"amount": 900}' | jq '{class, decision, guard_result}'
soat evaluate-guardrail --guardrail-id "$GUARDRAIL_ID" --tool-id "$BUDGET_TOOL_ID" \
--args '{"amount": 450}' | jq '{class, decision, guard_result}'
900 classifies C (decision: "route_to_approval", guard_result: null — the guard is not consulted for a class-C call). 450 classifies B but fails the < 200 guard, so decision: "tripwire".
for (const amount of [150, 900, 450]) {
const { data: evaluation } = await adminSoat.guardrails.evaluateGuardrail({
path: { guardrail_id: GUARDRAIL_ID },
body: { args: { amount }, tool_id: BUDGET_TOOL_ID },
});
console.log(amount, evaluation.class, evaluation.decision, evaluation.guard_result);
}
// 150 B execute true
// 900 C route_to_approval null
// 450 B tripwire false
for amount in 150 900 450; do
curl -s -X POST "$SOAT_BASE_URL/api/v1/guardrails/$GUARDRAIL_ID/evaluate" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"args\":{\"amount\":$amount},\"tool_id\":\"$BUDGET_TOOL_ID\"}" \
| jq '{class, decision, guard_result}'
done
JSON Logic coerces an absent var to a zero-ish value, so { "<": [{ "var": "args.amount" }, 500] } is true when amount is missing entirely — a call with no amount takes the permissive branch. When a missing argument must not reach it, test presence explicitly: { "and": [{ "var": "args.amount" }, { "<": [{ "var": "args.amount" }, 500] }] }. See Missing keys and comparisons.
Step 6 — Attach the guardrail to the tool
A guardrail governs nothing until it is attached. Attaching at the tool scope means this tool carries its own gate wherever it is used — binding it to a new agent can never silently escape classification. guardrail_ids is a list, so several guardrails can compose on one tool.
- CLI
- SDK
- curl
soat update-tool --tool-id "$BUDGET_TOOL_ID" \
--guardrail-ids "$GUARDRAIL_ID" | jq '{id, name, guardrail_ids}'
await adminSoat.tools.updateTool({
path: { tool_id: BUDGET_TOOL_ID },
body: { guardrail_ids: [GUARDRAIL_ID] },
});
curl -s -X PATCH "$SOAT_BASE_URL/api/v1/tools/$BUDGET_TOOL_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"guardrail_ids\":[\"$GUARDRAIL_ID\"]}" | jq '{id, name, guardrail_ids}'
Attach is cheap, detach is gated: adding an id needs only tools:UpdateTool, because it can only tighten the outcome. Removing one additionally requires guardrails:DetachGuardrail — see Step 13.
Step 7 — Drive the tool from an orchestration
An orchestration tool node is gated at dispatch exactly like an agent tool call, minus the model. With no agent in scope it composes the project + tool scopes only.
The apply node feeds the run's amount into the tool call as the guardrail's args.amount. The unlabeled edge is the success path; the blocked / tripwire edges catch a guardrail refusal, which is a routable outcome, not a run failure.
- CLI
- SDK
- curl
ORCH_NODES='[
{"id":"apply","type":"tool","tool_id":"'"$BUDGET_TOOL_ID"'","operation_id":"get-project","input_mapping":{"amount":{"var":"input.amount"}}},
{"id":"done","type":"transform","expression":"Budget updated.","state_mapping":{"state.outcome":{"var":"output.result"}}},
{"id":"halted","type":"transform","expression":"Stopped by a guardrail.","state_mapping":{"state.outcome":{"var":"output.result"}}}
]'
ORCH_EDGES='[
{"from":"apply","to":"done"},
{"from":"apply","to":"halted","condition":"blocked"},
{"from":"apply","to":"halted","condition":"tripwire"}
]'
ORCHESTRATION_ID=$(soat create-orchestration \
--project-id "$PROJECT_ID" \
--name "Budget Update Pipeline" \
--description "Applies a budget change through the gated tool" \
--nodes "$ORCH_NODES" \
--edges "$ORCH_EDGES" | jq -r '.id')
echo "ORCHESTRATION_ID: $ORCHESTRATION_ID"
const { data: orchestration } =
await adminSoat.orchestrations.createOrchestration({
body: {
project_id: PROJECT_ID,
name: 'Budget Update Pipeline',
description: 'Applies a budget change through the gated tool',
nodes: [
{
id: 'apply',
type: 'tool',
tool_id: BUDGET_TOOL_ID,
operation_id: 'get-project',
input_mapping: { amount: { var: 'input.amount' } },
},
{
id: 'done',
type: 'transform',
expression: 'Budget updated.',
state_mapping: { 'state.outcome': { var: 'output.result' } },
},
{
id: 'halted',
type: 'transform',
expression: 'Stopped by a guardrail.',
state_mapping: { 'state.outcome': { var: 'output.result' } },
},
],
edges: [
{ from: 'apply', to: 'done' },
{ from: 'apply', to: 'halted', condition: 'blocked' },
{ from: 'apply', to: 'halted', condition: 'tripwire' },
],
},
});
const ORCHESTRATION_ID = orchestration.id;
ORCHESTRATION_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/orchestrations" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"Budget Update Pipeline\",\"description\":\"Applies a budget change through the gated tool\",\"nodes\":[{\"id\":\"apply\",\"type\":\"tool\",\"tool_id\":\"$BUDGET_TOOL_ID\",\"operation_id\":\"get-project\",\"input_mapping\":{\"amount\":{\"var\":\"input.amount\"}}},{\"id\":\"done\",\"type\":\"transform\",\"expression\":\"Budget updated.\",\"state_mapping\":{\"state.outcome\":{\"var\":\"output.result\"}}},{\"id\":\"halted\",\"type\":\"transform\",\"expression\":\"Stopped by a guardrail.\",\"state_mapping\":{\"state.outcome\":{\"var\":\"output.result\"}}}],\"edges\":[{\"from\":\"apply\",\"to\":\"done\"},{\"from\":\"apply\",\"to\":\"halted\",\"condition\":\"blocked\"},{\"from\":\"apply\",\"to\":\"halted\",\"condition\":\"tripwire\"}]}" \
| jq -r '.id')
echo "ORCHESTRATION_ID: $ORCHESTRATION_ID"
Step 8 — Class B with a passing guard: the call just runs
Start a run with amount: 150. The guardrail classifies B, the guard passes, and the tool dispatches with no human involved.
- CLI
- SDK
- curl
RUN1=$(soat start-orchestration-run --orchestration-id "$ORCHESTRATION_ID" \
--input '{"amount":150}' --wait true)
RUN1_ID=$(printf '%s\n' "$RUN1" | jq -r '.id')
printf '%s\n' "$RUN1" | jq '{status, required_action}'
soat get-orchestration-run --orchestration-run-id "$RUN1_ID" | jq '{outcome: .state.outcome}'
Expected output:
{ "status": "succeeded", "required_action": null }
{ "outcome": "Budget updated." }
const { data: run1 } = await adminSoat.orchestrations.startOrchestrationRun({
body: {
orchestration_id: ORCHESTRATION_ID,
input: { amount: 150 },
wait: true,
},
});
console.log(run1.status); // succeeded
console.log(run1.state.outcome); // Budget updated.
RUN1=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/orchestration-runs" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"orchestration_id\":\"$ORCHESTRATION_ID\",\"input\":{\"amount\":150},\"wait\":true}")
RUN1_ID=$(printf '%s\n' "$RUN1" | jq -r '.id')
printf '%s\n' "$RUN1" | jq '{status, required_action}'
Step 9 — Class C: the run parks for sign-off
Now amount: 900. The guardrail classifies C, so the tool is not dispatched: the run parks as awaiting_input and files an approval item carrying the frozen arguments.
- CLI
- SDK
- curl
RUN2=$(soat start-orchestration-run --orchestration-id "$ORCHESTRATION_ID" \
--input '{"amount":900}' --wait true)
RUN2_ID=$(printf '%s\n' "$RUN2" | jq -r '.id')
APPROVAL_ID=$(printf '%s\n' "$RUN2" | jq -r '.required_action.approval_id')
printf '%s\n' "$RUN2" | jq '{status, required_action}'
Expected output:
{
"status": "awaiting_input",
"required_action": {
"type": "approval",
"node_id": "apply",
"prompt": "Approval required for tool call.",
"context": { "amount": 900 },
"approval_id": "apr_...",
"expires_at": "..."
}
}
The item records exactly which guardrail — and which version of it — sent the call here:
soat get-approval --approval-id "$APPROVAL_ID" \
| jq '{status, origin, orchestration_run_id, node_id, proposed_action, policy_version}'
{
"status": "pending",
"origin": "node",
"orchestration_run_id": "orch_run_...",
"node_id": "apply",
"proposed_action": { "tool_id": "tool_...", "arguments": { "amount": 900 } },
"policy_version": "guard_...@1"
}
Approve it, and the node re-dispatches the tool with the frozen arguments:
soat approve-approval --approval-id "$APPROVAL_ID" | jq '{status, resolved_by}'
soat get-orchestration-run --orchestration-run-id "$RUN2_ID" | jq '{status, outcome: .state.outcome}'
{ "status": "succeeded", "outcome": "Budget updated." }
const { data: run2 } = await adminSoat.orchestrations.startOrchestrationRun({
body: {
orchestration_id: ORCHESTRATION_ID,
input: { amount: 900 },
wait: true,
},
});
console.log(run2.status); // awaiting_input
const APPROVAL_ID = run2.required_action.approval_id;
const { data: item } = await adminSoat.approvals.getApproval({
path: { approval_id: APPROVAL_ID },
});
console.log(item.proposed_action, item.policy_version);
await adminSoat.approvals.approveApproval({
path: { approval_id: APPROVAL_ID },
body: {},
});
const { data: resumed } = await adminSoat.orchestrations.getOrchestrationRun({
path: { orchestration_run_id: run2.id },
});
console.log(resumed.status, resumed.state.outcome); // succeeded Budget updated.
RUN2=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/orchestration-runs" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"orchestration_id\":\"$ORCHESTRATION_ID\",\"input\":{\"amount\":900},\"wait\":true}")
RUN2_ID=$(printf '%s\n' "$RUN2" | jq -r '.id')
APPROVAL_ID=$(printf '%s\n' "$RUN2" | jq -r '.required_action.approval_id')
curl -s "$SOAT_BASE_URL/api/v1/approvals/$APPROVAL_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '{proposed_action, policy_version}'
curl -s -X POST "$SOAT_BASE_URL/api/v1/approvals/$APPROVAL_ID/approve" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" -d '{}' | jq '{status}'
curl -s "$SOAT_BASE_URL/api/v1/orchestration-runs/$RUN2_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '{status, outcome: .state.outcome}'
On approval the tool is re-dispatched with the frozen (or edited) arguments and the guardrail is not re-evaluated — the human decision is final for that call. Rejection or expiry means the tool never runs at all, and only a matching rejected / expired edge follows.
Step 10 — A failing guard: the tripwire
amount: 450 classifies B — but fails the < 200 guard. By default a failing class-B guard is a tripwire: it aborts the action outright.
- CLI
- SDK
- curl
RUN3=$(soat start-orchestration-run --orchestration-id "$ORCHESTRATION_ID" \
--input '{"amount":450}' --wait true)
RUN3_ID=$(printf '%s\n' "$RUN3" | jq -r '.id')
soat get-orchestration-run --orchestration-run-id "$RUN3_ID" \
| jq '{status, outcome: .state.outcome, refusal: .artifacts.apply}'
Expected output — the run succeeds down the tripwire edge; the refusal is data, not a crash:
{
"status": "succeeded",
"outcome": "Stopped by a guardrail.",
"refusal": {
"status": "tripwire",
"reason": "A guardrail tripwire fired: a class-B guard failed and the action was aborted."
}
}
A tripwire also files an exception so the abort lands in a triage queue instead of a log line:
soat list-exceptions --project-id "$PROJECT_ID" --kind guardrail_tripwire \
| jq '.data[0] | {kind, severity, status, title, occurrence_count}'
{
"kind": "guardrail_tripwire",
"severity": "warning",
"status": "open",
"title": "Guardrail tripwire aborted update-budget",
"occurrence_count": 1
}
const { data: run3 } = await adminSoat.orchestrations.startOrchestrationRun({
body: {
orchestration_id: ORCHESTRATION_ID,
input: { amount: 450 },
wait: true,
},
});
const { data: settled } = await adminSoat.orchestrations.getOrchestrationRun({
path: { orchestration_run_id: run3.id },
});
console.log(settled.state.outcome); // Stopped by a guardrail.
console.log(settled.artifacts.apply); // { status: 'tripwire', reason: '...' }
const { data: exceptions } = await adminSoat.exceptions.listExceptions({
params: { query: { project_id: PROJECT_ID, kind: 'guardrail_tripwire' } },
});
console.log(exceptions.data[0].title);
RUN3=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/orchestration-runs" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"orchestration_id\":\"$ORCHESTRATION_ID\",\"input\":{\"amount\":450},\"wait\":true}")
RUN3_ID=$(printf '%s\n' "$RUN3" | jq -r '.id')
curl -s "$SOAT_BASE_URL/api/v1/orchestration-runs/$RUN3_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '{status, outcome: .state.outcome, refusal: .artifacts.apply}'
curl -s "$SOAT_BASE_URL/api/v1/exceptions?project_id=$PROJECT_ID&kind=guardrail_tripwire" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '.data[0] | {kind, severity, title}'
Add "escalate": true to the document to soften this: a failing guard then routes to the approvals queue for a human decision instead of aborting. escalate is per-guardrail, and a tripwire from another applying guardrail still wins.
Step 11 — Read the governance trail
Every evaluation writes a guardrail_evaluation record. Those that changed the call's outcome — route_to_approval, blocked, tripwire, but not a plain execute — are also mirrored into the audit log as platform-originated entries.
- CLI
- SDK
- curl
soat list-audit-entries --project-id "$PROJECT_ID" --action "guardrails:Evaluate" \
| jq '[.data[] | {resource_srn, class: .detail.class, decision: .detail.decision, approval_id: .detail.approval_id}]'
Expected output — the class-C route and the tripwire are recorded; the autonomous class-B execute from Step 8 is not (it is high-volume operational telemetry, kept only in the guardrail's own evaluation records):
[
{
"resource_srn": "srn:proj_...:guardrail:guard_...",
"class": "B",
"decision": "tripwire",
"approval_id": null
},
{
"resource_srn": "srn:proj_...:guardrail:guard_...",
"class": "C",
"decision": "route_to_approval",
"approval_id": "apr_..."
}
]
Each entry's detail also carries the context_snapshot — a flat map of only the vars the evaluation actually referenced, frozen at their evaluation-time values. It is the only way to answer "why did this pass?" after the application's context has moved on.
const { data: audit } = await adminSoat.auditLog.listAuditEntries({
params: { query: { project_id: PROJECT_ID, action: 'guardrails:Evaluate' } },
});
for (const entry of audit.data) {
console.log(entry.detail.class, entry.detail.decision, entry.detail.context_snapshot);
}
curl -s "$SOAT_BASE_URL/api/v1/audit-log?project_id=$PROJECT_ID&action=guardrails:Evaluate" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '[.data[] | {class: .detail.class, decision: .detail.decision}]'
Step 12 — Raise the floor for the whole project
There is no override resource. To run a stricter posture, attach a tighter guardrail at the project scope — an always-C document forces sign-off on every tool call in the project.
Because composition is stricter-wins, this can only tighten: the amount: 150 call that executed autonomously in Step 8 now parks.
- CLI
- SDK
- curl
BASELINE_ID=$(soat create-guardrail \
--project-id "$PROJECT_ID" \
--name "Sign-off Baseline" \
--document '{"class": "C"}' | jq -r '.id')
soat update-project --project-id "$PROJECT_ID" \
--guardrail-ids "$BASELINE_ID" | jq '{guardrail_ids}'
RUN4=$(soat start-orchestration-run --orchestration-id "$ORCHESTRATION_ID" \
--input '{"amount":150}' --wait true)
printf '%s\n' "$RUN4" | jq '{status, node_id: .required_action.node_id}'
soat get-approval --approval-id "$(printf '%s\n' "$RUN4" | jq -r '.required_action.approval_id')" \
| jq '{policy_version}'
Expected output — the same input, now gated, and policy_version names the baseline as the governing guardrail:
{ "status": "awaiting_input", "node_id": "apply" }
{ "policy_version": "guard_...@1" }
const { data: baseline } = await adminSoat.guardrails.createGuardrail({
body: {
project_id: PROJECT_ID,
name: 'Sign-off Baseline',
document: { class: 'C' },
},
});
await adminSoat.projects.updateProject({
path: { project_id: PROJECT_ID },
body: { guardrail_ids: [baseline.id] },
});
const { data: run4 } = await adminSoat.orchestrations.startOrchestrationRun({
body: {
orchestration_id: ORCHESTRATION_ID,
input: { amount: 150 },
wait: true,
},
});
console.log(run4.status); // awaiting_input — the tool-scoped guardrail said execute
BASELINE_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/guardrails" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"Sign-off Baseline\",\"document\":{\"class\":\"C\"}}" \
| jq -r '.id')
curl -s -X PATCH "$SOAT_BASE_URL/api/v1/projects/$PROJECT_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"guardrail_ids\":[\"$BASELINE_ID\"]}" | jq '{guardrail_ids}'
curl -s -X POST "$SOAT_BASE_URL/api/v1/orchestration-runs" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"orchestration_id\":\"$ORCHESTRATION_ID\",\"input\":{\"amount\":150},\"wait\":true}" \
| jq '{status}'
Other projects — which don't carry the attachment — are untouched. A tenant can raise the floor, never lower it.
Step 13 — Edits are versioned, detach is gated
Every write that changes a document increments version and archives the new one, so an approval item's policy_version always resolves to the exact text that governed it. A version's config holds the archived policy as { document }. Metadata-only edits — and re-writing the document the guardrail already holds — archive nothing. See Versioning.
- CLI
- SDK
- curl
soat update-guardrail --guardrail-id "$GUARDRAIL_ID" --document '{
"default_class": "C",
"class": { "if": [{ "<": [{ "var": "args.amount" }, 500] }, "B", "C"] },
"guard": { "<": [{ "var": "args.amount" }, 100] }
}' | jq '{version}'
soat get-guardrail-version --guardrail-id "$GUARDRAIL_ID" --version 1 \
| jq '{version, guard: .config.document.guard}'
Expected output — the live guardrail is now version: 2, and version 1's original < 200 guard is still retrievable:
{ "version": 2 }
{ "version": 1, "guard": { "<": [{ "var": "args.amount" }, 200] } }
Attachments reference the guardrail's id, not a version, so this edit takes effect immediately everywhere it is attached — dry-run an edit before writing it when the guardrail is attached at scale.
Deletion refuses to do what detach permissions forbid. While the guardrail is still attached, delete-guardrail returns 409 listing every reference:
# → expect-fail
soat delete-guardrail --guardrail-id "$GUARDRAIL_ID"
{
"status": 409,
"error": {
"code": "GUARDRAIL_HAS_REFERENCES",
"message": "Guardrail 'guard_...' is still attached and cannot be deleted. Detach it from every tool, agent, and project first.",
"meta": { "references": { "tools": ["tool_..."], "agents": [], "projects": [] } }
}
}
Detach first — which requires guardrails:DetachGuardrail on top of tools:UpdateTool — and the delete succeeds:
soat update-tool --tool-id "$BUDGET_TOOL_ID" --guardrail-ids '[]' | jq '{guardrail_ids}'
soat delete-guardrail --guardrail-id "$GUARDRAIL_ID"
const { data: updated } = await adminSoat.guardrails.updateGuardrail({
path: { guardrail_id: GUARDRAIL_ID },
body: {
document: {
default_class: 'C',
class: { if: [{ '<': [{ var: 'args.amount' }, 500] }, 'B', 'C'] },
guard: { '<': [{ var: 'args.amount' }, 100] },
},
},
});
console.log(updated.version); // 2
const { data: v1 } = await adminSoat.guardrails.getGuardrailVersion({
path: { guardrail_id: GUARDRAIL_ID, version: 1 },
});
console.log(v1.config.document.guard); // { '<': [{ var: 'args.amount' }, 200] }
// Deleting while attached fails with 409 GUARDRAIL_HAS_REFERENCES.
await adminSoat.tools.updateTool({
path: { tool_id: BUDGET_TOOL_ID },
body: { guardrail_ids: [] },
});
await adminSoat.guardrails.deleteGuardrail({
path: { guardrail_id: GUARDRAIL_ID },
});
curl -s -X PATCH "$SOAT_BASE_URL/api/v1/guardrails/$GUARDRAIL_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"document":{"default_class":"C","class":{"if":[{"<":[{"var":"args.amount"},500]},"B","C"]},"guard":{"<":[{"var":"args.amount"},100]}}}' \
| jq '{version}'
curl -s "$SOAT_BASE_URL/api/v1/guardrails/$GUARDRAIL_ID/versions/1" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '{version, guard: .config.document.guard}'
# 409 while still attached
curl -s -X DELETE "$SOAT_BASE_URL/api/v1/guardrails/$GUARDRAIL_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '.error.code'
curl -s -X PATCH "$SOAT_BASE_URL/api/v1/tools/$BUDGET_TOOL_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" -d '{"guardrail_ids":[]}' | jq '{guardrail_ids}'
curl -s -X DELETE "$SOAT_BASE_URL/api/v1/guardrails/$GUARDRAIL_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" -o /dev/null -w '%{http_code}\n'
The classification model, fail-closed evaluation rules, and stricter-wins composition are documented in Guardrails.
Next Steps
- Feed live values into guards with
guardrail_contextand acontext_tool_id, and cap a runaway run withruntime.usage.orchestration_run_tokens— see Per-run spend ceilings. - Model an explicit human decision point in the graph instead of a guardrail-driven one with the
approvalnode. - Cap aggregate spend rather than individual calls with Cap Spend Per End User.
- Triage what a tripwire files — see Exceptions.