Operator in the loop
Some threats are obvious (a known drainer, a sanctioned address) and the SDK should block instantly. Some are ambiguous (a counterparty with light bad-faith signals, a SEMANTIC marker with mid-range confidence). For ambiguous cases, you want a human or a higher-level policy agent to make the call.
The onEscalate handler is the hook that turns a SUSPICIOUS verdict into a synchronous "ask the operator" callback.
When it fires
Two conditions must be true:
- A check has matched antibodies, OR the TEE returned a verdict.
- The maximum confidence falls in the escalate band (default:
60 <= confidence < 85).
In that window, the SDK invokes your onEscalate handler instead of auto-deciding. Outside the window, decisions are deterministic: confidence >= 85 blocks, confidence < 60 allows.
Both thresholds are configurable via confidenceThresholds.
The handler signature
onEscalate?: (ctx: {
reason: string; // human-readable reason
confidence: number; // 0..100
matched: { keccakId: string; immId: string }[]; // antibody refs
}) => Promise<boolean>; // true = allow, false = block
Return true to allow the action through. Return false to block. Throwing is treated as block.
Common handler patterns
Slack ping with two buttons
const immunity = new Immunity({
wallet,
axlUrl,
onEscalate: async ({ reason, confidence, matched }) => {
const ts = await postSlackMessage({
channel: "#agent-escalations",
blocks: [
{ type: "section", text: { type: "mrkdwn", text: `*Escalation* (${confidence}% confident)` } },
{ type: "section", text: { type: "mrkdwn", text: reason } },
{ type: "section", text: { type: "mrkdwn", text: `Matched: ${matched.map(m => m.immId).join(", ")}` } },
{
type: "actions",
elements: [
{ type: "button", action_id: "allow", text: { type: "plain_text", text: "Allow" } },
{ type: "button", action_id: "block", text: { type: "plain_text", text: "Block" }, style: "danger" },
],
},
],
});
return waitForButtonClick(ts); // resolves true on Allow, false on Block
},
escalationTimeout: 300, // 5 minutes
onTimeout: "deny", // default-deny if no one clicks
});
Higher-level policy agent
onEscalate: async ({ reason, confidence, matched }) => {
const verdict = await policyAgent.classify({
reason,
confidence,
matched,
contextWindow: getRecentContext(),
});
return verdict === "allow";
},
The policy agent can be another LLM, a rules engine, or an organization-specific SOC tool. The handler is just a function that returns a boolean.
Auto-deny with a notification
onEscalate: async ({ reason, confidence, matched }) => {
await pageOnCall({ severity: "info", reason, matched });
return false; // block, but tell someone
},
Useful for low-risk-tolerance agents that should never proceed on a SUSPICIOUS verdict but you still want telemetry.
Timeout behavior
escalationTimeout (default 300 seconds) caps how long the SDK waits for your handler. On timeout, onTimeout decides:
"deny"(default), block the action. Safe default."allow", allow the action. Only sensible when the agent is high-traffic and dropping ambiguous calls is worse than letting them through.
The handler itself is not killed on timeout; it keeps running in the background. The SDK just stops waiting for it.
Errors
| Error | Code | Meaning |
|---|---|---|
EscalationError |
ERR_ESCALATION_TIMEOUT |
handler did not resolve within escalationTimeout |
EscalationError |
ERR_ESCALATION_DENIED |
handler returned false |
EscalationError |
ERR_ESCALATION_NO_HANDLER |
escalate verdict but no handler configured |
ERR_ESCALATION_NO_HANDLER is a config bug. If you set confidenceThresholds.escalate, you must also set onEscalate.
What you DO have access to
Inside the handler, the second arg of check() (the CheckContext) is not passed through. Reason: the operator should make a decision based on the SDK's distilled signal (reason, confidence, matched), not the raw conversation. If you need richer context, capture it in your handler's closure when you call check().
Pattern:
async function safeSend(tx, ctx) {
// Capture the context up here.
const captured = { tx, ctx, agentId: process.env.AGENT_ID };
// The SDK's onEscalate will close over `captured`.
const result = await immunity.check(tx, ctx);
// ...
}
// Configure the SDK once, share captured via a queue keyed by check id.
See also
- Reference: ImmunityConfig, all config fields.
- Reference: Errors, the full taxonomy.
- Concepts: TEE verification, where SUSPICIOUS verdicts come from.