← All posts

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.

Vocion Teamvocion-v2.47.4

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.

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.