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.

{ "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.

{
  "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.

{ "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):

{ "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:

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

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".

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

2a. Find new inbound messages.

{ "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.

{ "name": "read_mailbox", "arguments": { "action": "getThread", "threadId": "thr_…" } }

2c. Reply. A reply is a send from the inbox that received the message, back to the inbound from.address, with replyToMessageId set to the message you answer: Dairo sets the reply headers from it and files the reply in the same thread. 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.

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

A replyToMessageId you cannot see is a 404. To answer mail that is not in Dairo, pass its raw inReplyTo Message-ID (and optionally references) instead.

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).

{
  "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):

{ "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.

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

Send to yourself:

{ "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.

{ "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.

{
  "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:

{
  "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:

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

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.

Recipe 5 — Interactive Telegram: buttons, tap, edit

On a Telegram-channel 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).

{
  "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), fires the message.received webhook, and is recorded in the event stream as message.button.tapped. Read it with the mailbox tool, or react to the webhook. Match sourceMessageId to your 5a send and branch on buttonCallback:

{ "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.

{
  "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.

{ "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.

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

See the Telegram interactivity guide 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:

{ "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):

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

The response is a compact summary: id, name, status, variables, updatedAt and htmlBytes. It does not echo the HTML back, and neither does updateTemplate. Use getTemplate when you need the HTML. previewTemplate returns failedChecks and checks next to valid, so a failed preview names the check to fix.

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:

{
  "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):

{ "name": "list_letters", "arguments": { "action": "price", "to": { "country": "CH" }, "pageCount": 1 } }
{
  "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. Recipients who complained or hard-bounced are blocked by default; override only with ignoreComplaints: true for a deliberate contact. A listComplaints hit means do not contact again; a permanent bounce means the address is dead.
  • 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 takes contactId, channel (email or a2a), replyToMessageId (or the raw inReplyTo / references for mail outside Dairo), replyTo, headers {}, and tags {}. headers is screened server-side — protected names such as From, To, Subject, Reply-To, Message-Id, DKIM, Content-*, and X-Dairo-* are rejected. 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. The design patterns these recipes rest on are in Why Dairo is agent-native. The per-action scope each tool call needs is covered in Permissions & scopes. For why a deterministic key is the reply-safety primitive, read Retries & idempotency.

On this page