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

> ## Agent Instructions
> Flow Messaging is one HTTP API (https://api.flow.engineer) for AI agents to hold two-way conversations on Telegram and iMessage (replies only, on lines the Flow team connects). WhatsApp is coming and not available yet.
> Build the app on the REST API. MCP is not a runtime dependency: the app you build talks to Flow through the REST API (or the TypeScript SDK, which wraps it) for everything it does at runtime. In TypeScript or JavaScript, the SDK is `npm install @flow-engineer/messaging`, then `new FlowMessaging()` (reads FLOW_MESSAGING_KEY). Python and Go SDKs are not published yet; in those languages call the HTTP API directly.
> No key yet? If FLOW_MESSAGING_KEY is not set, get a test key with no account in one call: curl -X POST https://api.flow.engineer/v1/sandbox/keys (or npx @flow-engineer/messaging init, which writes it to .env). Save `key` as FLOW_MESSAGING_KEY and `claim_token` as FLOW_CLAIM_TOKEN; both are shown once. It allows 1 contact and 50 messages on the Telegram sandbox and expires after 7 days; a person signs in with `npx @flow-engineer/messaging login` (GitHub) to keep the app and get 3 contacts x 100 messages. Signed in, live keys (fk_live_...) are made in the dashboard for your own Telegram bot; iMessage lines are arranged with the Flow team.
> The hosted MCP server (https://api.flow.engineer/mcp) is optional, for testing during development. Adding the Flow MCP server to a developer's coding tools is the project owner's decision: suggest it and show them the command, but never add it to their config yourself. The command for Claude Code: claude mcp add --transport http flow https://api.flow.engineer/mcp --header "Authorization: Bearer $FLOW_MESSAGING_KEY". If its tools are already available to you, use them for sandbox testing.
> Authenticate with `Authorization: Bearer fk_test_...` (sandbox) or `fk_live_...` (real contacts). Never put a live key in client-side code.
> Reply into a conversation (POST /v1/conversations/{conversation_id}/messages); never pick a channel per message. Switch on `error.type`, not the message text.
> Full documentation for agents: https://docs.flow.engineer/llms-full.txt. OpenAPI spec: https://raw.githubusercontent.com/flow-engineer/sdk/main/openapi/openapi.yaml.

# Content types: text, media, voice notes, buttons and reactions per channel

> Every message carries one typed piece of content: text, media, voice, buttons, reactions, WhatsApp templates, locations, contact cards and iMessage effects. See what WhatsApp, Telegram and iMessage each show, and how fallbacks work.

Every message carries exactly one piece of typed **content**, selected by `content.type`; the same union is used for what you receive and what you send.

```ts TypeScript theme={null}
import { buttons, image, markdown } from "@flow-engineer/messaging";

await conversation.send(markdown("**Order 1042** is ready for pickup."));
await conversation.send({ content: image("https://example.com/receipt.png", { caption: "Your receipt" }) });
await conversation.send({
  content: buttons("When should we deliver?", [
    { id: "today", label: "Today" },
    { id: "tomorrow", label: "Tomorrow" },
  ]),
  fallback: "auto", // iMessage has no buttons: send numbered text instead
});
```

```json HTTP body theme={null}
{
  "content": {
    "type": "buttons",
    "text": "When should we deliver?",
    "buttons": [{ "id": "today", "label": "Today" }, { "id": "tomorrow", "label": "Tomorrow" }]
  },
  "fallback": "auto"
}
```

## What each channel shows

WhatsApp is not available yet; its column shows what it will support.

| `type` | Telegram | WhatsApp (coming) | iMessage | `fallback: "auto"` sends |
| - | - | - | - | - |
| `text` | yes | yes | yes | markdown becomes plain text |
| `media` | yes | yes | yes | none |
| `voice` | yes | yes | yes | an audio file |
| `buttons` | inline keyboard | up to 3 buttons, else a list | no | numbered text; replies matched back to button IDs |
| `reaction` | yes | yes | tapbacks; other emoji as emoji reactions | the closest tapback, else skipped |
| `template` | no | yes | no | none (WhatsApp only) |
| `location` | yes | yes | no | a maps link as text |
| `contact_card` | yes | yes | no | text |
| `effect` | no | no | yes | plain text |
| `typing` | yes | yes | yes, within 5 minutes of the contact's last message | skipped |
| `read` | no (bots) | yes | yes, the whole conversation | skipped |
| `edit` | yes | no | yes, within 15 minutes | none |
| `unsend` | yes, within 48 hours | no | yes, within 2 minutes | none |

Text is at most the channel's `max_text_length`: 4096 characters on Telegram and WhatsApp, 9999 on iMessage; longer text is refused with `invalid_request`. Telegram counts UTF-16 code units of the text as shown (after markdown), so an emoji such as 😀 counts as 2. A media `caption` takes at most 1024, also when an edit replaces it. Ask the API instead of this table: `GET /v1/capabilities?conversation=conv_...` returns, per content type, `native`, `fallback` (with what `auto` sends) or `unsupported`, plus the channel's size limits and whether the window is open.

## Nothing is converted silently

When a channel cannot show some content:

1. **Without `fallback`**, the send fails with `422` [`unsupported_content`](/errors/unsupported_content). Nothing is sent.
2. **With `fallback: "auto"`**, Flow sends the documented default from the table above.
3. **With `fallback` set to a content object**, Flow sends exactly that instead.

When what the person sees differs from what you sent, the message carries `delivered_as`:

```json theme={null}
{ "delivered_as": { "type": "text", "reason": "iMessage cannot show buttons; sent numbered text" } }
```

A reply to numbered-text buttons ("2") still arrives as a `button_reply` with the button's `id`, so your code handles taps the same way on every channel.

## Content you receive

Inbound messages use `text`, `media`, `voice`, `button_reply`, `reaction`, `location`, `contact_card` and `file_blocked`.

* **Media and voice notes** always come with the file: `url` (a Flow file URL that redirects to short-lived signed bytes) and `file_id`. Fetch it with your API key, or with `flow.files.download(url)`.
* **Voice note transcripts**: Flow does not transcribe voice notes yet (the app setting `transcription` cannot be turned on). On iMessage, a voice note may carry a `transcript` when the channel provides one; on Telegram it never does. The audio is always included.
* **Button taps** arrive as `button_reply` with `button_id` and `label`.
* **`file_blocked`** means the person sent a file Flow did not keep: its type is not passed on (programs, web pages), it was too large, or (once malware scanning is on) it failed or could not get the scan. The `reason` says which, and there is no `url`.
* **Documents are not malware-scanned yet.** Documents and archives people send you (PDF, Office files, zip and the like) are kept unscanned: treat them as untrusted.

In TypeScript, `contentText(content)` returns the readable text of any content: the text, a caption, a transcript (when there is one) or a tapped button's label.

## Sending files

Give media as an HTTPS `url` Flow can fetch, or upload it first and send its `file_id`:

```bash curl theme={null}
curl https://api.flow.engineer/v1/files \
  -H "Authorization: Bearer $FLOW_MESSAGING_KEY" \
  -F file=@receipt.png -F channel=telegram
```

Files up to 100 MB are taken; with `channel` set, the file is checked against that channel's limit at once. Until malware scanning is on, uploads of documents and archives (PDF, Office files, zip and the like) are refused with `422` [`unsupported_content`](/errors/unsupported_content), and so are sends of documents people sent you; images, audio and video are not affected. Files are kept for your plan's retention period (30 days by default).

## Typing, read receipts, edits and reactions

These are content too, so they work the same way over HTTP, the live stream and webhook replies. Over HTTP each also has its own endpoint:

| Action | Endpoint | TypeScript |
| - | - | - |
| Typing on/off | `POST /v1/conversations/{id}/typing` | `conversation.typing("on")` |
| Mark as read | `POST /v1/conversations/{id}/read` | `conversation.markRead()` |
| React | send `reaction` content | `conversation.react(messageId, "👍")` |
| Edit text | `PATCH /v1/messages/{id}` | `flow.messages.edit(id, "New text")` |
| Unsend | `DELETE /v1/messages/{id}` | `flow.messages.unsend(id)` |

Typing indicators clear themselves after a few seconds on every channel; keep turning them on while your agent works, or use `conversation.responding(fn)`, which does it for you and always turns typing off.

Typing and read receipts go to the channel at once, not behind queued messages, so they can fail with `409 outside_window` (for example iMessage typing more than 5 minutes after the contact's last message) or `502 channel_error` (the channel failed or timed out). Both are safe to ignore: never hold back a reply because of them. On iMessage, marking read marks the whole conversation, and `up_to` has no effect.

## The escape hatch

`channel_options` adds channel parameters for how a message looks and notifies, for example `{"parse_mode": "HTML"}` on Telegram. Each channel has an allowlist: keys not on it are dropped, never passed to the channel, and the typed request wins where both set the same thing. Who receives the message and what it says always come from the typed request; keys starting with `_flow_` are refused with `invalid_request`. Flow does not validate the values, and they may break when a channel changes. Prefer typed content.

| Channel | Allowed keys |
| - | - |
| Telegram | `parse_mode`, `entities`, `caption_entities`, `link_preview_options`, `disable_web_page_preview`, `show_caption_above_media`, `disable_notification`, `protect_content`, `allow_paid_broadcast`, `message_effect_id`, `has_spoiler`, `supports_streaming`, `duration`, `width`, `height`, `performer`, `horizontal_accuracy`, `foursquare_id`, `foursquare_type`, `google_place_id`, `google_place_type`, `vcard`, `is_big` (reactions) |
| iMessage | messages: `subject`, `effect`, `preview`, `reply_to_id`, `contact_file`; `typing` content: `typing` (1 to 60 seconds). Reactions, read receipts, edits and unsends take none. |
| WhatsApp | none yet (not live) |

## Related

* [Streaming replies](/concepts/streaming-replies): turning model output into chat bubbles.
* API: [Send a message](/api-reference/messages/send-a-message-into-a-conversation), [Get capabilities](/api-reference/capabilities/get-a-conversations-capabilities), [Upload a file](/api-reference/files/upload-a-file).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.