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.
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:
workflow— a workflow run paused on anapproveoraskstep.docs/entities/workflow.mddescribes theapprovestep type: it "pauses in the review queue for a human decision," and its optionalreviewsfield names the earlier step whose output is being judged.mission— a mission run paused by the autonomy gate. Missions carry anautonomyPolicy.levelfrom 1 to 5 (docs/entities/mission.md); levels 1 and 2 gate every external action, so a mission running at those levels parks in this queue before it acts.action— a proposed connector-backed mutation (send an email, update a record) waiting on the trust gate, independent of any workflow or mission.
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
/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.
Related
- The review queue over HTTP — the v2.24.0 release that shipped
this whole
/api/v1surface, including how tenant tokens are minted. - Deploy AI Agents to Production With Human Approval — the end-to-end picture this page is the API slice of.
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.