Skip to content

Human approval

Some calls should not run just because somebody holds a permission. Deleting a production run, refunding above a threshold, closing a release. Declare it on the capability and the call waits for an answer before it runs.

tool(
'delete_case',
{
permission: 'cases:delete',
inputSchema: z.object({ id: z.string(), force: z.boolean().default(false) }),
audit: ({ id }) => `case:${id}`,
// Only the destructive half of the argument space warrants a person.
approval: ({ force }) => force,
},
deleteCase,
);
server(definitions, {
name: 'acme',
version: '1.0.0',
onApproval: async (request) => {
// request carries who, what tool, and the arguments
const reply = await askInSlack('#ops', request);
return reply.ok ? { approved: true, by: reply.user } : { approved: false, reason: reply.text };
},
});

approval is true for every call, or a predicate over the arguments when only some of them warrant a person.

{ approved: true } does not compile without by, and an approval that arrives naming nobody is refused at runtime as well.

The type is the first line and not the only one. An adapter written in untyped code, or one handing back a JSON reply from a chat tool, can still produce an anonymous yes, and that would run a destructive call under an audit trail claiming somebody had agreed to it. An approval with no name on it is not a second pair of eyes.

The approver’s name lands on the success audit event beside the caller’s. That event is the only place both people appear, because the downstream API still sees one service account.

The caller already holds cases:delete. Approval is the separate question of whether this call should happen.

It is also the one check here that cannot be a decision about whether to register something, because it turns on arguments that do not exist until the call. An approval-gated capability therefore stays visible in tools/list and is refused on invocation, unlike an unpermitted one.

A refusal reaches the handler’s place as an ApprovalRefusedError carrying the capability and, when there was one, the person who declined. The audit event is phase: 'refused' with decision: 'deny', and its durationMs covers the wait, so the log shows what a call actually cost rather than what the handler did. In Python the caller sees {"error": "forbidden", "reason": "approval_refused"}.

A capability with nowhere to ask fails at boot

Section titled “A capability with nowhere to ask fails at boot”

server(...) and gate(...) both refuse to wire an approval with no reachable sink, the same way an unreachable capability refuses to start. Python raises at registration. The failure lands at startup rather than at the first destructive call.

Nothing here is durable, and it does not need to be

Section titled “Nothing here is durable, and it does not need to be”

The caller is still on the other end of an open request, so a process that dies mid-question takes the request with it, and the action correctly did not happen. Durability is owed once you have told a caller you are done and promised to act later, which is a promise this never makes.

If you need approval that outlives the request, keep the same seam. Have onApproval write a pending row, return { approved: false, reason: 'ticket appr_123' }, and add a tool of your own that polls it. The store lives in your application, where you can see it, and this package stays something you install rather than something you run.

gate() takes the same options: an approval map keyed exactly like permissions, plus onApproval. Putting a person in front of somebody else’s destructive tool is the best reason to reach for it.