# Attachments

> Files in and out of a Pidgeon mailbox: upload once, attach by id, and download without the bytes ever passing through the API twice.

Files go up on their own and are named when you send. Nothing here is base64
in a JSON body.

## Sending one

Upload it, then reference the id:

```bash
curl https://pidgeon.ai/api/v1/attachments \
-H "Authorization: Bearer $PIDGEON_API_KEY" \
-F identity_id=0c939bd7-2d69-4150-a2ac-de994f0e4887 \
-F file=@invoice.pdf
```

```ts
const { data: file } = await attachmentsUpload({
body: { identity_id: '0c939bd7-2d69-4150-a2ac-de994f0e4887', file: blob },
});

await messagesSend({
body: {
  from: 'billing@yourdomain.com',
  to: 'priya@example.com',
  subject: 'Invoice 4471',
  text: 'Attached.',
  attachment_ids: [file.data.id],
},
});
```

```python
from pidgeon.api.attachments import attachments_upload

uploaded = attachments_upload.sync(client=client, body=body)
```

**Multipart, not JSON.** A ten-megabyte PDF is thirteen megabytes of base64,
and every proxy between you and us would carry the difference for nothing.

The same id can go on a [draft](https://pidgeon.ai/docs/drafts.md) instead, which is how a reply
gets a file attached before anybody has decided to send it.

## Receiving one

A message that arrives with files lists them, with their names, types and
sizes. **The bytes are not in the payload** — a webhook carries ids, never
bodies, which is the same rule everywhere in this API.

Ask for a link when you want the file:

```bash
curl https://pidgeon.ai/api/v1/attachments/att_7c2a \
-H "Authorization: Bearer $PIDGEON_API_KEY"
```

```ts
const { data } = await attachmentsDownloadUrl({ path: { id: 'att_7c2a' } });
```

```python
from pidgeon.api.attachments import attachments_download_url

link = attachments_download_url.sync(client=client, id="att_7c2a")
```

**A signed URL rather than the bytes.** Streaming a 25 MB file through the API
would pay for the transfer twice and gain the caller nothing — the link goes
straight to storage, and expires.

It is short-lived on purpose. Fetch it when you are about to use it rather
than storing it: a URL that outlived its usefulness in somebody's database is
a file leaking to whoever finds the row.

## Reading one as text

A CSV, a log, JSON or a calendar file can come back as text instead, for an
agent that needs to read what it was sent rather than download it:

```bash
curl "https://pidgeon.ai/api/v1/attachments/att_7c2a/text?body=clean" \
-H "Authorization: Bearer $PIDGEON_API_KEY"
```

```ts
const { data } = await attachmentsText({
path: { id: 'att_7c2a' },
query: { body: 'clean' },
});
```

```python
from pidgeon.api.attachments import attachments_text

text = attachments_text.sync(client=client, id="att_7c2a")
```

**The file is screened like the message it came with.** An attachment is mail
a model reads, so `firewall` says whether the text tries to steer the agent,
judged with the message's sender, and `body=clean` returns it with invisible
characters removed and lines posing as a chat turn marked — see
[Pidgeon Firewall](https://pidgeon.ai/docs/firewall.md). The default is `original`, the text as it
arrived; [the MCP server](https://pidgeon.ai/docs/mcp.md) asks for `clean`.

Up to 5 MB, of which the first 200,000 characters come back, with
`truncated: true` when there was more. HTML, PDF, Office files and images are
refused with a `validation` error; ask for the link above for those.
