# AI builder guides

Bird's API surface is agent-shaped — one operation per tool, JSON in and out, machine-checkable outcomes — but a reliable agent still needs the right patterns around it. The five below cover the failure modes that actually break agent integrations: treating acceptance as delivery, retrying blind, and parsing prose instead of structure. Each pattern works the same whether your agent drives the [MCP server](/docs/ai/mcp-server) or the [`bird` CLI](/docs/ai/cli-for-agents).

## Pattern 1: Loop one operation at a time

Bird's tools are deliberately granular — send a message, get a message, list domains, create a webhook endpoint — and every one returns structured JSON whose fields the next step can check. Build the loop so each step's exit condition is read from the previous step's output, never inferred:

```text
loop:
  result = run_tool(next_operation)        # one operation per call
  if result.ok: advance using result.data  # e.g. the em_… id, the verified domain
  else: branch on the failure category     # see Pattern 4
```

With the CLI, the failure category is the exit code (`2` invalid input, `3` not found, `4` auth, `1` other), so the branch needs no message parsing:

```bash
bird email get "$id" --format json > msg.json
case $? in
  0) jq .status msg.json ;;     # advance
  3) echo "wrong id — fix, don't retry" ;;
  4) bird auth login ;;          # recover, then re-run
esac
```

The granularity is the point: an agent that can check state between steps recovers from any single failure; an agent driving one mega-operation can only start over.

## Pattern 2: A send returns 202 — the outcome arrives later

`POST` a send and you get `202 Accepted` with a message ID. **Accepted means Bird took the message, not that it was delivered** — the final outcome never arrives inline. It arrives as [webhook events](/docs/guides/webhooks): `email.delivered` when the recipient's server accepts it, `email.bounced` when delivery fails permanently, `email.complained`, and so on.

An agent that declares success at `202` will silently miss every bounce. Structure the task as send-then-await instead:

```text
send → 202 + em_… id            # record the id, do NOT report "sent successfully"
await webhook event where data.email_id == em_… id:
  email.delivered → done
  email.bounced   → report failure with bounce_type / bounce_description
```

Correlate on `email_id` — webhook payloads echo your tags and metadata alongside the identity fields, so your own context comes back without an extra lookup, and remember deliveries are at-least-once and unordered — deduplicate on the `webhook-id` header and sort by the payload `timestamp`. If your agent has no webhook receiver, poll the message with `GET` (or `bird email get`) until its status resolves — slower, but the same rule applies: the read-back is the source of truth, the `202` is not.

## Pattern 3: Use the sandbox as your test harness

While developing the loop, don't send real mail — send to the [mail sandbox](/docs/guides/email/testing-sandbox)'s magic addresses on `messagebird.dev`. The outcome is determined by the address (`delivered@` always delivers, `bounce@` always hard-bounces, `complaint@` always complains), but everything else is the real production pipeline: the same `202`, the same event sequence, the same signed webhook deliveries, with no flag marking it as a test.

```text
for address in [delivered@, bounce@, suppressed@] @messagebird.dev:
  send to address+run42@…                  # +label correlates the test case
  assert the expected terminal event arrives (delivered / bounced / rejected)
```

That makes the sandbox the ideal agent test harness: deterministic outcomes to assert against, zero reputation risk, no suppression-list writes, and reusable addresses across every run. An agent that passes the sandbox matrix has exercised its entire Pattern 2 path — send, await, branch — before touching a real inbox.

## Pattern 4: Recover against the standard error envelope

Every Bird API error has the same shape, so error recovery is one code path, not one per endpoint:

```json
{
  "error": {
    "type": "invalid_request",
    "code": "E01002",
    "name": "InvalidRequest",
    "message": "from address is not on a verified sending domain",
    "doc_url": "https://bird.com/docs/api/errors/E01002",
    "request_id": "req_01krdgeqcxet5s7t44vh8rt9mg"
  }
}
```

Each field has a job in the loop: branch on **`type`/`code`** (stable, machine-readable), show **`message`** to the human, fetch **`doc_url`** when the agent needs the docs page for that exact error — it resolves to Markdown the agent can read — and log **`request_id`** so a human can hand it to Bird support. The decisive split is retryable versus not:

```text
4xx (except 429) → a request bug: fix the input, never retry as-is
429              → back off, then retry (Pattern 5)
5xx / timeout    → retry with the same Idempotency-Key (Pattern 5)
```

The full code catalog lives on the [errors page](/docs/guides/errors). With the CLI, the envelope arrives on stderr and the exit code pre-classifies it (`2`/`3`/`4`/`1` — see Pattern 1), so a shell-driving agent can branch before parsing anything.

## Pattern 5: Retry safely — Idempotency-Key, and Retry-After on 429

Retries are where agents do damage: a timeout on a send, a blind retry, and the customer gets two emails. Bird's [idempotency](/docs/guides/idempotency) support makes the retry safe — generate one `Idempotency-Key` per logical operation (not per attempt) and reuse it on every retry:

```text
key = uuid()                                  # once per logical send
attempt with Idempotency-Key: key
on 5xx / timeout: backoff, retry with SAME key
on 2xx with Idempotency-Replay: true → the first attempt had succeeded; do not treat as a new send
```

The `Idempotency-Replay: true` response header marks a replay of the original response, so your agent can log "recovered" instead of "sent twice". The Bird SDKs inject a key automatically on every mutating request, so SDK-based agents get this for free; with the CLI, pass `--idempotency-key` on mutations that might be retried.

Rate limiting gets the opposite treatment: a `429` means slow down, not try harder. The response carries a `Retry-After` header — honor it as the floor of your backoff rather than inventing your own schedule:

```text
on 429: sleep max(Retry-After, backoff(attempt)); retry with the same key
```

Never retry other `4xx` responses unchanged — they're cached and replayed by idempotency precisely because the same request will always produce the same error. Fix the request (Pattern 4) and use a **new** key, since reusing a key with a different body returns `409 IdempotencyKeyReuse`.

## Next steps

- [MCP server](/docs/ai/mcp-server) — the tool surface these patterns drive, hosted at `mcp.bird.com` or run locally with the CLI
- [CLI for agents](/docs/ai/cli-for-agents) — the same operations for shell-capable agents
- [Webhooks & events](/docs/guides/webhooks) — delivery semantics, signatures, and the event catalog behind Pattern 2
- [Idempotency](/docs/guides/idempotency) — replay semantics and failure modes behind Pattern 5
- [Errors](/docs/guides/errors) — the envelope and the full error-code catalog
- [Mail sandbox](/docs/guides/email/testing-sandbox) — the magic-address matrix behind Pattern 3