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.
Paste this into Claude Code, Codex or Cursor to send your agent's traces to Flow Observe.
1. Get a key and set up
First check for a key: ifFLOW_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.
.env, init gets one (POST /v0/sandbox/keys) without asking, then:
- writes
FLOW_MESSAGING_KEYandFLOW_CLAIM_TOKENto.envand adds.envto.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
--yesyourself), installs the Claude Code skill in.claude/skills/flow-messaging/, adds a Flow Messaging section toAGENTS.md(read by Codex and other agents), and registers the MCP serverflowfor Claude Code (.mcp.json) and Codex (codex mcp add).
--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:
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:
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.
2. Environment variables
Never hard-code keys, never commit
.env, and never put a key in browser or mobile code.
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 athttps://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:
~/.codex/config.toml):
~/.cursor/mcp.json):
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.
4. The skill
- Claude Code:
initinstalls 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.
5. Integrate: what to write
- Receive
message.receivedevents. In a web app, add a webhook route withflow.webhooks.handler({ onEvent })(orconstructEventon the raw body). In a worker or script, usefor await (const event of flow.events.stream({ types: ["message.received"] })). - Read what the person said with
contentText(event.data.message.content); it covers text, captions, voice note transcripts and button taps. - Answer with
event.conversation.reply(x), wherexis 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 }. - Register the webhook:
POST /v0/webhook_endpointswith the public URL and["message.received"]; save the returned secret asFLOW_MESSAGING_WEBHOOK_SECRET. Locally, with no public URL, read events from the live stream instead:GET /v0/stream(WebSocket, resumable withafter;flow.events.stream(...)in TypeScript). See Local development. - Handle errors by
error.type(TypeScript:err instanceof OutsideWindowErroretc.). Each error carries ahintand adoc_urlsuch as https://api.flow.engineer/docs/errors/outside_window.
TypeScript (minimal complete integration)
6. Verify end to end
Do not finish until this works:- Ask the user to join the sandbox from their phone, if they have not: give them the sandbox sender’s link from
sandbox_join(orGET /v0/senders). On Telegram, opening the link and tapping Start joins; otherwise they send the sender’sjoin_codeas it is, for exampleHi Sandbox app (ref wild-otter-04508705). The join reaches the app as their firstmessage.received. - Start the app (reading
GET /v0/streamwhen developing locally without a public URL). - If the Flow MCP tools are available to you, call
send_test_messageor ask the user to send a message, thenwait_for_eventformessage.received. Without them, ask the user to send a message and read it withGET /v0/events?type=message.received. After a send, wait formessage.sentormessage.failed(notmessage.delivered, which not every channel reports). Eachwait_for_eventcall waits up to 50 seconds (a longertimeout_secondsis clamped to 50, with a note, not refused); if it times out, call it again withafterset to thenext_afterit returned. - If a delivery failed, read it with
get_webhook_deliveries(MCP), fix the handler, andreplay_event; without the MCP tools, fix the handler and have the user write again.
Rules
- 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, orPOST /v0/conversations/{conversation_id}/basicand 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_contentnames what was refused (param,channel,channel_code) and itshintsays what to send instead; send that with a newIdempotency-Key, the refused one plus what it is (evt_123:card:2:basic). Code written for/v1: use theflow-messaging-migrateskill (Moving from /v1). - Never message first on iMessage unless the sender’s
imessage.can_initiateis on: the person writes first; onoutside_window, wait for them to write, do not retry the same content. (On WhatsApp: send an approvedtemplateinstead.) - Handle iMessage group chats: a conversation with
typegrouphas nocontact; readmessage.fromfor who wrote. Do not react, show typing or send read receipts in groups (each answers422 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 permissionwithchannel_codesandbox_allowance_usedorsandbox_contact_limit, or401 authenticationwithsandbox_key_expired, do not retry and do not get more keys: ask the user to sign in (npx @flow-engineer/messaging login --no-wait, thenloginonce they approve).