Skip to content

Ask the docs

Answers come only from these docs and are written by an AI model (Gemini), so check the linked pages. Your question is not kept. Do not include personal details or keys.

Build with a coding agent

For coding agents · add WhatsApp to AI agent · Claude Code WhatsApp · Codex WhatsApp integration · MCP server WhatsApp Telegram iMessage · AI agent SMS iMessage WhatsApp SDK · messaging MCP server

Give your coding agent the setup, examples and checks for a Messaging API integration.

This page is written for AI coding agents: follow it step by step to give the user’s AI agent Telegram or iMessage with Flow Messaging.

What exists today: Telegram is live (the shared sandbox bot with a test key, the user’s own bot with a live key). iMessage is live on Flow’s shared line (free: the user’s first sign-in at flow.engineer, or the first key they make in the dashboard, starts the account’s trial, which opens it; npx @flow-engineer/flow setup starts the same trial; people join with the app’s join message, and it ignores group chats) and on dedicated lines the Flow team connects to the user’s app (one-to-one and group chats). iMessage lines reply after the person writes first; writing first is available on request for dedicated lines only. It is not in the test-key sandbox allowance. WhatsApp is live on the business’s own number: the user connects a number in their own WhatsApp Business Account with a live key (POST /v0/senders with "channel": "whatsapp", or the dashboard’s Senders page; see Going live), and the Flow team can also connect one; the shared WhatsApp sandbox is not open yet. SMS (US) is in pilot only, on numbers the Flow team registers; it is not generally available. If the user asks for WhatsApp, tell them they connect their own number themselves (the access token and app secret stay in their environment variables or the dashboard, never in a chat), and build and test on Telegram meanwhile: the code does not change per channel.

Paste this into Claude Code, Codex or Cursor to have it do the integration.

Add two-way Telegram messaging (and iMessage replies, if the Flow team has connected a line) to my AI agent using Flow Messaging. Follow https://docs.flow.engineer/unified-messaging-api/coding-agents.md exactly: use FLOW_MESSAGING_KEY if it is set, otherwise get a test key yourself with npx @flow-engineer/messaging init (or curl -X POST https://api.flow.engineer/v0/sandbox/keys), use the TypeScript SDK @flow-engineer/messaging (or the HTTP API at https://api.flow.engineer in other languages), receive message.received events, reply with my agent’s answer through event.conversation.reply(...), and verify it end to end in the sandbox before you finish. Build on the REST API; the Flow MCP server is an optional development tool that I add myself if I want it.

Open in Cursor

Not on Flow Messaging? To have a coding agent add Flow Observe to an agent that talks through another channel:

Paste this into Claude Code, Codex or Cursor to send your agent's traces to Flow Observe.

Send my chat agent’s turns to Flow Observe by following https://docs.flow.engineer/unified-messaging-api/observe/traces.md exactly. Use the trace key in FLOW_OBSERVE_TRACE_KEY (never invent one; if it is not set, stop and ask me). Add the OpenTelemetry SDK with an OTLP/HTTP exporter to https://flow.engineer/observe/v0/traces, wrap each turn (from the moment a person’s message arrives to the reply being sent) in one span, and set flow.conversation_id, flow.message_id, flow.channel, flow.user_message and flow.agent_reply, where flow.agent_reply is the text my code actually sent, after any post-processing or splitting. If my agent uses a framework, follow https://docs.flow.engineer/unified-messaging-api/observe/frameworks.md for it. Then run one synthetic turn and check that the exporter got 200 and no rejected spans. Use synthetic test data only.

Open in Cursor

First check for a key: if FLOW_MESSAGING_KEY is set (in the environment or .env), use it. Otherwise get a test key yourself, with no account, in one call. Do not invent a key, and do not get a second one when you already have one.

npx @flow-engineer/messaging init

With no key in the environment or .env, init gets one (POST /v0/sandbox/keys) without asking, then:

  • writes FLOW_MESSAGING_KEY and FLOW_CLAIM_TOKEN to .env and adds .env to .gitignore;
  • prints the sandbox link and join code, the allowance and the expiry;
  • with the user’s approval (they are the project owner, so leave the answer to them and never pass --yes yourself), installs the Claude Code skill in .claude/skills/flow-messaging/, adds a Flow Messaging section to AGENTS.md (read by Codex and other agents), and registers the MCP server flow for Claude Code (.mcp.json) and Codex (codex mcp add).

Flags: --yes (no questions; the owner’s choice, not an agent’s), --key fk_test_... (use a key you already have), --no-agent-files, --no-mcp, --no-codex, --dir <path>.

Without the CLI, the same key by HTTP:

curl -X POST https://api.flow.engineer/v0/sandbox/keys

Save key as FLOW_MESSAGING_KEY and claim_token as FLOW_CLAIM_TOKEN in .env (which must be in .gitignore); both are shown once. The answer also holds the app’s sandbox_join_code and the sandbox senders with their links; later, GET /v0/app and GET /v0/senders give them again.

This key has a sandbox allowance: 1 contact and 50 messages in total on the Telegram sandbox, and it expires after 7 days. Tell the user, and offer to sign them in to keep the app (3 contacts x 100 messages, no expiry): run npx @flow-engineer/messaging login --no-wait, give them the link and code it prints, and once they say they approved, run npx @flow-engineer/messaging login to collect the new key. Full details, the curl version and the errors: Keys and sign-in.

Then add the SDK to a TypeScript or JavaScript project:

npm install @flow-engineer/messaging

In Python, add the SDK with pip install flow-messaging (Python 3.10+; from flow_messaging import Flow, and Flow() reads FLOW_MESSAGING_KEY). The Go SDK is not published yet: in Go call the HTTP API directly, as each page’s Go examples show.

VariableRequiredWhat it is
FLOW_MESSAGING_KEYyesThe API key: fk_test_... while building, fk_live_... in production. Server side only.
FLOW_CLAIM_TOKENuntil signed inThe claim_token (fct_...) from POST /v0/sandbox/keys. npx @flow-engineer/messaging login uses it to claim the app, then removes it.
FLOW_MESSAGING_WEBHOOK_SECRETwith webhooksThe endpoint’s signing secret, whsec_..., returned once by POST /v0/webhook_endpoints (and by POST /v0/webhook_endpoints/{id}/rotate_secret when you rotate it).
FLOW_MESSAGING_BASE_URLnoDefaults to https://api.flow.engineer.

Never hard-code keys, never commit .env, and never put a key in browser or mobile code.

3. The MCP server (optional, for development)

Section titled “3. The MCP server (optional, for development)”

Build on the REST API; the MCP server is for development. MCP is not a runtime dependency: the app you build talks to Flow through the REST API (or the TypeScript or Python SDK, which wrap it) for everything it does at runtime. Never make the app call the MCP server.

The hosted MCP server at https://api.flow.engineer/mcp (Streamable HTTP, the same API key as a Bearer token) is an optional tool for testing and operating the integration while you build: with a test key it shows the sandbox join link, sends test messages, waits for events, reads webhook deliveries and replays events; with a live key it reads and answers conversations. 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. If its tools are already available to you, use them for sandbox testing.

To suggest it, show the project owner the line for their tool. The server is named flow and reads the key from FLOW_MESSAGING_KEY in their environment:

Claude Code:

claude mcp add --transport http flow https://api.flow.engineer/mcp --header "Authorization: Bearer $FLOW_MESSAGING_KEY"

Codex (~/.codex/config.toml):

[mcp_servers.flow]
url = "https://api.flow.engineer/mcp"
bearer_token_env_var = "FLOW_MESSAGING_KEY"

Cursor (~/.cursor/mcp.json):

{"mcpServers": {"flow": {"url": "https://api.flow.engineer/mcp", "headers": {"Authorization": "Bearer ${env:FLOW_MESSAGING_KEY}"}}}}

Two traps: set FLOW_MESSAGING_KEY in the shell before running the Claude Code command (the shell fills the key in once, when you run it; unset, the header is saved as Bearer with no key and every call answers 401), and for Codex or Cursor have it in the environment they start with. A server added while the client runs loads only after a restart (in Claude Code, a new session). Added with an empty key already? export FLOW_MESSAGING_KEY=fk_test_..., then claude mcp remove flow, run the add command again, and restart.

Test keys get sandbox_join, send_test_message, simulate_inbound, get_test_transcript, wait_for_event, list_events, get_webhook_deliveries, replay_event; live keys get send_message, reply, react, edit_message, unsend_message, typing, list_conversations, get_conversation_messages; both get whoami, explain_error. After a test send, wait for message.sent or message.failed, never message.delivered (Telegram never sends it).

npx @flow-engineer/messaging mcp is a stdio bridge to https://api.flow.engineer/mcp that reads FLOW_MESSAGING_KEY from the environment or .env, for clients that run MCP servers as commands. Full tool list: MCP server.

  • Claude Code: init installs the skill in .claude/skills/flow-messaging/. It tells Claude how to integrate, which errors to handle and how to test.
  • Any agent: a skill file is published at https://docs.flow.engineer/unified-messaging-api/skill.md. Install it with npx skills add https://docs.flow.engineer.
  1. Receive message.received events. In a web app, add a webhook route with flow.webhooks.handler({ onEvent }) (or constructEvent on the raw body). In a worker or script, use for await (const event of flow.events.stream({ types: ["message.received"] })).
  2. Read what the person said with contentText(event.data.message.content); it covers text, captions, voice note transcripts and button taps.
  3. Answer with event.conversation.reply(x), where x is a string or the user’s LLM stream (OpenAI, Anthropic, Vercel AI SDK, OpenAI Agents SDK, Claude Agent SDK, LangChain, Mastra). Pass { idempotencyKey: event.id }.
  4. Register the webhook: POST /v0/webhook_endpoints with the public URL and ["message.received"]; save the returned secret as FLOW_MESSAGING_WEBHOOK_SECRET. Locally, with no public URL, read events from the live stream instead: GET /v0/stream (WebSocket, resumable with after; flow.events.stream(...) in TypeScript). See Local development.
  5. Handle errors by error.type (TypeScript: err instanceof OutsideWindowError etc.). Each error carries a hint and a doc_url such as https://api.flow.engineer/docs/errors/outside_window.
TypeScript (minimal complete integration)
import { FlowMessaging, contentText } from "@flow-engineer/messaging";
import { runAgent } from "./agent"; // the user's existing agent: returns a string or an LLM stream
const flow = new FlowMessaging();
export const POST = flow.webhooks.handler({
onEvent: async (event) => {
if (event.type !== "message.received") return;
const input = contentText(event.data.message.content);
await event.conversation.reply(await runAgent(input, { conversationId: event.conversation.id }), {
idempotencyKey: event.id,
});
},
});

Do not finish until this works:

  1. Ask the user to join the sandbox from their phone, if they have not: give them the sandbox sender’s link from sandbox_join (or GET /v0/senders). On Telegram, opening the link and tapping Start joins; otherwise they send the sender’s join_code as it is, for example Hi Sandbox app (ref wild-otter-04508705). The join reaches the app as their first message.received.
  2. Start the app (reading GET /v0/stream when developing locally without a public URL).
  3. If the Flow MCP tools are available to you, call send_test_message or ask the user to send a message, then wait_for_event for message.received. Without them, ask the user to send a message and read it with GET /v0/events?type=message.received. After a send, wait for message.sent or message.failed (not message.delivered, which not every channel reports). Each wait_for_event call waits up to 50 seconds (a longer timeout_seconds is clamped to 50, with a note, not refused); if it times out, call it again with after set to the next_after it returned.
  4. If a delivery failed, read it with get_webhook_deliveries (MCP), fix the handler, and replay_event; without the MCP tools, fix the handler and have the user write again.
  • Build on the REST API (or the TypeScript SDK); the MCP server is never a runtime dependency.
  • Use the SDK in TypeScript/JavaScript; do not hand-roll HTTP calls there.
  • Reply into the conversation (event.conversation, or POST /v0/conversations/{conversation_id}/basic and the other kinds’ endpoints). Never choose a channel per message and never assume a contact has a phone number.
  • Verify webhook signatures on the raw body before trusting it; deduplicate on the event id.
  • Answer webhooks within 10 seconds. Run slow agents after the response (after(), waitUntil(), a queue) or use the event stream.
  • Send only what the channel takes. Each kind has its own endpoint, whose page lists its channels; Flow converts nothing. A 422 unsupported_content names what was refused (param, channel, channel_code) and its hint says what to send instead; send that with a new Idempotency-Key, the refused one plus what it is (evt_123:card:2:basic). Code written for /v1: use the flow-messaging-migrate skill (Moving from /v1).
  • Never message first on iMessage unless the sender’s imessage.can_initiate is on: the person writes first; on outside_window, wait for them to write, do not retry the same content. (On WhatsApp: send an approved template instead.)
  • Handle iMessage group chats: a conversation with type group has no contact; read message.from for who wrote. Do not react, show typing or send read receipts in groups (each answers 422 unsupported_content, group_unsupported).
  • Test keys only while building. Do not switch to fk_live_ unless the user asks.
  • Do not work around the sandbox allowance. On 403 permission with channel_code sandbox_allowance_used or sandbox_contact_limit, or 401 authentication with sandbox_key_expired, do not retry and do not get more keys: ask the user to sign in (npx @flow-engineer/messaging login --no-wait, then login once they approve).
URLWhat
/llms.txtIndex of every page with a one-line description
/llms-full.txtThe whole documentation in one file
Any page URL + .mdThat page as Markdown (for example https://docs.flow.engineer/unified-messaging-api/quickstart.md)
https://raw.githubusercontent.com/flow-engineer/sdk/main/openapi/openapi.yamlThe OpenAPI 3.1 spec
https://docs.flow.engineer/unified-messaging-api/observe/traces.mdFlow Observe for agents not on Flow Messaging: the trace contract and examples
https://github.com/flow-engineer/sdkSDK source and examples