Concepts
Approvals
Every founder building this asks the same question in the first week: how do I let it run without me? The honest answer is that you mostly should not, and that the gate is not the thing slowing you down — it is the thing you are selling.
A customer paying for AI bookkeeping is not paying for a model. They are paying for someone being accountable when the tax return is wrong. The approval is where that accountability is recorded. Take it away and you have a cheaper product that is harder to sell.
So Mycel has no autonomy switch. It has a way to describe, narrowly, the cases where you have already decided.
What gets gated
Actions, not thoughts. The agent can reason, search, read a connection and draft freely. The gate closes when it tries to make something happen outside the sandbox — send, charge, refund, book, file, delete.
Two paths reach the gate, and they behave differently in one important way:
- The action proxy — the agent calls
/v1/internal/actions/:capabilityto use a connection. Alwaysrisk: high. This is the path that supports editing. - The tool gate — the OpenCode plugin intercepts any tool whose name matches a pattern (
send,charge,delete, …) and asks before it runs. Approve or reject only.
The lifecycle
- The agent requests a gated capability
- An
Approvalrow is created — always, even when policy will resolve it, so autonomy stays auditable rather than invisible - Policy is evaluated first. A match resolves it as
auto_approvedwith a reason, emitsapproval.resolved, and the run never pauses - No match: task status becomes
awaiting_approval,approval.requestedis emitted with the preview payload, and the run blocks - You call
approveorreject. On approve the task returns torunningand the action executes
Rejecting is not "skip this step". It ends the whole task with status rejected. If you want a different draft, edit and approve.
The queue
GET /v1/approvals?status=pending
GET /v1/approvals?status=auto_approved # what ran without you — review these
POST /v1/approvals/:id/approve
{ "edited": { "body": "…" } } # optional; merged over the payload
POST /v1/approvals/:id/rejectAn Approval carries approval_id, task_id, action, risk, the preview payload, status, an optional policy_reason and expires_at. Note the id field is approval_id, not id.
The auto_approved filter is the one people forget to build a screen for. It is how you check on Friday what your policy actually let through on Wednesday.
Editing is how the system learns
The draft is nearly right. You fix a sentence, approve, and move on. Two things then happen that make that thirty seconds worth more than a prompt-engineering session:
editedis merged over the agent's payload —{...payload, ...edited}— and the merged version is what actually goes out- a
correctionknowledge item is written for the wedge, recording what was proposed and what you changed it to, andfeedback.recordedlands on the task timeline
The next run sees your judgment. You did not author anything. See Teaching the agent.
Editing only applies on the action proxy path. The OpenCode tool gate reads the decision and discards edited — the tool runs with its original arguments and no correction is stored. Approve-with-edit on a raw tool call is, today, just approve.
Policy: describing what you have already decided
Auto-approve is declared per wedge, in wedge.json. It is an envelope, not a toggle:
"policy": {
"auto_approve": [
{ "action": "email:send_reminder", "max_per_task": 3, "max_per_day": 50 },
{ "action": "refund:", "max_amount_usd": 25 }
]
}Four fields, and they are all you get:
action— exact match, or a prefix if it ends in:or*, or"*"for everything. Case-insensitive.max_amount_usd— read from the payload keysamount_usd,amount,total,value, in that order, top level onlymax_per_task— a cap within a single runmax_per_day— a cap per project per UTC calendar day
It fails closed in every direction that matters. No policy block means every action is gated. No matching rule means gated. A rule that caps an amount, applied to a payload with no recognisable amount, means gated — a nested { invoice: { amount: 9999 } } is not found, so it stops. Refusals do not consume budget.
Two behaviours to design around: only the first matching rule applies, not the most specific — so order your rules narrow to broad. And the counters live in process memory, so with N replicas max_per_day is effectively N times what you wrote, and a restart resets it. Treat the caps as a guard rail, not an accounting control.
Failure modes
| Symptom | What is happening |
|---|---|
Task sits in awaiting_approval, then fails with expired | Nobody decided within five minutes. The TTL is fixed and not settable per request. |
409 already approved | It was already decided — by you twice, by a colleague, or by the TTL. The row is the source of truth; do not retry. |
| Approved in the UI, run never resumed | The decision landed on a different replica than the blocked run. A 700ms poll of the approval row covers this; if it persists past a few seconds, the run is gone. |
Approvals stuck pending forever after a restart | The expiry timer is process-local and expires_at is never swept. A restart fails the task and orphans the row. |
Cancelling a suspended task leaves it rejected | A cancel while blocked settles the waiter as a rejection. Cosmetic, but your UI should not promise otherwise. |
Building the UI
- Subscribe to the task stream; on
approval.requestedrender a card fromdata.preview— it is the payload that will be sent - Make the preview editable. If it is read-only, you lose the learning loop
- Route approve and reject through your server proxy, never the browser
- Show a countdown, or at least do not present a five-minute window as though it were open ended
- Or skip all of this to start with — the portal in
portal/ships an approvals queue
What to read next
Connections & secrets — what is on the other side of the gate, and where the credential lives while you decide.