> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dairo.app/llms.txt
> Use this file to discover all available pages before exploring further.

# MCP recipes

> Copy-paste tool-call JSON for the flows an agent runs on Dairo — send, reply, resolve a contact, attach a file, drive Telegram.

Copy-paste `tools/call` arguments for the flows an autonomous agent actually runs.
Dairo's MCP surface is split per domain into read and write tools (`list_inboxes`
and `manage_inboxes`, `read_mailbox` and `delete_messages`); a few primitives stay
standalone (`send_message`, `verify_message`, `search_docs`).

Each block below is `{ "name": <tool>, "arguments": {...} }` — the shape a
`tools/call` takes.

## Recipe 1 — Send from an inbox, then confirm delivery

**1a. List inboxes and pick one.** Each inbox carries an `id` (pass it as
`inboxId` when you send), an `address`, and a `status`.

```json theme={null}
{ "name": "list_inboxes", "arguments": { "action": "list" } }
```

**1b. Send.** An immediate send is synchronous: you get `status: "sent"` with a
`providerMessageId`, or a precise error at submit time — never a false "queued"
that fails later.

```json theme={null}
{
  "name": "send_message",
  "arguments": {
    "inboxId": "inbox_123",
    "to": ["dest@example.com"],
    "subject": "Quarterly numbers",
    "html": "<p>Attached below.</p>",
    "idempotencyKey": "q3-report-2026-07-02-dest"
  }
}
```

Response: `{ "id": "msg_…", "status": "sent", "providerMessageId": "…", "channel": "email", "warnings": [] }`.
A send where every recipient is a Dairo inbox is delivered `status: "sent"` over
`channel: "a2a"` with a provenance receipt instead.

**1c. Diagnose** — the one call to reach for when a send misbehaves. Read-only,
`messages:read`, and A2A-aware.

```json theme={null}
{ "name": "list_sent_messages", "arguments": { "action": "diagnose", "messageId": "msg_…" } }
```

Returns `renderStatus`, `providerStatus`, `domainVerification`,
`recipientEligibility`, `deliveryEvents`, and a precise `nextAction`.

**1d. Fetch delivery events.** Two surfaces — reach for the right one.

Per-message timeline (`messages:read`):

```json theme={null}
{ "name": "list_sent_messages", "arguments": { "action": "listEvents", "messageId": "msg_…" } }
```

Durable, gap-detectable ledger (`events:read`) — filter by the same
`idempotencyKey` you sent with to correlate the send to its events:

```json theme={null}
{
  "name": "list_events",
  "arguments": { "action": "list", "idempotencyKey": "q3-report-2026-07-02-dest", "order": "newest" }
}
```

<Note>
  The ledger defaults to oldest-first — it is a forward catch-up and replay stream.
  Pass `order: "newest"` to see the most recent event on page 1. On an unfiltered
  page, `gaps: []` is the healthy signal (no lost events in the window), not
  "detection skipped".
</Note>

## Recipe 2 — Receive a message, fetch the thread, reply idempotently

**2a. Find new inbound messages.**

```json theme={null}
{ "name": "read_mailbox", "arguments": { "action": "listMessages", "direction": "inbound", "limit": 20 } }
```

**2b. Fetch the full thread** — the thread object plus `messages[]`, each with
`from.address`, `subject`, `direction`, and `textBody`.

```json theme={null}
{ "name": "read_mailbox", "arguments": { "action": "getThread", "threadId": "thr_…" } }
```

**2c. Reply.** A reply is a new send from the inbox that received the message, back
to the inbound `from.address`. Make it safe against retries with a deterministic
`idempotencyKey` derived from the inbound message id — re-running the call returns
the original send verbatim instead of double-replying.

```json theme={null}
{
  "name": "send_message",
  "arguments": {
    "inboxId": "inbox_123",
    "to": ["sender@example.com"],
    "subject": "Re: your question",
    "text": "Thanks — here's the answer.",
    "idempotencyKey": "reply-to-msg_9f31c4"
  }
}
```

<Warning>
  Threading is not first-class. `send_message` has no `threadId` or `inReplyTo`
  parameter, and the inbound payload does not expose an RFC `Message-Id`. Dairo
  starts a new thread for the outbound send; it does not auto-join the inbound
  thread. For email you may set `In-Reply-To` and `References` yourself through the
  passthrough `headers` field (both are allowed) so the recipient's mail client
  threads it — the A2A path ignores those headers. Treat idempotency, not threading,
  as the safety primitive here.
</Warning>

## Recipe 3 — Resolve a contact and send to it

Contacts are a channel-agnostic address book: one identity, many handles, plus a
free-text `info` note.

**3a. Create a contact** (mutation, so `confirm: true`, scope `contacts:write`).

```json theme={null}
{
  "name": "manage_contacts",
  "arguments": {
    "action": "create",
    "displayName": "Ada Lovelace",
    "alias": "ada",
    "kind": "person",
    "info": "Prefers plain-text replies.",
    "handles": [{ "channel": "email", "value": "ada@example.com", "isPrimary": true }],
    "confirm": true
  }
}
```

**3b. Resolve** (use `contactId: "me"` for the project self-contact):

```json theme={null}
{ "name": "list_contacts", "arguments": { "action": "get", "contactId": "ctc_…" } }
```

**3c. Send to it.** In `to[]` use `"@alias"`, `"contact:<uuid>"`, or `"@me"` — Dairo
resolves the contact's primary handle for the sending inbox's channel
automatically. `send_message` also takes a top-level `contactId` field that does
the same resolution.

```json theme={null}
{
  "name": "send_message",
  "arguments": {
    "inboxId": "inbox_123",
    "to": ["@ada"],
    "subject": "Welcome",
    "text": "Hi Ada."
  }
}
```

Send to yourself:

```json theme={null}
{ "name": "send_message", "arguments": { "inboxId": "inbox_123", "to": ["@me"], "subject": "Note to self", "text": "…" } }
```

**3d. Read a contact's full history** (`messages` action, `contacts:read`) — every
inbound and outbound message with this contact across all channels, merged
newest-first, so an agent can catch up on a relationship before it replies. Each
item carries its `channel` and `direction`; page with `limit` and `cursor`.

```json theme={null}
{ "name": "list_contacts", "arguments": { "action": "messages", "contactId": "ctc_…", "limit": 20 } }
```

## Recipe 4 — Send with an attachment

Each attachment is an object, and each item is exactly one of `contentBase64`
(inline) or `objectId` (storage reference) — never both, never neither. Up to 10
attachments.

**4a. Inline** — small files only (≤ 8 MiB decoded per attachment and ≤ 8 MiB total
inline). `filename` is required.

```json theme={null}
{
  "name": "send_message",
  "arguments": {
    "inboxId": "inbox_123",
    "to": ["dest@example.com"],
    "subject": "Invoice",
    "text": "Invoice attached.",
    "attachments": [
      { "filename": "invoice.pdf", "contentType": "application/pdf", "contentBase64": "JVBERi0xLjcK…" }
    ]
  }
}
```

**4b. By `objectId`** — the way to attach anything larger (native-attached up to
24 MiB, fetched server-side, ownership-checked). First upload:

```json theme={null}
{
  "name": "manage_storage",
  "arguments": { "action": "createUploadLink", "bucketId": "buk_…", "filename": "deck.pdf", "contentType": "application/pdf", "confirm": true }
}
```

The response gives `{ "objectId": "obj_…", "uploadUrl": "…", "method": "PUT", "headers": {…}, "expiresInSeconds": … }`.
`PUT` the raw bytes to `uploadUrl` with exactly those headers yourself. To confirm
the object finalized before you attach it, poll the read tool `list_storage` with
`{ "action": "getUploadStatus", "bucketId": "buk_…", "objectId": "obj_…" }`. Then
attach by id — `filename` and `contentType` are derived from the object unless you
override them:

```json theme={null}
{
  "name": "send_message",
  "arguments": {
    "inboxId": "inbox_123",
    "to": ["dest@example.com"],
    "subject": "The deck",
    "text": "See attached.",
    "attachments": [ { "objectId": "obj_…" } ]
  }
}
```

<Warning>
  Keep an A2A send on A2A. A send to Dairo-inbox recipients stays on the internal
  A2A channel (delivered `"sent"`, with a receipt) only if every attachment is an
  `objectId` reference. A single inline `contentBase64` attachment forces the whole
  send over email. Upload first and attach by `objectId` to preserve A2A.
</Warning>

## Recipe 5 — Interactive Telegram: buttons, tap, edit

On a [Telegram-channel](/channels/telegram) inbox, `send_message` takes a `buttons`
grid, taps come back as inbound events, and `manage_sent_messages` edits or reacts
in place.

**5a. Send with an inline keyboard.** `buttons` is a 2-D array (rows of buttons);
each button is exactly one of `url` (opens a link) or `callback` (surfaces the
tap).

```json theme={null}
{
  "name": "send_message",
  "arguments": {
    "inboxId": "inbox_123",
    "text": "Deploy #42 is ready to ship — approve?",
    "buttons": [
      [ { "text": "Approve", "callback": "approve:deploy-42" },
        { "text": "Reject",  "callback": "reject:deploy-42" } ],
      [ { "text": "View diff", "url": "https://dairo.app/deploys/42" } ]
    ]
  }
}
```

**5b. Receive the tap.** A callback tap lands as a normal inbound message
(`subject: "Button tapped: Approve"`, `textBody` set to your `callback` value) and
fires a `message.button.tapped` webhook. Read it with the mailbox tool, or
subscribe to the event. Match `sourceMessageId` to your 5a send and branch on
`buttonCallback`:

```json theme={null}
{ "name": "read_mailbox", "arguments": { "action": "listMessages", "inboxId": "inbox_123", "direction": "inbound", "limit": 20 } }
```

**5c. Edit the message to its outcome.** The body is exactly one of `text` or
`html`. Omit `buttons` to keep the current keyboard, pass `[]` to clear it, or pass
a new grid to replace it. Clearing the keyboard on completion stops a second tap.

```json theme={null}
{
  "name": "manage_sent_messages",
  "arguments": {
    "action": "edit",
    "messageId": "msg_the_5a_send",
    "text": "Deploy #42 shipped — approved by @alice.",
    "buttons": []
  }
}
```

**5d. React (optional).** Acknowledge a message you sent or received without a
reply. `emoji` must be a Telegram-allowed reaction; `unreact` clears it.

```json theme={null}
{ "name": "manage_sent_messages", "arguments": { "action": "react", "messageId": "msg_…", "emoji": "👍", "big": true } }
```

**5e. Message another bot or channel.** Put an `@username` in `to` to send by
username instead of the bound chat.

```json theme={null}
{ "name": "send_message", "arguments": { "inboxId": "inbox_123", "to": ["@partner_bot"], "text": "handoff: ticket-8842" } }
```

See the [Telegram interactivity guide](/agent-first/telegram-interactivity) for the
full tap-event shape and grid limits.

## Recipe 6 — Send a compliant physical letter (no postal knowledge needed)

Physical mail has a layout contract (address window, keep-out zones) that the
platform teaches you — never guess it.

**6a. Get the spec + starter templates.** One call returns the full layout
contract in mm plus compliant, ready-to-store letter HTML:

```json theme={null}
{ "name": "prepare_letters", "arguments": { "action": "requirements" } }
```

**6b. Store a starter template.** Take a `starterTemplates[].html` from the
response verbatim (or your own HTML that keeps the documented zones clear):

```json theme={null}
{
  "name": "prepare_letters",
  "arguments": {
    "action": "createTemplate",
    "name": "Plain letter",
    "html": "<the starterTemplates[0].html string>",
    "variables": ["subject", "body"],
    "confirm": true
  }
}
```

**6c. Verify the exact letter you are about to send.** The `templateId` source
renders with your real recipient and checks the result — pass `to` so the
printed address is matched against the intended recipient:

```json theme={null}
{
  "name": "prepare_letters",
  "arguments": {
    "action": "verify",
    "templateId": "ltpl_123",
    "templateData": { "subject": "Contract 4711", "body": "Dear Sir or Madam,\n\n..." },
    "to": { "name": "Jane Doe", "street": "Hauptstrasse", "houseNumber": "12", "postalCode": "8001", "city": "Zürich", "country": "CH" }
  }
}
```

Response: `{ "valid": true, "checks": [...] }`. Fix every `fail` before
sending; a bring-your-own PDF (`pdfBase64`) verifies the same way.

**6d. Price, then send.** Same source shapes; `dryRun: true` on `send` is a
full no-send rehearsal (validated + priced, never mailed, never charged):

```json theme={null}
{ "name": "list_letters", "arguments": { "action": "price", "to": { "country": "CH" }, "pages": 1 } }
```

```json theme={null}
{
  "name": "send_letters",
  "arguments": {
    "action": "send",
    "templateId": "ltpl_123",
    "templateData": { "subject": "Contract 4711", "body": "Dear Sir or Madam,\n\n..." },
    "fileName": "termination-4711.pdf",
    "to": { "name": "Jane Doe", "street": "Hauptstrasse", "houseNumber": "12", "postalCode": "8001", "city": "Zürich", "country": "CH" },
    "idempotencyKey": "termination-4711",
    "confirm": true
  }
}
```

Track it with `list_letters { "action": "get", "letterId": "let_…" }` — the
status runs queued → submitted → in\_transit → delivered.

## Sharp edges

Things an agent gets wrong without being told:

* **Body is a strict one-of.** Provide exactly one of `text`, `html`, `react`, or
  `template`. Two or more is a `400` — `"Provide exactly one body source: one of
  text, html, react, or template"`. `text` + `html` together is rejected; Dairo
  does not build a multipart alternative. A send with no body is allowed only when
  it carries an attachment or a Telegram payload (a caption-less file or a poll).
* **`template`, not `templateId`.** Send from a stored template with
  `template: { id, version?, variables }`. A template that renders to an empty body
  fails synchronously (`422 template_render_empty`, naming the id, version, and
  variables) before any handoff — not as a silent blank email.
* **Attachment caps and shape.** One object per item; exactly one of `contentBase64`
  (inline, requires `filename`, ≤ 8 MiB each and ≤ 8 MiB total across all inline
  attachments) or `objectId` (≤ 24 MiB). Two 8 MiB inline attachments fail the
  total cap even though each is within the per-item cap. Over cap → `413`. Both or
  neither set → `400`. `delivery: "link"` → `400`; Dairo never auto-edits your body
  to insert a share link.
* **Inline attachment breaks A2A.** See Recipe 4 — any inline bytes force the send
  over email.
* **Idempotency returns the original, never re-sends.** Reusing a key returns the
  first send for that key verbatim. Reusing it with a different `subject` or `to`
  still returns the original, plus a `warnings[]` entry
  `reason: "idempotency_key_reused_with_different_params"` — use a fresh key for
  different content. Keys are ≤ 128 characters.
* **Confirmation gate on mutations.** Most mutating write-tool actions require
  `confirm: true` (`manage_inboxes` create/delete/setSchema, `manage_contacts`
  create/update/addHandle, `replay_event`, `delete_messages`,
  `manage_sent_messages` cancelScheduled, all `manage_storage` writes,
  `manage_budgets` set/delete, and the rest). The Telegram edits are the exception:
  `manage_sent_messages` `edit`, `react`, and `unreact` take no `confirm`.
  `send_message` does not take `confirm` either — it is gated by the `messages:send`
  scope.
* **Suppression safety.** Complained recipients are blocked by default; override
  only with `ignoreComplaints: true` for a deliberate contact. A `listComplaints`
  hit means do not contact again. A bounce means verify the address before
  retrying.
* **Scope is per action, not per tool.** Reads use `…:read`, writes use `…:write`;
  `send_message` needs `messages:send`. A key that lacks the scope gets a `403`.
* **Recipient limits and resolution.** `to + cc + bcc` ≤ 50. A UUID-shaped
  recipient resolves to that inbox's address, org-scoped — another tenant's UUID
  will not resolve. Contact refs (`@alias`, `contact:<uuid>`, `@me`) resolve to the
  primary handle for the sending inbox's channel.
* **`channel: "a2a"` cannot be scheduled.** A2A delivers synchronously, so pairing
  it with `sendAt` is a `400` — omit one. When `channel` is omitted, Dairo
  auto-classifies: A2A when every recipient is a Dairo inbox, otherwise email. A
  Telegram-channel inbox always delivers over Telegram regardless.
* **Extra send fields.** Beyond the body and recipients, `send_message` advertises
  `contactId`, `channel` (`email` or `a2a`), `replyTo`, `headers {}`, and
  `tags {}`, and its top-level schema is `additionalProperties: true`, so unknown
  extras are tolerated. `headers` is screened by a server-side denylist — `From`,
  `To`, `Subject`, `Reply-To`, `Message-Id`, DKIM, `Content-*`, and `X-Dairo-*` are
  rejected, while any other custom header (including `In-Reply-To` and `References`)
  passes through. `replyTo` is validated as a single email address.
* **Telegram buttons and edits.** `buttons` is a 2-D grid (≤ 8 rows × ≤ 8 per row);
  each button is exactly one of `url` (`http`, `https`, or `tg` only) or `callback`
  (≤ 200 characters — Dairo stores it and hands it back on the tap, so it never
  hits Telegram's 64-byte `callback_data` cap). `manage_sent_messages { action:
  "edit" }` edits only Telegram messages you sent (else `404`); on an edit, omitting
  `buttons` preserves the keyboard, `[]` clears it. `react` needs a Telegram-allowed
  emoji (a custom or premium one is a `400`). Each tap is its own inbound row, plus
  a `message.button.tapped` event.
* **MCP never streams raw bytes.** Attachment actions return metadata and URLs
  only. To read an inbound file, call `read_mailbox { "action": "getAttachmentUrl" }`
  (or `"downloadAttachment"`) and fetch the returned URL yourself.

## Next steps

Connect a client and see the full tool surface in the
[MCP server](/agent-first/mcp-server). The design patterns these recipes rest on
are in [Why Dairo is agent-native](/agent-first/agent-patterns). The per-action
scope each tool call needs is covered in [Permissions & scopes](/concepts/scopes).
For why a deterministic key is the reply-safety primitive, read
[Retries & idempotency](/concepts/idempotency).
