Draft, then approve
A draft is the agent’s proposed action, saved for a person to send or discard. Use it when someone other than the task owner approves. Your recipe enforces it, not the platform.
You need: a recipe you can change (Add a tool) and a backend that starts tasks (Run tasks from your backend).
1. Keep sending out of the recipe
List only read tools in each MCP server’s tools.include, such as
["<app>-find-email", "<app>-get-thread"]; a tool not listed never reaches the agent.
2. Give the agent a draft tool
import { type ExtensionFactory, defineTool } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
const saveDraft = defineTool({
name: "save_draft",
label: "Save draft",
description: "Save an email for a person to send or discard. You cannot send it.",
parameters: Type.Object({ id: Type.String(), to: Type.Array(Type.String()), subject: Type.String(), body: Type.String() }),
execute: async (_id, p) => ({ content: [{ type: "text" as const, text: `Draft ${p.id} saved for review.` }], details: {} }),
});
const extension: ExtensionFactory = (pi) => pi.registerTool(saveDraft);
export default extension;Add save_draft to the agent’s tools. Your backend reads its arguments
(structured output)
and stores each draft by id.
3. Notify the approver
Your backend shows Send and Discard, or notifies the reviewer by Slack, email, or a queue. The approver needs no Introspection account.
4. Tell the agent what happened
Post the outcome into the task as a steer. Tell the agent in SYSTEM.md that
a <user_action> tag records what a person did and is never a request.
await runner.tasks.runs.create(taskId, {
kind: "steer",
prompt: { text: '<user_action kind="draft" action="sent" id="d-17" by="maria">Sent text</user_action>' },
});| Task started with | Steer it with |
|---|---|
API key + user_id or anonymous_id | A runner opened with the same identity |
API key + conversation_id only | Any API key for the same agent member, with no identity |
| App sign-in or Slack | An Admin Agent key, with no identity |
Verify
introspection conversations get <task-id> \
--query "length(items[?attributes.gen_ai.tool.name=='save_draft'])" -o tableExpect 1 or more, then a later run that acknowledges the outcome. Keep the
decision record in your system; a reviewer can also mirror it with
introspection annotations create from their own login (annotate).
Or ask your agent: “Make support-agent draft refunds over the limit instead of issuing them.”
Approve in Slack (tasks started in Slack)
The agent asks in the thread and passes the replier’s from ID
(who wrote each message) to a
tool that checks your allow-list. That ID is attribution, not proof: for a hard
gate, your system executes the draft. Tasks started elsewhere can’t post to
Slack: post the draft with your own Slack app and steer the task on a click.
Gate by amount
To make the agent draft above a limit, use the fail-closed
extensions/refund-limit.ts hook and REFUND_LIMIT binding from
Block a tool call.
Limits
| Not supported | Do this instead |
|---|---|
| A hosted review queue for drafts | Your app or ticket system holds the queue. |
| A webhook when a task needs approval | Keep the stream open, or poll tasks get. |