approval_required in the policy and supply an async callback. When the agent tries to call one of them, the callback fires. Return True and the call goes through. Return False and the framework sees a [policy_denied] marker instead of a tool result.
That callback is the whole feature. You write one function of the shape async fn(decision) -> bool and Hexgate calls it every time a tool needs approval. Where the human actually sits (terminal, playground popup, Slack channel, webhook) is up to you.
Quickstart
Declare the tool in your policy:asyncio.to_thread(input, ...). Any blocking call inside the handler will stall the whole agent turn if you skip it, including the streaming tokens for the assistant’s reply.
What the callback receives
TheDecision object holds everything the enforcer knew when it flagged the call.
| Field | Type | Notes |
|---|---|---|
tool_name | str | Name the model asked to call, e.g. refund_order, mcp-slack-send_message. |
arguments | dict[str, Any] | Exact arguments the model produced. Show these to the human verbatim, they’re what will run. |
reason | str | Human-readable string from the policy engine, usually the failing constraint or the reason: field. |
role | str | None | Active role from User(role=...) at the time of the call. |
agent_name | str | Which agent issued the call. Useful when several agents share one approval sink. |
User scope. The callback shouldn’t be reaching out to fetch its own context.
Common shapes
CLI. The quickstart above. Auto-approve during tests. A plainbool is a valid approval_handler:
approval_required call without asking, which defeats the point of having the policy in the first place.
Playground. hexgate serve binds a RelayApprovalHandler for you. Prompts appear inline above the composer in the connected playground tab, and the user’s answer flows back over the same WebSocket. Nothing to configure.
Webhook or Slack. Send an approval.request to your own queue inside the handler, park on an asyncio.Event, and set the event from the webhook handler when the answer arrives. Roughly forty lines of code. RelayApprovalHandler in the SDK source is a working reference.
Watch out for
- Timeouts. If your handler can hang (waiting on a human who went to lunch, a webhook that never fires), wrap the wait in
asyncio.wait_forand returnFalseonTimeoutError. Hexgate will not do this for you. The tool call sits open until the handler returns. - Blocking calls. Anything sync (
input,requests.post,subprocess.run) needsasyncio.to_threador the agent turn stops streaming. approval_handler=Truein production. Add a grep to CI. It is the fastest way to accidentally ship a policy that reads “approval required” but behaves like “allow”.- Parallel tool calls. Modern models fan out tool calls via
asyncio.gather, so your handler will be invoked concurrently with differentDecisionobjects. If you keep any state, key it by the decision content, not by array index.
Where to next
- Framework comparison walks through how other agent frameworks handle approval, and where Hexgate’s callback fits in that landscape.
- Policy decision covers what
Decisionis and how the enforcer produces one.
Adapter coverage
The callback is wired through the LangChain, OpenAI Agents, Pydantic AI, and Google ADK adapters. Pass it the same way on each:HexgateRunner classes, the same argument is available on the constructor and threads through to every run.
Known limitations
- No cross-process durability yet. State lives in the calling process’s memory. A restart drops any pending prompt. A durable, Postgres-backed handler is on the roadmap for when overnight or off-host approvers become a real need.
- No per-tool RBAC on approvers. A policy today says “somebody must approve”, not “somebody with role
finance-approvermust approve”. Follow-up.