← All posts

Approve AI Agent Actions With a Review Queue API

Vocion's review queue API: GET /api/v1/reviews lists an org's pending agent actions, POST /api/v1/reviews/decide approves, rejects, or edits one.

Vocion Teamvocion-v2.47.4

An agent review queue is a server-side list of actions an AI agent has proposed but not yet performed, plus an API a human or an internal tool uses to approve, reject, edit, assign, or defer each one. Vocion, an open-source agent workforce platform, exposes it as REST. GET /api/v1/reviews returns one tenant's unified queue — paused workflow runs, missions awaiting review, and pending action proposals — filterable by kind, assignedTo, and includeSnoozed. POST /api/v1/reviews/decide takes { kind, id, action, reason?, editedInput? }, where kind is workflow | mission | action, action is approve or reject, and editedInput is the edit-then-approve payload. Both endpoints accept a tenant API token (Authorization: Bearer vcn_live_…) or a signed-in dashboard session, and deciding requires the approve capability. Clients poll the queue; nothing is pushed to them.

This page is the API reference for that surface: what ends up in the queue, the nine routes under /api/v1/reviews, one runnable example against a real demo, and what the API deliberately does not do.

Why an approval API and not a chat interrupt

The easy version of human-in-the-loop is a readline() call in the middle of an agent's loop: the process blocks, a person answers in the same terminal, execution resumes. That works for a demo and falls apart in production. The process that proposed the action is not the process a reviewer is looking at — it might be a background worker, a scheduled run, or a process that has already exited. A queue that lives in Postgres instead of a stack frame survives a restart, can be read by someone who was never in the original session, and can be built into a dashboard, a Slack bot, or a CI check without touching the agent's code. The tradeoff is latency: nothing fires the instant a person decides. A client has to ask.

What ends up in the queue

GET /api/v1/reviews merges three different kinds of pending work into one feed, distinguished by the kind field:

All three share one status model at the database level: pending. An item leaves the queue the moment it is decided, snoozed past its window, or expires.

Reading the queue: GET /api/v1/reviews

GET /api/v1/reviews?kind=workflow&assignedTo=unassigned&includeSnoozed=false&limit=50&offset=0

Four query parameters, all optional: kind narrows to one plane; assignedTo filters to one person's queue, or the literal string unassigned for a triage view; includeSnoozed (default false) brings back items someone delayed into the future; limit and offset page the result (default 50, capped at 200, per readPagination in _shared.ts). The route's own docblock notes that the list returns "thin rows" — enough to render a queue table, not the full payload — "so the queue stays cheap to poll." Auth is a tenant token or a dashboard session; there is no separate read-only key.

Reading one item in full: GET /api/v1/reviews/:kind/:id

Once a client has an id from the list, GET /api/v1/reviews/:kind/:id returns the whole item: the proposed input, the agent's confidence envelope (confidence, rationale, evidence), and the action's own review card when it defines one. This is what a client renders a single approval screen from. An id belonging to another org's tenant is a 404, not a redacted 200 — the handler never returns a hint that a record exists outside the caller's org.

Deciding: POST /api/v1/reviews/decide

{ "kind": "workflow", "id": 42, "action": "approve", "reason": "tone fixed", "editedInput": { "...": "..." } }

action is approve or reject. editedInput is only applied when action is approve — the service layer discards it on reject (writeApi.ts: editedInput: input.action === 'approve' ? input.editedInput : undefined). What editedInput means depends on kind: for an action item it is corrected fields for the mutation; for a paused workflow it is the input the run resumes with. Deciding requires the approve capability — owners, PMs and client-reviewers hold it, specialists do not — and the response is not just { ok: true }; it carries the caller's refreshed queue ({ ok: true, reviews: [...] }) so a client's inbox count stays correct without a second request.

Deciding is not signaling: POST /api/v1/reviews/signal

POST /api/v1/reviews/signal takes { id, signal, hint? }, where signal is one of approve, edit, reject, skip, save, rewrite — the API twin of the dashboard's Skip and Save-for-later buttons. The route's docblock is explicit about the distinction from decide: "signal shares some words with the action field of POST /api/v1/reviews/decide, but means something different: this endpoint never decides anything. The item stays in the queue and only the signal is recorded, which is what the trust ladder learns from. To actually approve or reject an item, call /api/v1/reviews/decide." Use signal to record what a reviewer did in passing; use decide to actually clear the item.

Routing the queue: assign and snooze

POST /api/v1/reviews/assign takes { kind, id, assignedTo, note? }; assignedTo: null unassigns an item back to the triage pool. POST /api/v1/reviews/snooze takes { kind, id, until }, an ISO timestamp; a snoozed item drops out of the default list view and reappears once until has passed, or immediately if a client asks for includeSnoozed=true. Both return the refreshed queue, same shape as decide, and both require the approve capability.

Getting a draft rewritten before approving: POST /api/v1/reviews/rewrite

POST /api/v1/reviews/rewrite takes { id, hint? } and asks the model to rewrite a pending draft. The route's docblock is clear that the result is "returned, not saved" — a client is expected to show the rewrite as a preview, then send it back through POST /api/v1/reviews/decide as editedInput if the reviewer accepts it. Nothing is mutated by calling rewrite on its own.

Putting work into the queue from outside: POST /api/v1/reviews/propose

POST /api/v1/reviews/propose is the write side: { actionId, input, agentSlug?, rationale?, confidence?, dedupKey?, expiresInDays? }. The item always lands pending — the docblock notes it "rides the normal autonomy gate, so this endpoint can never fire an action outright," which means an external tool cannot use this route to bypass approval by pretending confidence is high. Repeating a call with the same dedupKey refreshes the existing item instead of creating a duplicate, which matters if the same upstream event fires twice.

What was approved without a human: GET /api/v1/reviews/auto-executed

Not every proposal reaches a person — a proposal above its configured confidence threshold in trust.yaml can auto-execute. GET /api/v1/reviews/auto-executed?limit=&offset= lists exactly those, newest first, as an audit trail rather than a queue: there is nothing to decide here, only to review after the fact.

Auth and capability

Every route in this list accepts the same two credentials: a tenant API token (Authorization: Bearer vcn_live_…) or a signed-in dashboard session cookie. _shared.ts states the rule plainly: "an Authorization header, when present, is the credential — a bad token is a 401 and never silently falls back to whatever cookie the request happened to carry." Reads (GET /api/v1/reviews, the item detail route, auto-executed) only need a valid credential for the org. Every mutation — decide, signal, assign, snooze, rewrite, propose — additionally requires the approve capability, enforced in writeApi.ts by enforceQueueCapability() before the call reaches ReviewService: owners, PMs, and client-reviewers hold approve; specialists do not.

Try it: list, inspect, decide

Any workflow that pauses on an approve step puts exactly one item in the queue, which is all you need to exercise these endpoints. Author a two-step workflow whose second step is type: approve, start a run, and let it pause. Then, with a session cookie or an API token for the org:

# list the queue for this org
curl -s -H "Authorization: Bearer $VOCION_TOKEN" \
  "http://localhost:3001/api/v1/reviews?kind=workflow&limit=5"

# approve the paused run, with a correction to the draft it resumes with
curl -s -X POST \
  -H "Authorization: Bearer $VOCION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"kind":"workflow","id":42,"action":"approve","reason":"tone fixed","editedInput":{"draft":"Corrected reply text."}}' \
  http://localhost:3001/api/v1/reviews/decide

Swap 42 for the id the first call returned, and re-run the list call afterward — the item is gone, and the decide response already carried the refreshed queue. 3001 is what the demo's scripts/dev.sh sets PORT to; a self-hosted deployment can run on any port. Tokens are minted by an org admin, not by this API — see the token-minting section of The review queue over HTTP for that flow.

What this API does not do

It does not push. There is no webhook, callback, or SSE stream telling a client an item arrived — every route here is pulled, not pushed, so a client polls GET /api/v1/reviews on whatever interval fits. It does not rate-limit, publish an OpenAPI spec, or ship an SDK client beyond the raw HTTP contract. It does not give dedupKey any guarantee beyond "the same key refreshes the existing item instead of duplicating it" — there is no documented concurrency semantics beyond that. And the audit rows behind auto-executed are ordinary Postgres rows: nothing here is described as immutable, signed, or tamper-proof.

How Vocion implements this

The route handlers live in vocion-core at packages/core/src/app/api/v1/reviews/ — nine files, one per endpoint above, each with a docblock stating its method, path, and body. They are thin wrappers: every handler calls authApi(), then a matching function in packages/core/src/services/writeApi.ts (apiListReviews, apiGetReview, apiDecideReview, and so on), which in turn calls the same ReviewService functions the dashboard's internal oRPC router (packages/core/src/routers/Review.ts) uses — its own header comment says "each route maps 1

to a service function so the UI can poll freely." The /api/v1 layer adds its own explicit approve-capability check on top of that shared service layer, rather than trusting a caller's session alone. The gate that actually produces a workflow-kind queue item is the approve step type (docs/entities/workflow.md in vocion-core); the product-level description of gated writes is on the site at Actions.

Clone vocion-core, author a workflow that pauses on an approve step, and hit GET /api/v1/reviews yourself before you build a reviewer UI on top of it.