Integrations

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 modeWhere the offer is
withhold, the defaultIn the assistant text that takes the held call's place: [Sentisec blocked tool_use: … Awaiting human approval; … Recovery offers: [<offers>] …]. Streamed or not.
renameIn 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.approval object: {"status": "pending", "calls": [ … ], "review_ids": [ … ]}. Each entry in calls is an offer plus the held call's call_id. When exactly one call is held, the response also carries the header X-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.

Warning
A held answer can look finished

Under withhold, when nothing else in the answer runs, the turn ends with a standard stop reason, end_turn or stop. Your agent loop ends the run as if the work were done. Look for an offer before you treat a run as finished.

2. Wait for the decision

Poll the offer's status_url on the API host, with the workspace key that sent the call:

GET/v1/workspaces/{workspace_id}/actions/{action_id}/approvalWorkspace key
The held call's approval status
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 accept x-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:

statusMeaningWhat to do
pendingNobody has decided yet.Keep waiting.
armedApproved, and the approval can be used.Re-send now, before approval_expires_at.
consumedThe approval has already released the call once.Do not re-send.
declinedAn admin rejected the call.Stop, and tell the person your agent works for that it was not approved.
revokedAn admin withdrew the approval.As declined.
expiredNobody decided in time, or the approval lapsed unused.The call needs a new approval.
unavailableThe 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.

ResponseMeaning
401 invalid_workspace_keyNo key, an unknown key, or a key sent another way than Authorization: Bearer.
403 workspace_mismatchThe key belongs to another workspace.
404 action_not_foundNo 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.

Warning
Conversations that open with the same message share approvals

Sentisec knows two requests belong to the same conversation by their session id, or by their first user message. If your application opens every conversation with the same fixed message, for example "Handle the next ticket", all of those conversations share it. An approval given in one of them can then release the same call with reworded free text in another, once, while the approval lasts. What runs is still exactly the call the approver saw. To keep each approval with its own conversation, put what the conversation is about, such as the ticket or customer id, in its first message.

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_seconds states. Re-send before approval_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/anthropic model 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 generateText instead of Mastra, the text is result.text, and a renamed call is in result.steps[].toolCalls, with toolName and input.
Protected docs