# Recipe: review and approve AI reply drafts

> List the AI reply drafts waiting in the inbox, read what each one used and flagged, then approve, edit or discard them from your own app with the unibox API.

Source: https://docs.ayinbox.com/api/recipes/approve-ai-drafts/

You build a human-in-the-loop approval queue for AI sales replies with three unibox endpoints. The [inbox agent](/guides/inbox-agent/) keeps at most one pending draft per thread; your code lists them with `GET /unibox/agent-drafts`, shows each draft's grounding and flags to a reviewer, and sends the chosen one with `POST /unibox/agent-drafts/:id/approve`. A draft that carries flags answers `409 draft_flagged` to an API key until the call repeats with `"acknowledge_flags": true`.

An **agent draft** is a suggested reply the inbox agent writes when a person answers one of your campaigns. It is stored, not sent. Approving is the only path that sends it, and it goes out through the normal reply path from the draft's mailbox (`email_account_id`), threaded to the prospect's reply.

Before you start [#before-you-start]

| Need                                                                      | Why                                                                                                                           |
| ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| Inbox agent turned on (**Settings, Workspace**, **Draft replies for me**) | Without it no agent drafts are written. It is a paid feature that costs 5 [AI credits](/guides/ai-credits/) per drafted reply |
| An API key with `READ_UNIBOX`                                             | To list drafts                                                                                                                |
| `WRITE_UNIBOX` on the same key                                            | To approve or discard. Approving sends mail                                                                                   |
| `WRITE_CAMPAIGNS` (optional)                                              | To see flags that quote your internal rules, see [step 2](#2-show-the-flags)                                                  |
| `REALTIME_SUBSCRIBE` (optional)                                           | To hear about new drafts instead of polling, see [keep it live](#keep-it-live)                                                |

Requests below use `https://api.ayinbox.com/v1` and a key in the `Authorization` header. Scopes and how to grant them are in [permissions](/api/permissions/).

1. List drafts [#1-list-drafts]

```bash
curl -s "https://api.ayinbox.com/v1/unibox/agent-drafts" \
  -H "Authorization: Bearer aybox_..."
```

The answer is `{"data": [...]}`: the workspace's pending drafts, newest first, up to 100. The route takes no cursor or limit, and a draft leaves the list as soon as it is approved or discarded.

| Field                        | What it holds                                                                        |
| ---------------------------- | ------------------------------------------------------------------------------------ |
| `id`                         | The draft id you approve or discard                                                  |
| `thread_id`                  | The conversation. Read it with `GET /unibox/thread` to show the reviewer the context |
| `email_account_id`           | The mailbox the reply will be sent from                                              |
| `to_addr`, `subject`         | Recipient and the subject the reply will carry                                       |
| `body`                       | The suggested reply, plain text                                                      |
| `campaign_id`, `contact_id`  | Set when the thread belongs to a campaign and a known contact                        |
| `intent_class`, `confidence` | How the agent classified the reply it answered                                       |
| `model`                      | The model that wrote it                                                              |
| `status`                     | Always `pending` on this list                                                        |
| `grounding`                  | What the draft read, present when a campaign or brain applied                        |

`grounding` names what the draft read and never holds the source text. `brains` lists the [campaign brain](/guides/campaign-brain/) (with its `revision`), `campaign` names the campaign and sequence, `fields` lists the brain fields used, and `sources` lists up to 40 sources the draft read (material, pages and playbooks) with a title, character count and why each was picked. `read` and `left_out` count the brain's material only: how many sources it read and how many it left out, and `budget_chars` is the reading budget. Show this as a "used" line next to the draft so a reviewer knows which pricing page or playbook the answer leaned on. The full shape is in [draft grounding](/api/reference/unibox/#draft-grounding).

2. Show the flags [#2-show-the-flags]

After a draft is written, AY Inbox checks it against the brain and records anything the brain does not back as `grounding.flags`. Each flag has a `kind`, the `match` copied from the draft body, a `sentence` you can show as is, and `from_inbound: true` when the draft repeats something from the prospect's own email.

| `kind`                | Meaning                                        |
| --------------------- | ---------------------------------------------- |
| `link_not_allowed`    | A link the brain does not give                 |
| `address_not_allowed` | An email address the brain does not give       |
| `phone_not_allowed`   | A phone number the brain does not give         |
| `unverified_figure`   | A price or figure the brain does not give      |
| `internal_figure`     | A figure taken from your internal rules        |
| `internal_phrase`     | A run of words quoted from your internal rules |
| `do_not_say`          | A phrase on your do-not-say list               |

What a caller sees depends on its scope. A key without `WRITE_CAMPAIGNS` (or whose member lacks the manage campaigns permission) gets `unverified_figure` in place of `internal_figure` and no `internal_phrase` at all, so internal rules never leak to a reader who may not see them. Render the flags as badges beside the send button. The check is a heuristic: it catches specific mistakes, misses some and over-flags others, so treat it as a prompt for the reviewer, not a verdict. See [flags](/guides/campaign-brain/#flags).

3. Edit before approving [#3-edit-before-approving]

To send a changed reply, pass the reviewer's text as `body` on approve. An empty or missing `body` sends the draft as written.

```json
{ "body": "Hi Dana,\n\nTuesday at 10 works. I'll send an invite.\n\nSam" }
```

When the text you send differs from the stored draft, AY Inbox records that the draft was edited. The campaign's [auto-reply](/guides/auto-reply/#modes) panel reads that record in Supervised mode, where it reports how many answers went out as written and how many were edited or discarded. Editing does not clear flags: an edited body on a flagged draft still needs the acknowledgement in step 4.

4. Approve or discard [#4-approve-or-discard]

Approve with an `Idempotency-Key` so a retry after a timeout cannot send twice:

```bash
curl -s -X POST "https://api.ayinbox.com/v1/unibox/agent-drafts/$DRAFT_ID/approve" \
  -H "Authorization: Bearer aybox_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: approve-$DRAFT_ID-1" \
  -d '{}'
```

A success returns the queued send (`task_id`, `scheduled_at`, `send_mode`). If the draft has flags the caller may read, the call is held instead and the draft stays pending:

```json
{
  "error": "Conflict",
  "code": "draft_flagged",
  "message": "This draft has flags. Read them, then approve again with acknowledge_flags set to true and a new Idempotency-Key (or none).",
  "flags": [{ "kind": "link_not_allowed", "match": "https://example.net/x", "from_inbound": true, "sentence": "Repeats a link from their email. Check it before sending." }],
  "request_id": "req_8f3a1c2b9d"
}
```

The flow your code should follow:

1. Approve without `acknowledge_flags`.
2. On `409` with `code: "draft_flagged"`, show the returned `flags` to the reviewer.
3. When the reviewer confirms, approve again with `"acknowledge_flags": true` and a **new** `Idempotency-Key` (or none). Reusing the first key with a different body answers `409`.

```bash
curl -s -X POST "https://api.ayinbox.com/v1/unibox/agent-drafts/$DRAFT_ID/approve" \
  -H "Authorization: Bearer aybox_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: approve-$DRAFT_ID-2" \
  -d '{"acknowledge_flags": true}'
```

Only API key and OAuth callers are held. A signed-in member approving in the dashboard sees the same flags as badges and is never blocked. A caller without `WRITE_CAMPAIGNS` is held only on the flags it can read, so a draft whose only flag is an `internal_phrase` is not held for it.

Other answers to handle: `404` when the draft does not exist in this workspace, and `409` "this draft was already handled" when a teammate approved or discarded it first. Two approvals can never both send, because the first one claims the draft before sending.

To close a draft unsent:

```bash
curl -s -X POST "https://api.ayinbox.com/v1/unibox/agent-drafts/$DRAFT_ID/discard" \
  -H "Authorization: Bearer aybox_..."
```

It answers `{"status": "discarded"}`, or `404` when the draft is gone or already handled. All error bodies follow the envelope in [error codes](/api/error-codes/).

5. Draft on demand [#5-draft-on-demand]

The agent only drafts on inbound human replies. To get a draft for any thread yourself, call `POST /unibox/reply/draft` with `thread_id` and an optional `instruction`:

```bash
curl -s -X POST "https://api.ayinbox.com/v1/unibox/reply/draft" \
  -H "Authorization: Bearer aybox_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: draft-thread-42-1" \
  -d '{"thread_id": "...", "instruction": "propose Tuesday at 10"}'
```

It returns `text`, `credits_charged`, `credits_remaining`, `model` and, when a campaign or brain applied, the same `grounding` and flags. It never sends and is not stored as an agent draft, so send the reviewed text with `POST /unibox/reply`. It costs at least 2 AI credits, plus a usage-based amount when the call uses many tokens, for example with a large brain. An `Idempotency-Key` pays for one draft, a retry replays the first answer, and a brain that cannot be read answers `503 brain_unavailable` before anything is charged.

An agent built on [agent tools](/api/agent-tools/) or [MCP](/api/mcp/) uses `generate_reply_draft` (`READ_UNIBOX` and `AI_DRAFT`), which requires an `idempotency_key` argument. A call without one answers `422 idempotency_key_required`, and a key whose draft succeeded will not draft again, so use a new key for each new draft. The same surfaces offer `list_agent_drafts` and `discard_agent_draft`. There is no tool that approves: sending stays with a person or with your own code calling the approve route.

Keep it live [#keep-it-live]

Instead of polling the list, open a [realtime](/api/realtime/) socket with a key holding `REALTIME_SUBSCRIBE` and `READ_UNIBOX`, join `org:<org_id>`, and narrow the stream with intents such as `["AI_DRAFT", "AUDIT"]`.

| Event                                        | When                                                             | What to do                                          |
| -------------------------------------------- | ---------------------------------------------------------------- | --------------------------------------------------- |
| `AI_DRAFT_READY`                             | The agent stored a new draft. Carries `thread_id` and `draft_id` | Refetch `GET /unibox/agent-drafts`                  |
| `AUDIT_CREATED` with `entity_type: "unibox"` | Someone approved or discarded a draft                            | Refetch, so the draft leaves every reviewer's queue |

For how drafts are written and why a person stays in the loop, see [AI replies to cold email](/learn/ai-replies-to-cold-email/). The endpoint reference is [unibox](/api/reference/unibox/).

## Frequently asked questions

### Does the inbox agent send replies on its own?

No. The inbox agent only writes a pending draft. A reply goes out when a person in the dashboard, or your code with a WRITE_UNIBOX key, calls approve. Sending without approval is a separate per-campaign opt-in called auto-reply.

### Can an API key limited to some mailboxes review agent drafts?

Yes, for its own mailboxes only. GET /unibox/agent-drafts leaves out drafts from mailboxes the key is not allowed, and approving one of those is refused.

### What happens if the send fails after I approve a draft?

The draft goes back to pending, so it is not lost and you can approve it again. The error from the send path is returned to your call.

### Why does a draft have no grounding object?

Grounding is only attached when a campaign or campaign brain applied to the draft. A draft without it carries no flags, so approving it never answers 409 draft_flagged.

### How long does a pending agent draft wait?

Until someone approves or discards it. Turning the inbox agent off stops new drafts but leaves waiting ones in place, and a calendar booking draft replaces whatever draft was pending on that thread.
