---
title: Drafts & human-in-the-loop
description: Voluntary vs. forced drafts, the pending_approval flow, content_hash concurrency at approval, tombstones, and the MCP draft tools.
---

# Drafts & human-in-the-loop

A draft is a message prepared but not yet delivered. Drafts power two things: an agent staging
work for later, and the human-in-the-loop (HITL) approval gate.

## Voluntary vs. forced drafts

- **Voluntary** — you explicitly create a draft with `POST /api/v1/drafts`. It sits in status
  `draft` until you send it (or a human touches it). A voluntary draft is **never** forced at
  creation time, even on a mailbox that requires approval.
- **Forced** — you call `POST /api/v1/send` on a mailbox whose `require_approval` is on. Instead
  of delivering, Postfleet creates a draft in status `pending_approval` and returns `202`:

  ```json
  { "draft_id": "d_123...", "status": "pending_approval" }
  ```

  This is **not** a failure and must **not** be retried — the email is delivered once a human
  approves it. Whether a mailbox requires approval is a dashboard (control-plane) setting; it
  is read fresh at send time, and a forced draft stays forced even if the setting is later
  turned off.

## Draft endpoints

All draft endpoints need an `api`/`mcp` key. Reads need `can_read`; writes need `can_send`.

### Create — `POST /api/v1/drafts`

Same envelope fields as `POST /api/v1/send` (`mailbox_id` + `text` required; `to`/`subject`
optional, or pass `reply_to_message_id` to draft a threaded reply). Returns `201`:

```json
{ "id": "d_123...", "status": "draft" }
```

### List — `GET /api/v1/drafts?mailbox_id=…`

Returns the **open** drafts (status `draft`, `pending_approval`, or `sending`), newest first:

```json
{ "drafts": [ { "id": "d_123...", "mailbox_id": "b1a2...", "status": "draft",
  "to": "person@example.com", "subject": "…", "text": "…",
  "reply_to_message_id": null, "requires_human": false,
  "thread_id": "t_…", "created_at": "…", "updated_at": "…" } ] }
```

### Get — `GET /api/v1/drafts/:id`

Returns that same public draft shape. Missing, cross-account, cross-mailbox (for a bound key),
and tombstoned drafts all collapse to a byte-identical `404 {"error": "draft not found"}`.

### Edit — `PATCH /api/v1/drafts/:id`

Merges `to`/`subject`/`text` over the stored draft (an omitted field is unchanged) and
re-validates through the single-recipient guard. The reply context is immutable: sending
`reply_to_message_id` returns `400`. If the draft is no longer editable (it moved to `sending`
or a terminal state), the guarded update loses and returns:

```json
{ "code": "approval_conflict", "error": "draft is no longer editable" }
```

with status `409`.

### Delete — `DELETE /api/v1/drafts/:id`

`204` on success. If the draft can no longer be deleted (already `sending`/terminal),
`409 {"code": "approval_conflict", "error": "draft can no longer be deleted"}`. Missing/cross-scope
is `404`.

### Send — `POST /api/v1/drafts/:id/send`

Voluntary send of your own draft. The mailbox's **current** `require_approval` decides the path:

- Already `pending_approval` → idempotent `202 {"draft_id": "…", "status": "pending_approval"}`.
- `require_approval` on → the draft is promoted into the human queue, `202` pending.
- `require_approval` off → the draft is delivered through the shared send pipeline. On success,
  `201`:

  ```json
  { "id": "m_123...", "draft_id": "d_123...", "thread_id": "t_…",
    "provider_message_id": "prov_…" }
  ```

A lost transition (concurrent send/delete/approval) returns
`409 {"code": "approval_conflict", "error": "draft can no longer be sent"}`. A rejected/failed
delivery reverts the draft to `draft` and surfaces the reason; a provider-`unknown` outcome
leaves the draft in `sending` (see limitations).

## `content_hash` optimistic concurrency (at approval)

When a human approves a pending draft in the dashboard, they approve **the specific content
they reviewed**. The approve action carries the `content_hash` (a versioned `v1:` SHA-256 over
`{to, subject, text, in_reply_to}`) of the rendering they saw. If the draft was edited between
render and approval, the hash no longer matches and the guarded approval loses — surfaced as
`approval_conflict`. This prevents a last-second edit from being approved unseen.

Note: `content_hash` is **internal** — it is not part of the public draft shape and API callers
do not supply it. Editing a draft (`PATCH`) recomputes it automatically, which is what
invalidates any in-flight approval.

## Tombstones — "the draft disappears after send"

Once a draft is sent (or rejected, or deleted), it is **tombstoned**: it drops out of
`GET /api/v1/drafts` and returns `404` from `GET /api/v1/drafts/:id`. So after a successful
send, the message lives on under `/api/v1/messages` and the draft is gone. Don't treat a `404`
on a previously-sent draft as an error — it's the expected end state.

## MCP draft tools

- `create_draft` — stage a draft (returns `{id, status:"draft"}`).
- `list_drafts` — list open drafts in a mailbox.
- `send_draft` — send a staged draft by id. On a `require_approval` mailbox this returns the
  `202 {draft_id, status:"pending_approval"}` outcome — not a failure, do not retry; check
  `list_drafts`.

See [MCP setup](./mcp).

## Current limitations

- **No `client_id` idempotency on `POST /api/v1/drafts`.** The endpoint accepts a `client_id`
  but does **not** de-duplicate creation on it — a retried create can produce duplicate drafts.
  (Idempotency applies to *sending*, not to draft *creation*.)
- **No audit trail on draft create/edit/delete.** Only the approval/rejection steps are
  audited; `POST`/`PATCH`/`DELETE /api/v1/drafts` write no audit event.
- **A provider-`unknown` draft stuck in `sending` has no redrive.** If a draft send hits an
  ambiguous provider outcome it stays in `sending` (reconciled server-side) and there is no
  dashboard or API button to force-retry it.
