# Attachments

There are two independent attachment surfaces, and they never share a byte or a
code:

- **Inbound** — reading attachments on mail *sent to you*. What an agent can see
  depends on the mailbox's exposure tier. Raw bytes are the most-restricted tier;
  a safe text **preview** is the middle tier; **metadata only** is the default.
- **Outbound** — sending attachments *from your mailbox* by a two-phase
  **stage-then-reference** flow: upload the bytes once to get a single-use
  **handle**, then pass the handle id in `attachment_ids` on a send / reply /
  draft. Handles are content-scanned, expire in 24 hours, and are consumed once.

Attachment bytes are stored encrypted at rest (provider-managed) and in transit
over TLS, and the platform scans attachment content. This is **not** end-to-end
or zero-access encryption.

---

## Inbound: reading attachments

Every attachment appears in the `attachments[]` array of the message detail
(`GET /v1/messages/:id`) and thread reads with per-attachment metadata:
`filename`, `content_type` (server-sniffed magic-byte type, not the declared
MIME), `size`, `hash`, plus a preview-summary projection (below). What you can
*retrieve* — a safe text preview or the raw bytes — is gated by the mailbox's
exposure tier and your key role.

### Exposure tiers, as an agent sees them

| Mailbox tier | Metadata (in `attachments[]`) | Text preview (agent key) | Raw bytes (agent key) |
|---|---|---|---|
| `metadata_only` (default) | Yes | `403 ATTACHMENT_PREVIEW_DISABLED` | `403 ATTACHMENT_ACCESS_DISABLED` |
| `derived_content` | Yes | Yes, when ready | `403 ATTACHMENT_ACCESS_DISABLED` |
| `raw_download_selected_types` | Yes | Yes, when ready | Yes, for allowed families |

The tier is set by a human account owner in the dashboard. An agent cannot change
it. Dashboard sessions and admin keys are governed by a separate human-safety
gate and can read clean previews even on a `metadata_only` mailbox; the table
above describes **agent-role keys**, which follow the mailbox tier exactly.

### The preview machine — `preview_status`

A safe text preview is extracted asynchronously after a message arrives. Poll the
per-attachment `preview_status` on the **message detail** (`GET /v1/messages/:id`)
or thread read to decide when to fetch — do not poll the preview endpoint while a
derivative is still pending.

`attachments[i]` carries these preview-summary fields:

| Field | Type | Meaning |
|---|---|---|
| `preview_status` | `pending \| ready \| blocked \| failed \| null` | Derivative lifecycle state; `null` when no preview was ever attempted (unsupported type, outbound, legacy). |
| `preview_kind` | `text \| null` | `text` once ready (the only kind today), else `null`. |
| `preview_reason_code` | string `\| null` | Populated for `blocked` / `failed` (e.g. `extraction_timeout`, `parser_failure`, `encrypted`, `scanner_error`, `unsupported_office_format`, `source_too_large`). |
| `preview_char_count` | integer `\| null` | Extracted source character count (before the inline cap). |
| `preview_page_count` | integer `\| null` | Non-null only for PDF. Office derivatives persist `null` (slides/sheets are not pages). |
| `preview_truncated` | boolean | True if truncated at extraction or at the inline cap. |
| `preview_generated_at` | ISO timestamp `\| null` | Non-null only when `preview_status = 'ready'`. |

Supported source types: `text/plain`, `text/csv`, `application/pdf`, and Office
Open XML (DOCX / PPTX / XLSX). Office previews carry **visible text only** —
comments, speaker notes, hidden sheets, embedded media, OLE objects, uncached
formula results, and macros are excluded. Legacy binary `.doc`/`.ppt`/`.xls` and
macro-enabled variants are unsupported (`preview_status` stays `null`).

### Fetching a preview — `GET /v1/messages/:id/attachments/:idx/preview`

Call only when `preview_status === 'ready'`. Returns extracted UTF-8 text inline,
hard-capped at 20,000 characters (the shared body delivery/scanner cap), never raw
bytes.

```json
{
  "attachment": { "filename": "invoice.pdf", "content_type": "application/pdf", "size": 12345, "hash": "sha256:..." },
  "preview": {
    "kind": "text",
    "content": "extracted text, capped ...",
    "truncated": true,
    "char_count": 8123,
    "page_count": 4,
    "extractor": "<opaque engine identifier>",
    "generated_at": "2026-04-27T18:00:00.000Z"
  }
}
```

`content` is capped at 20,000 chars; `char_count` is the pre-cap source size;
`truncated: true` means the worker truncated at extraction OR the inline cap was
hit. `extractor` is an opaque identifier for the extraction engine used.

**Preview error matrix (code → meaning → action):**

| HTTP | `code` | Meaning | Action |
|---|---|---|---|
| `400` | `VALIDATION_ERROR` | `:idx` is not a non-negative integer | Fix the index. |
| `403` | `ATTACHMENT_PREVIEW_DISABLED` | Agent key on a `metadata_only` mailbox (or a tier below the required entitlement) | Config/entitlement change; a human raises the mailbox tier. Don't retry as-is. |
| `403` | `ATTACHMENT_PREVIEW_BLOCKED` | Extracted-text scan rejected the content | Terminal for this attachment. Do not retry. |
| `404` | `NOT_FOUND` | Unknown message, out-of-range `idx`, or an unbound agent key (masked) | Verify the id and your key's mailbox binding. |
| `404` | `ATTACHMENT_PREVIEW_NOT_AVAILABLE` | Derivative `failed`, or no derivative exists (unsupported type) | Terminal. Read `preview_reason_code` on the message detail. |
| `409` | `ATTACHMENT_PREVIEW_PENDING` | Extraction still in flight | Retry after a short delay, or wait for the preview webhook. Poll message detail, not this endpoint. |
| `500` | `PREVIEW_STORAGE_ERROR` | Storage/invariant fault (never masked as 404) | Transient infra error — retry with backoff. |

Preview lifecycle webhook events fire as a derivative transitions
(queued / ready / blocked / failed) and carry metadata only, never content — see
[Webhooks](/agents/webhooks) for the event catalog.

### Raw byte download — `GET /v1/messages/:id/attachments/:idx`

Returns a short-lived presigned download URL (`Content-Disposition: attachment`,
sniffed `Content-Type`). For an **agent key** this requires the mailbox on
`raw_download_selected_types`, the attachment's family in the allowlist, and a
Pro+ entitlement; otherwise `403 ATTACHMENT_ACCESS_DISABLED`. Raw **image**
downloads additionally require a separate image-risk acknowledgement
(`403 ATTACHMENT_IMAGE_RISK_NOT_ACCEPTED` until accepted). A gate denial by
message/attachment state returns `403` with a specific code
(`ATTACHMENT_BLOCKED`, `ATTACHMENT_QUARANTINED`, `ATTACHMENT_UNVERIFIED`,
`ATTACHMENT_LEGACY_UNSCANNED`, `MESSAGE_NOT_AVAILABLE`);
`404 ATTACHMENT_NOT_STORED` means the bytes were never individually stored or were
discarded after retention. Inline image rendering
(`.../attachments/:idx/inline-data-url`) is a dashboard-session surface — all
Bearer keys (agent and admin) get `403`.

The Pro+ entitlement and per-tier availability live in [Limits](/docs/limits).

---

## Outbound: staging attachments to send

Outbound attachments use a **stage-then-reference** state machine. You never
attach bytes directly to a send body — you stage them, then reference the handle.

```
POST /v1/attachments (multipart)
      │  content_scan_status = "pending"
      ▼
[handle staged]  ──(24h TTL)──►  expires → 409 ATTACHMENT_ALREADY_CONSUMED
      │
      │  poll GET /v1/attachments/:id → content_scan_status: clean | flagged | error
      ▼
send / reply / draft with attachment_ids: [handle_id]
      │
      ▼
[consume once]  ──re-reference──►  409 ATTACHMENT_ALREADY_CONSUMED
```

### Prerequisite: the mailbox must be enabled (an agent cannot self-enable)

Outbound attachments are off by default. Enabling them
(`POST /v1/mailboxes/:id/outbound-attachments`) is a **dashboard-session +
fresh re-auth** action performed by a human account owner — a Bearer API key is
rejected with `403 REAUTH_REQUIRES_SESSION` before any state change. It also
requires a Pro+ account. Once a human has enabled the mailbox, agent keys stage
and send handles normally. If your uploads fail with
`403 OUTBOUND_ATTACHMENTS_DISABLED`, the mailbox has not been enabled; if
`403 TIER_LIMIT`, the account is below Pro+. (`OUTBOUND_ATTACHMENTS_INELIGIBLE`
is a distinct code from the enablement route — it means the account is
**suspended**, not below-tier.)

### Step 1 — stage: `POST /v1/attachments`

`multipart/form-data` with exactly two parts: a text field `mailbox_id` (name or
UUID, the mailbox the handle is scoped to) and a single file part.

```json
{
  "id": "8b1f...-handle-uuid",
  "filename": "invoice.pdf",
  "content_type": "application/pdf",
  "size": 18234,
  "hash": "9f86d081...",
  "scan": null,
  "content_scan_status": "pending"
}
```

- `id` is **the handle** — put it in `attachment_ids`.
- `content_type` is the **server-sniffed** type, not your declared MIME. The
  declared MIME never admits a family.
- `scan` (a `ScanSummary`) is `null` on a clean upload — as shown above. It is a
  populated object **only** when the synchronous filename scan found something.
- `content_scan_status` is **always `"pending"`** at 201 — the deep content scan
  runs asynchronously.

**Upload error matrix (code → meaning → action):**

| HTTP | `code` | Meaning | Action |
|---|---|---|---|
| `400` | `VALIDATION_ERROR` | Missing/empty `mailbox_id` or file part | Fix the request. |
| `400` | `ATTACHMENT_TOO_LARGE` | File exceeds the 10 MB per-file cap | Shrink or split. |
| `400` | `ATTACHMENT_MULTIPLE_FILES` | More than one file part | One file per upload; call again per file. |
| `400` | `OUTBOUND_ATTACHMENT_TYPE_NOT_ALLOWED` | Sniffed family not in the outbound allowlist | Use an allowed type (declared MIME is ignored). |
| `400` | `OUTBOUND_ATTACHMENT_FILENAME_INVALID` | Filename >255 chars, non-ASCII-printable, or has path separators / quotes / control chars | Rename. |
| `400` | `ATTACHMENT_BLOCKED` | Filename/type disallowed by policy | Terminal for this file. |
| `400` | `ATTACHMENT_INFECTED` | Synchronous AV returned infected | Terminal. |
| `400` | `ATTACHMENT_AV_ERROR` | AV scanner errored (fail-closed) | Transient — retry. |
| `400` | `OUTBOUND_IMAGE_DISCLAIMER_REQUIRED` | An image staged before the image-risk disclaimer was accepted; `details.required_version` | A human accepts the image-risk disclaimer for the mailbox. |
| `403` | `OUTBOUND_ATTACHMENTS_DISABLED` | Mailbox not enabled for outbound attachments | A human enables it (see above). |
| `403` | `TIER_LIMIT` | Account below the `outbound_attachments` (Pro+) tier | See [Limits](/docs/limits). |
| `404` | `NOT_FOUND` | Mailbox not found, or agent key not bound to it | Check the selector and key binding. |
| `429` | `OUTBOUND_ATTACHMENT_STAGING_QUOTA_EXCEEDED` | Too many un-consumed staged handles for the mailbox | Consume or `DELETE` pending handles first. |

### Step 2 — poll status: `GET /v1/attachments/:id`

Returns one of two shapes, discriminated by the presence of `status`.

**Active handle** (no `status` field):

```json
{
  "id": "8b1f...",
  "filename": "invoice.pdf",
  "content_type": "application/pdf",
  "size": 18234,
  "hash": "9f86d081...",
  "scan": { "verdict": "warning", "categories": [{ "category": "secret_detected", "decision": "allow_with_warning" }], "findings": [] },
  "content_scan_status": "flagged"
}
```

**Consumed handle** (`status: "consumed"`):

```json
{ "id": "8b1f...", "status": "consumed", "consumed_at": "2026-06-01T00:00:05.000Z", "consumed_message_id": "uuid" }
```

`content_scan_status` is a separate channel from the `scan.verdict`. Its terminal
values decide what happens at send:

| `content_scan_status` | Meaning | At send time |
|---|---|---|
| `pending` | Deep content scan still running | `409 ATTACHMENT_SCAN_PENDING` — poll, then retry the send. |
| `clean` | No findings | Sends normally. |
| `flagged` | A secret/PII finding in content or filename | **Sendable.** Findings fold into the message verdict (block/quarantine by severity, like a body finding) — not a 4xx. |
| `error` | Scan could not complete | Fail-closed: `400 ATTACHMENT_SCAN_ERROR` at send. |

You don't have to poll to `clean` before sending — the send resolver enforces the
scan state itself. A `flagged` handle is not an error; it is a signal that the
finished message may be quarantined or blocked by the send gate.

### Step 3 — attach and consume: `attachment_ids`

Pass the handle id(s) in `attachment_ids` (a `string[]` of UUIDs, `uniqueItems`)
on `POST /v1/messages/send`, `POST /v1/messages/:id/reply`, or `POST /v1/drafts`.
Each handle is **consumed once** at send and must be scoped to the send's mailbox
(a cross-mailbox reference resolves as `404 ATTACHMENT_NOT_FOUND`). On a draft the
handles are **held** at create time and consumed at dispatch
(`POST /v1/drafts/:id/send`), so they must still be within the 24h window then.

A `flagged` attachment does **not** produce a send-time 4xx — its findings fold
into the message verdict and are blocked or quarantined by severity exactly like
a body finding. See [Message states & verdicts](/agents/messages) for the verdict
vocabulary and [Send gates](/agents/send-gates) for the decision tree.

**Send-resolver error matrix (code → meaning → action):**

| HTTP | `code` | Meaning | Action |
|---|---|---|---|
| `404` | `ATTACHMENT_NOT_FOUND` | Unknown / cross-account / cross-mailbox / expired handle | Re-stage; reference from the owning mailbox. |
| `409` | `ATTACHMENT_ALREADY_CONSUMED` | Handle already sent, or its 24h window elapsed | Re-stage a fresh handle. |
| `409` | `ATTACHMENT_SCAN_PENDING` | Content scan not yet terminal | Poll `GET /v1/attachments/:id`, then retry the send. |
| `400` | `ATTACHMENT_SCAN_ERROR` | Scan ended in `error` (fail-closed) | Re-stage the file. |
| `400` | `ATTACHMENT_BLOCKED` / `ATTACHMENT_INFECTED` / `OUTBOUND_ATTACHMENT_TYPE_NOT_ALLOWED` | Policy / AV / type rejection surfaced at send | Terminal for this file. |
| `400` | `ATTACHMENT_LIMIT_EXCEEDED` | More than 10 attachments on the message | Reduce count. |
| `400` | `ATTACHMENT_TOTAL_SIZE_EXCEEDED` | Total raw bytes over 15 MB (or over the provider's encoded cap) | Reduce total size. |
| `502` | `ATTACHMENT_FETCH_FAILED` | Stored bytes could not be fetched | Transient — retry; re-stage if it persists. |
| `403` | `OUTBOUND_ATTACHMENTS_DISABLED` / `TIER_LIMIT` | Mailbox disabled or account downgraded between stage and send | Re-enable / upgrade. |

### Consume-once and delete

- Referencing a spent handle again → `409 ATTACHMENT_ALREADY_CONSUMED`.
- `GET /v1/attachments/:id` on a spent handle returns the consumed shape
  (`status: "consumed"` + `consumed_message_id`).
- `DELETE /v1/attachments/:id` removes an **un-consumed** handle (`204`);
  deleting a consumed handle → `409 ATTACHMENT_ALREADY_CONSUMED`.

### Attachment sends are synchronous

Attachment sends must be synchronous in v1 (a single-use handle expires 24h after
upload and is consumed at dispatch, so a deferred dispatch could fire after the
handle is gone). `POST /v1/messages/send` and `POST /v1/messages/:id/reply` ignore
`Prefer: respond-async`, so the case never arises there. The only async send path
is `POST /v1/drafts/:id/send` with `Prefer: respond-async`, and an
attachment-bearing draft sent that way is rejected with
`400 ATTACHMENTS_REQUIRE_SYNC_SEND`. The scheduled-send combination is likewise
rejected up front: `POST /v1/drafts` refuses `send_at` + `attachment_ids`
together, and `PATCH /v1/drafts/:id` refuses setting `send_at` on an
attachment-bearing draft (or adding attachments to a scheduled draft) — all
`400 ATTACHMENTS_REQUIRE_SYNC_SEND`.

---

## Caps

| Limit | Value | Exceeded → |
|---|---|---|
| Per-file size | 10 MB | `400 ATTACHMENT_TOO_LARGE` (stage), `400 ATTACHMENT_TOTAL_SIZE_EXCEEDED` (send total) |
| Attachments per message | 10 | `400 ATTACHMENT_LIMIT_EXCEEDED` |
| Total raw bytes per message | 15 MB | `400 ATTACHMENT_TOTAL_SIZE_EXCEEDED` |
| Filename | ≤255 ASCII-printable chars; no path separators, quotes, or control chars | `400 OUTBOUND_ATTACHMENT_FILENAME_INVALID` |
| Handle lifetime | 24 hours from upload | `409 ATTACHMENT_ALREADY_CONSUMED` on reference |
| Preview text | 20,000 characters (default) | `truncated: true` in the preview response |
| Files per upload | 1 | `400 ATTACHMENT_MULTIPLE_FILES` |

---

## Related

- [Message states & verdicts](/agents/messages) — how a flagged attachment's verdict resolves.
- [Send gates](/agents/send-gates) — the outbound decision tree a consumed attachment feeds.
- [Errors](/agents/errors) — the shared error-envelope shape.
- [Webhooks](/agents/webhooks) — preview lifecycle and message events.
- [Limits](/docs/limits) — tier entitlements for previews and outbound attachments.
