The phrase “approval-gated” gets used loosely in agent demos. Usually it means “the chat will ask if you want to continue before publishing.” That is not approval-gating. That is a polite UI prompt.
This post defines what we mean by approval-gated in the execution contract, so the term has a falsifiable definition you can hold any operator to.
The minimum bar
An operator is approval-gated if and only if the executor tool refuses to fire when the action’s status is anything other than approved — and it checks this against persisted state on the server, not the contents of the prompt.
That’s the whole bar. Three pieces:
- The action exists as a record in persisted state. Not a closure in the model’s context. Not a session variable. A row in the action store with an
id, astatus, aworkspaceId, and aconnector. - The status field is the source of truth. It transitions from
awaiting_approval→approved(orrejectedoredited) via a signed operation. The transition is server-authored; the calling agent can request it but cannot perform it. - The executor enforces it. When
chieflab_publish_approved_postis called, it loads the action record, checksstatus === "approved", and rejects withrequires_approvalif not. The check happens after auth and before the connector call. There is no path around it.
If any of those three is missing, the system has an approval theme, not an approval gate.
The state machine
prepare
│
▼
awaiting_approval ───────► rejected
│ ▲
│ │ edit
│ │
approve│ │
│ └────── edited (back to awaiting_approval)
▼
approved ──────► executed
A few non-obvious properties:
editedis not a terminal state. When a human modifies content, the action goes back toawaiting_approval. They have to re-approve their own edit. This prevents the case where someone clicks Approve, then clicks Edit, and the system silently fires the edited version without the second consent.rejectedis terminal. A rejected action can be referenced but cannot transition forward. You make a new action; you don’t resurrect the old one. This is what makes the audit trail meaningful: a rejected post is a permanent record of “we considered this and chose not to.”executedis also terminal. Once the external side effect has fired — LinkedIn post created, email sent, payment charged — the status reflects reality. The action carries the external id (postId,messageId,chargeId) so the audit trail joins back to the live artifact.
Two paths to approval, one state machine
There are two ways a human can flip an action from awaiting_approval to approved:
In-chat path (preferred for IDE-native flows):
The calling agent renders the action’s content inline using renderInChat[channel].body, prompts the user with “approve linkedin / x / email,” and when the user agrees, calls:
chieflab_approve_action({ actionId: "<UUID>" })
The server flips status. The state machine doesn’t care which UI surface sent the request — it cares that the request is authenticated and the workspace matches.
Web path (fallback for phone / multi-person / image gallery review):
The agent surfaces the reviewUrl — an HMAC-signed, 7-day-TTL URL scoped to one runId. The human opens it, clicks Approve, the server flips status. Same state machine.
Both converge. The state machine doesn’t have a “preferred” path; it has one set of rules.
What “approval-gated” rules out
Once you’ve defined approval-gating this strictly, a lot of nearby things stop counting:
- An in-prompt instruction “always confirm with the user before publishing” is not approval-gating. The prompt is replayable. A jailbreak or a prompt-injection in user content can bypass it. The model’s politeness is not a security boundary.
- A two-step LLM dialogue (“ready to publish?” → “yes”) is not approval-gating. There’s no persisted state. The “yes” is just a token in the conversation history.
- A UI confirm dialog inside the agent runtime is not approval-gating either, if there’s no server-side check downstream. The user clicked OK on a button, but the executor doesn’t know that — it just sees an authenticated tool call and fires.
- A tool with a
requireConfirmation: trueflag is approval-gating IF the server enforces it. It is theater IF the flag is just metadata the agent is asked to honor.
Why this matters
Two reasons.
First, safety. When the agent is firing real external actions — posting to your audience, charging your card, sending your customer list — the line between “model behaved well today” and “we can prove no action fires without a human” is the difference between a tool you can trust at scale and a tool that’s one bad afternoon away from a deleted account.
Second, vocabulary integrity. The MCP ecosystem is going to fill up with tools claiming to be approval-gated. If the term doesn’t have a strict definition, every “are you sure?” prompt counts, and the buyers can’t tell the difference. We’d rather lock the definition now, even if it means our own surfaces have to meet a higher bar.
How ChiefLab implements it
For the record, here’s what our six operators enforce, in code:
- Every action ends up in
chiefmo_actions(Supabase) withworkspace_id,tenant_id,connector, andstatuscolumns. chieflab_approve_actionis the only tool that can transitionawaiting_approval → approved. It checks workspace ownership before transitioning.- Every executor (
chieflab_publish_approved_post,chieflab_send_email, etc.) loads the action by id, verifiesstatus === "approved"andworkspace_idmatches the caller’s auth, and rejects with a structuredrequires_approvalrecovery shape otherwise. - The reviewUrl path performs the same transition through a signed token route in our API server.
- Idempotency keys are honored at the executor layer, so retries don’t double-publish.
If you read spec v0.1 §3.3 and §3.4, the rules are there in writing. We hold ourselves to them; we’d ask any other operator that wants to call itself approval-gated to do the same.
The takeaway
“Approval-gated” is not a vibe. It’s: the executor refuses to fire unless the action’s persisted status is approved, transitioned by a signed operation.
That definition is the load-bearing reason a stateful operator is a different category from a stateless LLM call. It’s also why an agentDependency list always includes “approval state” as one of the six things the model cannot do alone.
Build operators that meet the bar. Hold the tools you depend on to it. The vocabulary will travel.