Docs The loop
Control
Stop a bad action before it runs, with a policy the engine evaluates on every tool call.
Controls. A policy is a list of rules. Before a tool call runs, the engine evaluates the rules against the call and the run so far, and the call is allowed, stopped, or held for a person.
issue_refund({"customer_id":8842,"amount":250}), after lookup_customer. A$100 is the stored threshold, the one that stopped the run.A$250 refund at a A$100 line
Stopped.
A$250 is over A$100 on a disputed charge and no one escalated it to a person first, so the rule stops the call before it runs.
The verdict above comes from the same policy engine your agents ask before each tool call, so what you see here is what your agent would get.
How to: stop a tool call
This stops your agent calling a tool when a rule says no. The example stops refunds over a threshold on a disputed charge.
Write the policy
In the app, open Controls and select New policy, or start from Templates → (Start from a template while there are none). Give it a Policy name, then write its Rules (select Load example to start from a working rule). Select Simulate against history → to see which of your past runs it would have stopped, then Save policy version.
Expected outputThe policy appears under Saved policies. Simulation lists the calls it would have stopped, and calls no model.Assign it to your agent
On the same page, under Assignments, select Add assignment. Set Agent to the name your runs carry (your SDK's
runNameorrun_name), choose the Policy, and set Mode to Block: stop the call, or hold it for approval. Select Save assignment. Choose Record only first to watch what it would stop without stopping anything.Ask Runback before each tool call
Turn on
decide, then check each tool call before running it. If you use the Vercel AI SDK'sdbg.tools(...), this happens for you.agent.mjsJavaScript // agent.mjs: npm install @runback/sdk import { startRun } from "@runback/sdk/core"; // decide: true asks Runback before each tool call, against the policies // assigned to this agent name in Controls › Policies › Assignments. const run = startRun({ runName: "refund-agent", input: "Refund order 8842", decide: true }); const args = { customer_id: 8842, amount: 250 }; const d = await run.decide("issue_refund", args); if (d.allowed) { // ...call your real issue_refund tool here... console.log("allowed: issue_refund ran"); } else { console.log(`${d.decision}: ${d.detail ?? d.rule}`); } await run.finish({ output: d.decision, status: "success" });agent.pyPython # agent.py: pip install runback-sdk from runback import Collector # decide=True asks Runback before each tool call, against the policies # assigned to this agent name in Controls › Policies › Assignments. run = Collector(run_name="refund-agent", input="Refund order 8842", decide=True) args = {"customer_id": 8842, "amount": 250} d = run.decide_tool_call("issue_refund", args) if d.allowed: # ...call your real issue_refund tool here... print("allowed: issue_refund ran") else: print(f"{d.decision}: {d.detail or d.rule}") run.finish(output=d.decision)Expected outputWith the policy assigned in Block mode, the refund printsdeny:and the rule's reason, and your tool is never called. A call the policy allows printsallowed: issue_refund ran.See what was stopped
Open Controls. The stopped call is under What the rules stopped or held, in the latest calls a rule stopped or held, linked to its run, where the step shows as stopped.
Troubleshooting
| You see | What it means | Fix |
|---|---|---|
deny: Runback rejected the decide request (HTTP 401) | The key is wrong, so Runback cannot decide, and the call is stopped, to be safe. | Check RUNBACK_API_**Settings › Keys and access › API key**, or create a new key in Settings › Keys and access › API key. |
| Every call is allowed, even one the rule should stop | No assignment matches this agent, or it is set to Record only. | Make the assignment's Agent exactly your runName, and its Mode Block. |
| You see | What it means | Fix |
|---|---|---|
deny: Runback rejected the decide request (HTTP 401) | The key is wrong, so Runback cannot decide, and the call is stopped, to be safe. | Check RUNBACK_API_**Settings › Keys and access › API key**, or create a new key in Settings › Keys and access › API key. |
| Every call is allowed, even one the rule should stop | No assignment matches this agent, or it is set to Record only. | Make the assignment's Agent exactly your run_name, and its Mode Block. |
decide_tool_call needs Collector(..., decide=True) | decide is not turned on. | Create the Collector with decide=True. |
Rules
A rule is one of two kinds:
| Kind | What it says | Example |
|---|---|---|
assert | A predicate that must hold | no_error |
require | When this holds, that must hold too | The refund rule: when issue_refund.amount is over the threshold and the input mentions a dispute, escalate_to_human must have been called first |
Predicates combine with and, or and not:
| Predicate | Holds when |
|---|---|
tool_called | The model calls this tool |
tool_arg | An argument of that call compares true with a value |
input_matches | The input text matches a pattern |
output_matches | The model's text matches a pattern |
finish_reason | The model's finish reason is this value |
tool_call_count | The number of tool calls compares true with a value |
no_error | The model's output carries no error |
A rule whose references do not resolve against the call is reported as not evaluated, never as passed.
Where the decision happens
| Where | How | The answer |
|---|---|---|
| In your process | The TypeScript SDK's enforce option evaluates the rules before each tool call | Recorded on the event |
| On the server | POST /api/policies/decide, for a pending call. The Python SDK uses it through Collector(decide=...) | allow, deny or require_approval |
A rule with effect: "require_approval" holds the call for a person in Controls › Approvals instead of stopping it. From Starter
Try a policy on past runs
In Controls, “Simulate against history” evaluates a draft policy over your last 100 recorded runs and shows which calls it would have stopped. No model is called. It needs a signed-in session. From Scale
Alerts
Alerts fire on five triggers (run_failure, error_rate, cost_spike, silence, drift) and deliver to six channels: email, Slack, webhook, Microsoft Teams, PagerDuty (Events v2) and Opsgenie. Configure rules in Controls › Alerts. Sample runs (the demo data and the one-click sample run) never fire an alert and never count as an agent reporting. From Starter
| Alert type | When it fires |
|---|---|
run_failure | A run arrives that failed |
error_rate | The error rate over the rule's window passes its threshold |
cost_spike | Spend over the rule's window passes its threshold |
silence | The agent has not reported a run for the rule's window, at a time of week it normally runs. Checked every minute |
drift | A new behaviour-drift alert opens at or above the rule's minimum severity. Once per alert |
Webhooks
Every alert type sends the same body:
{
"type": "silence",
"subject": "refund-agent has been silent for 31m",
"lines": [
"No run from refund-agent since 2026-10-10T02:29:00.000Z (31 minutes; the rule allows 30).",
"It normally runs at this time: it did in 4 of the last 4 weeks."
],
"severity": "high",
"dedupe_key": "critical_gap:refund-agent:stale",
"link": "https://runback.dev/app/coverage",
"sent_at": "2026-10-10T03:00:12.000Z"
}Every delivery is signed. Each rule gets its own signing secret when it is created, shown once; New secret in Controls › Alerts issues another, and the old one stops verifying immediately. A rule created before signing existed sends deliveries without a signature until you issue one, and Controls › Alerts marks it “no signing secret”.
| Header | Value |
|---|---|
Runback-Timestamp | Unix seconds when this attempt was sent |
Runback-Signature | v1= then the hex HMAC-SHA256 of "<timestamp>.<raw body>", keyed with the rule's secret |
Idempotency-Key | The same on every retry of one delivery: drop a repeat |
Verify against the raw request body, before parsing it, and reject a timestamp more than 5 minutes from your clock:
// verify.mjs: Node 18+. Returns true only for a fresh, untampered Runback alert delivery.
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* @param {string} secret the rule's signing secret, shown once when it was issued
* @param {string} rawBody the request body exactly as received, before any JSON parsing
* @param {string} timestamp the Runback-Timestamp header (Unix seconds)
* @param {string} signature the Runback-Signature header ("v1=<hex>")
* @param {number} [nowSeconds]
*/
export function verifyRunbackSignature(secret, rawBody, timestamp, signature, nowSeconds = Math.floor(Date.now() / 1000)) {
const ts = Number(timestamp);
if (!Number.isFinite(ts) || Math.abs(nowSeconds - ts) > 300) return false;
const expected = "v1=" + createHmac("sha256", secret).update(timestamp + "." + rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature || "");
return a.length === b.length && timingSafeEqual(a, b);
}PagerDuty and Opsgenie de-duplicate on dedupe_key. A delivery that fails with a timeout, 408, 429 or 5xx is retried with backoff, up to 5 attempts for silence and drift alerts and 3 for alerts raised as a run lands; any other 4xx is final. Recent deliveries in Controls › Alerts lists each delivery and its outcome.
Next
A stopped call is a finding. Prove the fix turns it into a test.