--dry-run stubs every write, which rehearses a workflow but never does the work. There was no middle - no way to say draft it and ask me - so anything that speaks in your name had to be handed the real capability up front, on the strength of a plan you read once.
Approvals are that middle.
Turning it on
Per workflow, in its frontmatter. This wins over the store-wide default in both directions:Read tools never wait. A queue that fills up with searches is one nobody reads, and the point of the gate is the calls that leave a mark.
Working the queue
px0 approvals show prints both the arguments and the output the run produced. Both, because a Slack message shown without the digest it announces is a decision made blind.
failed rather than returned to the queue. Retrying is your decision, and one that silently went back to pending would be approved twice.
Editing before sending
“Right message, wrong channel” had one answer before this: reject it and run the workflow again, which drafts something else. The message you actually wanted was never sendable.--set takes KEY=VALUE and is repeatable. A value that parses as JSON is read as JSON, so --set count=3 sets a number and --set channel=#ops sets a string. JSON that comes back from your editor unparseable changes nothing rather than being guessed at.
Every edit is stamped on the approval and shown on its screen, because what goes out should never be silently different from what the run produced.
Answering from somewhere else
The queue notified you wherever you asked and could only be answered at the machine px0 runs on - the wrong shape for drafted writes, since approvals happen when you are away from the desk. px0 has no server, so this is polling. A read tool is asked what came back, and replies naming an approval are acted on:approve apr_..., reject apr_..., or just yes apr_... / no apr_..., since that is what people actually type in answer to a message asking them a question. The verb has to open the reply, so “do not approve apr_…” matches nothing - and matching nothing does nothing.
Every reply goes through the same path a person at the terminal takes: an expired draft stays expired, an answered one is not answered twice, and the call that goes out is the one that was drafted. The daemon polls only while something is actually waiting.
Going stale
A drafted message is written against a moment. Sending last Tuesday’s standup on Friday because it was still sitting in the queue is worse than not sending it, so a draft older thanapprovals.expire_days (7 by default) stops being approvable. Set it to 0 to never expire.
px0 status counts what is waiting, and an unattended run that drafts something tells you through the same channels a failure uses. Nobody was there to see it happen, and silence would leave you believing the message went out. A manual run is not notified about: you are there, and it prints the draft.

