Skip to content

guides

Intended Documentation

What your agent gets back

ALLOW, DENY and ESCALATE, and how an escalated action reaches its final answer. The SDKs, the MCP server, the gateways and the Claude Code hook wait for the approver for you; the raw API exposes the same wait as GET /v1/decisions/{intentId}/wait.

What your agent gets back#

Intended decides whether an agent may take an action. It never takes the action itself. Your agent asks, gets an answer, and acts only on ALLOW.

There are three first answers:

First answerMeaningWhat your agent does
ALLOWPolicy approved the actionActs, using the signed single-use authority token that comes with the answer
DENYPolicy blocked the actionDoes not act. The answer includes the reason
ESCALATEA person has to decideWaits for the final answer below. Does not act yet

An escalated action ends in one of these final answers:

Final answerMeaningWhat your agent does
ALLOWAn approver approved itActs, using the token minted at approval
DENYAn approver rejected it (the answer carries their reason), or the escalation was canceledDoes not act
EXPIREDNo approver decided before the escalation expiredDoes not act. Treat it exactly like DENY. Submit the action again to ask again
PENDINGStill waiting for an approverKeeps waiting, or stops and checks again later

You do not need to build polling or webhooks. Every Intended client below waits for the approver for you. If another system has to react to an escalation, subscribe it to the decision.escalated, decision.approved, decision.rejected, decision.canceled and decision.expired webhooks.

Expiry#

An escalation waits for an approver for 24 hours by default. An admin or owner can change the window per workspace with PUT /v1/authority-config and escalationTtlSeconds (60 seconds to 30 days). A new window applies to escalations raised after the change. Once an escalation expires, the agent gets EXPIRED and the escalation can no longer be approved or rejected. Intended records the expiry within about a minute of the deadline, whether or not an agent is waiting: the escalation leaves the review queue and the audit trail gets one ESCALATION_EXPIRED event.

TypeScript SDK#

guard(), evaluate() and authorize() wait for the approver by default.

ts
import { createIntendedSdk } from "@intended-inc/sdk";

const intended = createIntendedSdk({ baseUrl: "https://api.intended.so", tenantId, apiKey });

const out = await intended.guard(
  { action: "deploy release 4.2 to production", actor: "deploy-agent" },
  async (decision) => deploy(decision.token), // runs only on a final ALLOW
);

if (out.status === "allowed") console.log(out.result);
if (out.status === "denied") console.log(out.expired ? "expired" : "denied", out.reason);
if (out.status === "pending_approval") console.log("still waiting", out.intentId);
  • Approved: the callback runs with the token minted at approval, exactly as for a direct ALLOW.
  • Rejected or expired: status: "denied" with the reason. expired is true for an expiry. The callback does not run.
  • Still waiting when waitTimeoutMs (default 10 minutes) passes: status: "pending_approval" with the intentId. The callback does not run. Call intended.waitForDecision(intentId) to keep waiting.
  • { onEscalate: "return" } returns pending_approval at once instead of waiting.

waitForDecision(intentId, { timeoutMs }) returns { status, reason, decidedBy, decidedAt, token, tokenExpiresAt, receiptUrl, expiresAt, timedOut }.

Python SDK#

authorize() waits for the approver by default, on both IntendedSdk and AsyncIntendedClient.

python
decision = sdk.authorize(action="deploy release 4.2 to production", actor="deploy-agent")
if decision.allowed:
    deploy(decision.token)
elif decision.expired:
    print("no approver decided in time")
  • on_escalate="wait" (default) or "return", and wait_timeout_seconds (default 600).
  • Approved: allowed is True and token is the token minted at approval. Rejected: denied with the reason. Expired: denied with expired=True. Still waiting at the timeout: still escalated, with the intent id.
  • wait_for_decision(intent_id, timeout_seconds=600) returns a DecisionOutcome with the same fields as the API.
  • The LangChain, OpenAI Agents, PydanticAI and MCP helpers inherit this: an escalated tool call waits, then runs or is blocked.

MCP server#

submit_intent returns ESCALATE with an instruction for the assistant: do not perform the action yet, and call check_decision with the intent id. check_decision takes intentId and waitSeconds (default 25, max 50) and returns the final answer, or PENDING with an instruction to call it again. The assistant performs the action only when check_decision returns ALLOW. It needs the intent:read scope.

MCP gateway#

On ESCALATE the gateway holds the tool call while an approver decides, for up to INTENDED_GATEWAY_ESCALATION_WAIT_SECONDS (default 50; most MCP clients give up on a request after about 60 seconds).

  • Approved in time: the call is forwarded to the upstream server with the token minted at approval.
  • Rejected or expired: the reason is returned as an error and the upstream server is not called.
  • Still waiting: a pending result with the intent id. When the assistant makes the same call with the same arguments after approval, the gateway forwards it without asking again. The token is single-use, so a third identical call is a new request.

LLM gateway#

When a model asks to call a tool and the answer is ESCALATE, the gateway holds the model response for up to INTENDED_LLM_GATEWAY_ESCALATION_WAIT_SECONDS (default 30). Approved: the tool call passes through. Rejected or expired: the tool call is replaced with the reason. Still waiting: the tool call is replaced with a pending note carrying the intent id, and the same tool call made again after approval passes through.

Claude Code hook#

On ESCALATE the hook waits up to INTENDED_HOOK_ESCALATION_WAIT_SECONDS (default 45). Approved: the tool call runs. Rejected or expired: it is denied with the reason. Still waiting: it is denied with a message naming the intent id; after an approver approves it in the console, retry the same tool call and it runs. Claude Code stops a hook after 60 seconds by default, so raise the hook's timeout if you set a longer wait. INTENDED_HOOK_ON_PENDING=ask hands a still-pending call to Claude Code's own permission prompt instead.

The hook needs INTENDED_API_KEY (scopes intent:write and intent:read) and INTENDED_TENANT_ID. If either is missing, it denies every tool call it governs and says which setting is missing. To try the hook before configuring it, set INTENDED_HOOK_UNCONFIGURED=allow: tool calls then run without an authority decision, and Claude Code shows a warning on every call.

Raw API#

POST /v1/agent-intent answers an escalation with HTTP 202, authorityDecision.decision: "ESCALATED", an intentId and an escalationId. Then:

http
GET /v1/decisions/{intentId}/wait?timeout=50
Authorization: Bearer intended_live_…

The request returns at once if the decision is final, otherwise it is held until an approver decides or timeout seconds pass (0 to 50, default 25). Call it again while the answer is PENDING.

json
{
  "intentId": "int_…",
  "status": "ALLOW",
  "reason": "Approved by an approver (role: approver).",
  "decidedBy": { "type": "human", "role": "approver" },
  "decidedAt": "2026-10-31T14:02:11.000Z",
  "escalationId": "…",
  "expiresAt": "2026-11-01T14:00:05.000Z",
  "token": "eyJ…",
  "tokenExpiresAt": "2026-10-31T14:07:11.000Z",
  "receiptUrl": "/v1/intent/int_…/receipt?tenantId=…",
  "retryAfterMs": null,
  "waitedMs": 41200
}
  • status is ALLOW, DENY, EXPIRED or PENDING.
  • decidedBy.type is policy, human, autopilot or system (expiry). For a human it carries the console role, never a name or email.
  • token is present only on ALLOW, and only on the first ALLOW answer for an approved escalation (see below).
  • receiptUrl points at the offline-verifiable receipt once the decision is final.
  • On PENDING, wait retryAfterMs before calling again (0 means call again now).
  • 404 for an unknown intent or another workspace's intent, 403 for a key without intent:read, 429 when you call too often (up to 120 calls a minute per key).

The token is single-use#

The token for an approved escalation is minted once, at approval, and handed to the agent once: the first wait call that answers ALLOW carries it, and Intended erases its stored copy at that moment. Later wait calls for the same intent still answer ALLOW, with token: null and a reason saying the token was already handed over. For an action approved by policy straight away, the token comes back in the POST /intent response and the wait endpoint never returns it. The token is never minted again.

Using the token is single-use too, if the service that acts on it checks. Each token carries a single-use nonce: POST /verify/token with "consume": true accepts it once and refuses every later attempt with TOKEN_ALREADY_CONSUMED, and offline verifiers keep their own nonce cache. Intended never sees the action, so a service that only checks the signature (or calls /verify/token without consume) would accept the same token twice within its 5 minutes. Verify with consume: true, or record the nonce yourself.

Retrying POST /intent with the same Idempotency-Key within 24 hours replays the original response, including its token, which may already have been used. Treat a replayed token like any other: if the verifier says TOKEN_ALREADY_CONSUMED, the action already ran once on that decision. The token is valid for 5 minutes from approval. The clients above pick it up within seconds; if you pick up an approval later than that, or the response carrying the token was lost, submit the action again.

What your agent gets back | Intended