AI Agent Approval Workflow: How to Build One
How to build an AI agent approval workflow you can self-host: an approve step, a skill that requires review, trust thresholds, and a REST decision API.
An AI agent approval workflow is a defined procedure in which an agent produces a proposed
action, the run pauses, a named human accepts or rejects it, and only then does the action
execute. It differs from a chat approval prompt in three ways: the pause is durable, the
decision is recorded, and the rule for when a pause is required lives in configuration rather
than in a prompt. In Vocion, an open-source agent workforce platform, a workflow is a YAML file
of ordered steps; an approve step pauses in the review queue and names the earlier step whose
output is being judged; trust.yaml states which registered actions may execute without a
review and above what confidence.
This is for an engineer deciding whether to buy an approval product or build one. If you search for "AI agent approval workflow" today the results are vendor product pages with screenshots and no code, and none of them are self-hostable. This page shows the YAML, the config file, and the two HTTP endpoints, so you can write the gated workflow yourself and decide a queued item without touching a UI.
What is an AI agent approval workflow?
The definition above is the whole of it: a pause, a human decision, a recorded outcome, and a rule for when the pause happens that lives outside the model's own judgement. What it is not is a chat interface asking "are you sure you want to send this?" — that pattern has no durable state. If the process restarts, the pending action is gone, and there is nothing to audit later beyond a line in a chat transcript.
In Vocion the pause is a database row, not a suspended process. A workflow run that hits an
approve step, or a mutation that needs a gate, lands in a queue that survives restarts,
deploys, and a slow reviewer. docs/entities/workflow.md describes the step types; the review
queue itself is documented at /blog/the-review-queue-over-http.
When do you need one?
You need an approval gate wherever an agent's output becomes irreversible or externally visible:
sending an email, writing to a CRM record another team depends on, releasing or holding physical
work. You do not need one for drafting, summarizing, searching, or anything that only produces
text a person reads before acting on it — authz.ts in vocion-core calls this the difference
between "internal work" and "external side-effects," and internal work is never gated
(packages/core/src/services/authz.ts).
The two things worth gating separately are the workflow step (pause the whole procedure until a person looks) and the action (require review no matter which workflow, mission, or ad hoc call tried to invoke it). The rest of this page covers both.
What does the YAML look like?
Below is the full example from docs/entities/workflow.md, a workflow that reads a call
transcript, drafts a follow-up email, pauses for review, then sends:
slug: discovery-followup
name: Discovery Follow-up
description: Turn a discovery call into an approved follow-up email.
agent: revenue-lead
trigger:
type: schedule
cron: '0 12 * * 1-5'
steps:
- name: refresh-mail
type: sync
sources: [gmail]
- name: transcript
type: ask
prompt: Paste the discovery call transcript.
default: '{{input.transcript}}'
- name: review-draft
type: approve
prompt: Approve the follow-up email before it sends.
reviews: transcript
- name: send
type: action
action: gmail.send
input:
body: '{{steps.review-draft.output.body}}'
Four step types exist: sync (refresh a source before later steps read it, degrading gracefully
on a per-source failure), ask (collect text from a human, or from an automated caller via
default), approve (pause for a decision on an earlier step's output, named by reviews), and
action (run a registered, connector-backed action). There is no skill step — skills are read
by the agent on its own judgement, not invoked as a workflow step (docs/entities/workflow.md).
Registered action ids, including gmail.send, live in packages/core/src/libs/actions/ and are
listed in docs/entities/trust.md.
Approve step vs ask step — which one?
approve is a decision on output that already exists: the model drafted something, and a human
says yes or no, optionally with an edit. ask collects new information — text a person types in,
or that an automated caller supplies. The default field on an ask step is the interesting
part: when it interpolates to a non-empty string, the step completes with that value and never
pauses. That means the exact same workflow file serves two callers — a script that already has
the transcript passes it as input.transcript and the workflow runs straight through, while a
person starting the workflow by hand leaves it blank and gets prompted.
The tradeoff shows up in the last H2 below: a default that resolves to something non-empty by
accident — an upstream field that used to be blank and now carries a stray value — silently skips
the human step that was meant to be there.
How do you gate a single skill instead of a whole workflow?
This is not a skill-frontmatter field. SKILL.md frontmatter, documented in
docs/entities/skill.md, has slug, name, description, playbooks, version, resources,
and license — nothing about approval. The doc says it plainly: "Approval lives with actions
(propose_action and the review queue), never with a skill definition. A skill can never grant
itself sending rights."
requiresApproval does exist, but one level down, in the plugin contract that backs a
plugin-provided operation rather than an authored skill file. The shape is defined in
packages/core/src/libs/plugins/loader.ts, where every operation a plugin registers must satisfy:
const OperationShapeZ = z.object({
slug: z.string().min(1),
name: z.string().min(1),
version: z.string().min(1),
requiresApproval: z.boolean(),
run: z.any(),
inputSchema: z.any(),
outputSchema: z.any(),
});
The registry that holds loaded operations (packages/core/src/libs/plugins/registry.ts) carries
the same field through to its catalog view, so the flag is queryable at runtime, not just a
build-time annotation. That contract is for marketplace plugins, not for skills you author
yourself. If you are writing an authored skill and want a gate, the correct place is still the
action layer — a trust.yaml rule with enabled: false, or an approve step in the workflow
that runs the skill's output through a human before an action fires.
How do you let low-risk actions through?
trust.yaml, one file per workspace, at the workspace root. Each rule names a registered action
id and a confidence threshold:
rules:
- action: hubspot.update
autoApproveAbove: 0.95
enabled: true
- action: gmail.send
autoApproveAbove: 0.99
enabled: false
enabled defaults to false, so nothing auto-executes until someone turns a rule on, and
setting it back to false reverts the rule without deleting it. A proposal for an enabled action
at or above its threshold runs without a person looking — but it still lands in the auto-executed
audit list, so "unattended" never means "unlogged" (docs/entities/trust.md).
Autonomy interacts with this the same way. Vocion's mission autonomy ladder has five declared
levels — 1 draft only, 2 ask before action, 3 act within rules, 4 manage a goal, 5 improve itself
— but the code that actually decides whether a mutation needs a human has exactly one boundary,
between level 2 and level 3. requiresApprovalForMutation in
packages/core/src/services/authz.ts is the whole rule:
export function requiresApprovalForMutation(
level: AutonomyLevel,
opts: { external: boolean; approvalRequired?: boolean },
): boolean {
if (opts.approvalRequired) {
return true;
}
if (!opts.external) {
return false;
}
return level <= 2;
}
Levels 3, 4, and 5 are behaviorally identical for this gate: none of them requires approval for
an external action unless the action itself sets approvalRequired, and none of them gates
internal work at all. docs/entities/mission.md states the same thing in plain language:
"Levels 1–2 gate every external action; 3+ let them run unless the task flags approval." If a
description tells you level 4 is more supervised than level 3, or level 5 adds a second gate,
that description is wrong — the ladder is five labels sitting on one enforcement line.
How does a reviewer decide — dashboard or API?
A human can decide from /dashboard/review, or a client can drive the same queue over HTTP.
There is no push notification, webhook, or streaming channel on the queue today — a client polls
GET /api/v1/reviews on whatever interval it wants and diffs the result:
curl -s -H "Authorization: Bearer $VOCION_TOKEN" \
"https://your-install/api/v1/reviews?kind=action&includeSnoozed=false&limit=25"
GET /api/v1/reviews returns thin rows "so the queue stays cheap to poll" — that phrasing is in
the single-item route's own doc comment
(packages/core/src/app/api/v1/reviews/[kind]/[id]/route.ts). To render one item in full, including the
agent's confidence and rationale, fetch it individually:
curl -s -H "Authorization: Bearer $VOCION_TOKEN" \
"https://your-install/api/v1/reviews/action/482"
And to decide it:
curl -s -X POST -H "Authorization: Bearer $VOCION_TOKEN" \
-H "Content-Type: application/json" \
-d '{"kind":"action","id":482,"action":"approve"}' \
"https://your-install/api/v1/reviews/decide"
POST /api/v1/reviews/decide takes kind (workflow, mission, or action), id, action
(approve or reject), an optional reason, and an optional editedInput for edit-then-approve
— corrected fields for an action, or the input a paused workflow resumes with
(packages/core/src/app/api/v1/reviews/decide/route.ts). Both endpoints accept a tenant API
token (vcn_live_…, minted at /dashboard/api-tokens) or a signed-in dashboard session — the
full endpoint list is in /blog/write-api-review-queue. If you
want the fuller build-to-first-approved-action walkthrough, the parent guide is
/blog/deploy-ai-agents-production-human-approval.
What gets recorded?
Three things. First, the decision itself — who approved or rejected, when, and any reason or
editedInput passed with it, via POST /api/v1/reviews/decide. Second, the auto-executed list —
every proposal that cleared a trust.yaml threshold without a person, fetchable at
GET /api/v1/reviews/auto-executed, described in its own route comment as "the audit surface for
the trust ladder, newest first." Third, the workspace_sha stamped on the tool call behind the
proposal, so a decided item traces back to the exact version of the agent, skill, and prompt
configuration that produced it, not just to "the agent" in the abstract. None of this requires a
separate logging integration — it is what the review and action tables already store.
Try it: the smallest complete gated workflow
You do not need a demo to see the gate. Author a two-skill workflow — one skill drafts an output,
the next step is type: approve — and workspace:apply it into any workspace. Trigger a run,
and it pauses in awaiting_review at the approve step until you decide it from
/dashboard/review or with the curl commands above. That is the whole mechanism described in
this article, independent of any particular demo or core version.
This article is verified against v2.47.4 on vocion-core main; the WorkflowStepSchema
union (approve, ask, action, sync) is the source for the step types above.
Where this design hurts
Three sharp edges worth knowing before you build on this. First, trust.yaml thresholds are
per action id, not per agent — if two agents both call hubspot.update, one rule governs both,
so a cautious agent and an aggressive one share a gate. Second, a stuck reviewer stalls the
workflow: an approve step has no timeout in the schema, so a queue item nobody looks at holds
the run open indefinitely unless something outside the workflow snoozes or decides it. Third, the
ask step's default field is a genuine footgun — because a non-empty interpolation completes
the step without pausing, an upstream value that is unexpectedly non-blank silently removes the
human from a step that was designed to have one. None of these are bugs; they are consequences of
keeping the gate rule in configuration instead of hardcoding a stop everywhere, and the tradeoff
is worth naming rather than discovering in production.
Related
An AI agent registry is the versioned catalog this workflow's agent: field points into — see
the glossary entry for that shape, and the /docs/features/actions page
for how registered action ids like gmail.send and hubspot.update get their input schemas.
For the autonomy ladder's single enforcement boundary in more depth, see
/blog/what-an-ai-agent-autonomy-level-gates, and
for the missions that carry autonomyPolicy.level in the first place, see
/docs/features/missions.
Clone vocion-core, write a workflow.yaml with one approve step, and decide your first item
with POST /api/v1/reviews/decide before you evaluate anything closed-source.