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.

Get a test key, join the Telegram sandbox and answer a message.

Get your agent answering on your phone. Start with the Telegram sandbox; the same handler later works with your own bot, WhatsApp number or a Flow-connected iMessage line.

Get a test key

From your project folder:

Terminal
npx @flow-engineer/messaging init

This creates a test key without an account, writes FLOW_MESSAGING_KEY and FLOW_CLAIM_TOKEN to .env, and prints a sandbox link. It asks before installing coding-agent support or configuring the optional MCP connection.

Already have a key? Use it instead of creating another app.

Join from your phone

Open the printed Telegram link and tap Start. If you found the bot another way, send the join message printed by the CLI.

Check it worked: your app has a conversation with your phone. The sandbox only sends to people who have joined it.

Run the reply handler

Choose an SDK, install it and set FLOW_MESSAGING_KEY in your process environment. Node does not automatically load .env; use the run command below.

Terminal window
npm install @flow-engineer/messaging
# Save the TypeScript example below as agent.ts.
node --env-file=.env agent.ts
import { FlowMessaging, contentText } from "@flow-engineer/messaging";
const flow = new FlowMessaging();
for await (const event of flow.events.stream({ types: ["message.received"] })) {
const said = contentText(event.data.message.content);
await event.conversation.reply("You said: " + said, {
idempotencyKey: event.id,
});
}

Check it worked: text “hello” to the bot and receive “You said: hello”. A send is queued first; watch message.sent or message.failed to learn its outcome.

Use your agent's answer

Replace the echo with a call to your model or agent. The TypeScript SDK’s reply() also accepts supported model streams and sends complete chat bubbles as they become ready.

See streaming replies or choose your agent framework.

An anonymous test key allows one contact and 50 outbound messages, and expires after seven days. Sign in to keep the app and use the signed-in allowance. Use synthetic data and shared sandbox senders only for testing.

The API works from any language. These alternatives use the same key and conversation:

Receive and reply with curl
curl
# 1. Read the newest messages from the event log (oldest first; pass after=evt_... to page forward).
curl -G https://api.flow.engineer/v0/events \
-H "Authorization: Bearer $FLOW_MESSAGING_KEY" \
--data-urlencode "type=message.received"
# 2. Reply into the conversation from an event (data[].conversation.id) with a basic message.
curl https://api.flow.engineer/v0/conversations/conv_01JB8ZC3K5M7P9R1T3V5X7Z9B1/basic \
-H "Authorization: Bearer $FLOW_MESSAGING_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"text": "Hello from my agent"}'
Run the Go HTTP example
Go (HTTP)
// The Go SDK is not published yet; this is the same agent over plain HTTP,
// standard library only.
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"time"
)
const api = "https://api.flow.engineer"
type event struct {
ID string `json:"id"`
Conversation struct {
ID string `json:"id"`
} `json:"conversation"`
Data struct {
Message struct {
Content struct {
Kind string `json:"kind"` // basic, voice, button_reply, ...
Text string `json:"text"`
} `json:"content"`
} `json:"message"`
} `json:"data"`
}
func call(method, path string, body any, idempotencyKey string, out any) error {
var buf bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil {
return err
}
}
req, err := http.NewRequest(method, api+path, &buf)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("FLOW_MESSAGING_KEY"))
req.Header.Set("Flow-Version", "2026-11-01")
req.Header.Set("Content-Type", "application/json")
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 300 {
return fmt.Errorf("%s %s: %s", method, path, res.Status)
}
if out != nil {
return json.NewDecoder(res.Body).Decode(out)
}
return nil
}
func main() {
after := ""
for {
q := url.Values{"type": {"message.received"}, "limit": {"100"}}
if after != "" {
q.Set("after", after)
}
var page struct {
Data []event `json:"data"`
HasMore bool `json:"has_more"`
}
if err := call("GET", "/v0/events?"+q.Encode(), nil, "", &page); err != nil {
fmt.Println(err)
time.Sleep(2 * time.Second)
continue
}
for _, ev := range page.Data {
after = ev.ID
reply := map[string]any{"text": "You said: " + ev.Data.Message.Content.Text}
if err := call("POST", "/v0/conversations/"+ev.Conversation.ID+"/basic", reply, ev.ID, nil); err != nil {
fmt.Println(err)
}
}
if !page.HasMore {
time.Sleep(time.Second)
}
}
}

Each kind of message has its own endpoint (/basic, /buttons, /list, /carousel, …), and its page opens with the channels it works on. A kind, field or value the channel does not take answers 422 unsupported_content and sends nothing; its hint says what to send instead.

The API is V0: every path starts with /v0, and each kind of message has its own endpoint instead of POST /v1/conversations/{conversation_id}/messages with content. There is no fallback, delivered_as, markdown format or capabilities endpoint, typing takes no state, and starts are flat ({"sender": "snd_...", "to": {...}, "kind": "basic", "text": "Hi"}).

  • Plain HTTP or the SDKs (@flow-engineer/messaging 0.4.0, flow-messaging 0.2.0): have your coding agent run the flow-messaging-migrate skill. In Claude Code, run claude plugin marketplace add flow-engineer/sdk, then claude plugin install flow-messaging@flow-engineer. The skill’s source is on GitHub.
  • Flow Framework: upgrade @flow-engineer/flow to 0.7.0. Your agent code does not change.