Calls waiting for approval
What your application receives when a tool call waits for a person's approval, how to wait for the decision, and how to re-send so the approved call runs once.
Under the approval policy, a payment or an outbound message can wait for an admin's approval. While it waits, the call never reaches your application. Your application gets a held answer instead. It waits for the decision, then re-sends the request. The approved call is released once, and your code runs it.
This page is the contract for that loop: how to recognise a held answer, how to wait, how to re-send, how long each step lasts, and why the call never runs twice.
1. Recognise a held answer
A held answer is an ordinary 200 in your provider's format. It carries
one offer for each held call:
{
"type": "approval_requested",
"review_id": "43fcf027-6a9a-4213-9e35-c0ccfb1a3676",
"action_id": "7c385916-e058-4e7b-9d17-5d7ab99592f2",
"status": "pending",
"status_url": "/v1/workspaces/<workspace-id>/actions/7c385916-e058-4e7b-9d17-5d7ab99592f2/approval",
"default_grant_duration_seconds": 3600
}Where the offer sits depends on the workspace's recovery mode:
| Recovery mode | Where the offer is |
|---|---|
withhold, the default | In the assistant text that takes the held call's place: [Sentisec blocked tool_use: … Awaiting human approval; … Recovery offers: [<offers>] …]. Streamed or not. |
rename | In the arguments of the call renamed sentisec_tool_call_blocked: {"error": "tool_call_blocked", "reason": "…", "offers": [<offers>]}. |
Two more places carry the same offers:
- A non-streamed Messages, Chat Completions or Responses answer has a
top-level
sentisec.approvalobject:{"status": "pending", "calls": [ … ], "review_ids": [ … ]}. Each entry incallsis an offer plus the held call'scall_id. When exactly one call is held, the response also carries the headerX-Sentisec-Approval: pending; id=<review_id>. - A stream carries the same object in an SSE comment,
: sentisec-verdict {"approval": … }. Most SDKs drop comments.
The text and the renamed call reach your code through every SDK, so read those first.
2. Wait for the decision
Poll the offer's status_url on the API host, with the workspace key
that sent the call:
curl https://api.sentisec.ch/v1/workspaces/$SENTISEC_WORKSPACE_ID/actions/$ACTION_ID/approval \
-H "Authorization: Bearer $SENTISEC_API_KEY"- Send the key as
Authorization: Bearer. This read does not acceptx-api-key. - Use the key that sent the call. Another key of the same workspace
gets
404 action_not_found. - Poll every 10 seconds or slower, from one place. Each poll counts against your request limit. Repeated requests with a wrong key get the address blocked for a minute, and your agent's own model calls from that address with it.
The reply's status says what to do:
status | Meaning | What to do |
|---|---|---|
pending | Nobody has decided yet. | Keep waiting. |
armed | Approved, and the approval can be used. | Re-send now, before approval_expires_at. |
consumed | The approval has already released the call once. | Do not re-send. |
declined | An admin rejected the call. | Stop, and tell the person your agent works for that it was not approved. |
revoked | An admin withdrew the approval. | As declined. |
expired | Nobody decided in time, or the approval lapsed unused. | The call needs a new approval. |
unavailable | The approval cannot release this call, for example because the workspace's protection changed after it was given. | release_once_refusal says why. Treat it as not approved. |
The reply also carries review_id, approval_expires_at, consumed_at
and consumed_by_action_id. It never carries the call's arguments.
| Response | Meaning |
|---|---|
401 invalid_workspace_key | No key, an unknown key, or a key sent another way than Authorization: Bearer. |
403 workspace_mismatch | The key belongs to another workspace. |
404 action_not_found | No such held call was sent with this key. |
3. Re-send the request
Sentisec never runs your tool, and it never adds a call to an answer. The model has to propose the approved call again. When it does, the call is released once, to your code.
- Same key, same tool declaration. Re-send with the workspace key that sent the call, and declare the tool exactly as before: the same name, description and input schema.
- In the same conversation. Send a follow-up turn in the conversation that was held, for example a new user message on the same thread, or run the original request again from its first message.
- An exact retry is released anywhere. The same tool with the same arguments is released from any conversation.
- A reworded retry is released in the same conversation only. Models rarely write a reason or a message body the same way twice. When only such free text changed, Sentisec sends your application the call the approver saw, word for word, under the retry's own call id.
- Anything else is held again. A changed amount, recipient, account or id, or a change to the workspace's protection since the approval, opens a new review.
How a retry is matched is in when the agent tries again.
4. How long each step lasts
- A review waits up to 14 days. If nobody decides by then, it expires,
and the status reads
expired. - An approval lasts as long as the approver chose. In the dashboard
that is 15 minutes, 1 hour, 4 hours or 24 hours. One hour is the
default, which
default_grant_duration_secondsstates. Re-send beforeapproval_expires_at. An approval nobody uses lapses, and the call then needs a new one.
5. The call runs at most once
- One approval releases one call, once. The release and the use of the approval are recorded together. If two re-sends race, one is released and the other is held again.
- Your code runs the tool. The released call carries the re-send's own tool-call id. If your executor can receive the same call twice, deduplicate by that id.
- At most once, not exactly once. If your application stops after the call was released and before it ran the tool, the approval is used up. The next re-send is held again and needs a new approval.
Example: a Mastra support desk
One ticket is one conversation, on a Mastra thread with memory. The agent runs. When the answer is held, the code waits for the decision. When the call is approved, it re-sends on the same thread.
import type { Agent } from "@mastra/core/agent";
const API = "https://api.sentisec.ch";
const KEY = process.env.SENTISEC_API_KEY!; // the key your agent's model calls use
type Offer = { type: string; review_id: string; action_id: string; status_url: string };
type Run = { text: string; steps: { toolCalls: { payload: { toolName: string; args?: unknown } }[] }[] };
// A held answer carries its offers in the text (withhold) or in a renamed call (rename).
function heldOffers(run: Run): Offer[] {
const found: Offer[] = [];
for (const m of run.text.matchAll(/Recovery offers: (\[\{.*?\}\])/gs)) {
found.push(...(JSON.parse(m[1]) as Offer[]));
}
for (const call of run.steps.flatMap((step) => step.toolCalls)) {
if (call.payload.toolName === "sentisec_tool_call_blocked") {
found.push(...((call.payload.args as { offers?: Offer[] } | undefined)?.offers ?? []));
}
}
const ids = new Set<string>();
return found.filter((o) => o.type === "approval_requested" && !ids.has(o.action_id) && ids.add(o.action_id));
}
async function decision(offer: Offer): Promise<string> {
for (;;) {
const res = await fetch(new URL(offer.status_url, API), {
headers: { Authorization: `Bearer ${KEY}` }, // Bearer only: x-api-key is not read here
});
if (!res.ok) throw new Error(`approval status: HTTP ${res.status}`);
const { status } = (await res.json()) as { status: string };
if (status !== "pending") return status; // armed, consumed, declined, revoked, expired, unavailable
await new Promise((resolve) => setTimeout(resolve, 15_000));
}
}
// One ticket, one Mastra thread (the agent is built with Memory).
export async function handleTicket(agent: Agent, ticketId: string, prompt: string) {
const memory = { thread: `ticket-${ticketId}`, resource: "support-desk" };
let run = await agent.generate(prompt, { memory });
const statuses: string[] = [];
for (const offer of heldOffers(run)) statuses.push(await decision(offer));
if (statuses.includes("armed")) {
run = await agent.generate("A person approved the held action. Continue.", { memory });
}
return { run, statuses };
}- The agent's model is an
@ai-sdk/anthropicmodel whose base URL is your workspace URL ending in/v1; see Vercel AI SDK and Mastra. - Put the ticket id in
prompt, so that each ticket's conversation opens with its own message. - In production, keep the offers with the ticket and poll from a background job, not inside a request. A decision can take hours.
- When a status is not
armed, tell the customer the request did not go ahead, in your application's words. - With the AI SDK's
generateTextinstead of Mastra, the text isresult.text, and a renamed call is inresult.steps[].toolCalls, withtoolNameandinput.
Related
- Approval for payments, messages and contact changes — which calls wait
- Roles and permissions — who approves, and how a retry is matched
- What happens when a tool call is blocked —
withhold,rename, and which to choose - Rate limits — what each poll costs
What each transport covers
The per-transport capability map — what Sentisec inspects and enforces on each API shape, which frameworks have a maintained recipe, and which providers carry traffic.
OpenAI SDK
Route the official OpenAI Python and Node SDKs, and the Python Agents SDK, through Sentisec by changing one argument — and what your code sees when a tool call is blocked.