@flow-engineer/flow 0.0.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +611 -0
  3. package/coding-agents/skill.md +353 -0
  4. package/coding-agents/test-skill.md +223 -0
  5. package/coding-agents/tools-skill.md +89 -0
  6. package/dist/flow-messaging/src/client.d.ts +40 -0
  7. package/dist/flow-messaging/src/client.js +142 -0
  8. package/dist/flow-messaging/src/index.d.ts +72 -0
  9. package/dist/flow-messaging/src/index.js +605 -0
  10. package/dist/simulators/src/index.d.ts +2 -0
  11. package/dist/simulators/src/index.js +2 -0
  12. package/dist/simulators/src/page.html +1245 -0
  13. package/dist/simulators/src/person.html +100 -0
  14. package/dist/simulators/src/platforms.d.ts +29 -0
  15. package/dist/simulators/src/platforms.js +72 -0
  16. package/dist/simulators/src/server.d.ts +201 -0
  17. package/dist/simulators/src/server.js +759 -0
  18. package/dist/src/agent.d.ts +504 -0
  19. package/dist/src/agent.js +818 -0
  20. package/dist/src/channels.d.ts +178 -0
  21. package/dist/src/channels.js +243 -0
  22. package/dist/src/conversation.d.ts +361 -0
  23. package/dist/src/conversation.js +1487 -0
  24. package/dist/src/db.d.ts +18 -0
  25. package/dist/src/db.js +57 -0
  26. package/dist/src/events.d.ts +17 -0
  27. package/dist/src/events.js +20 -0
  28. package/dist/src/http.d.ts +45 -0
  29. package/dist/src/http.js +269 -0
  30. package/dist/src/index.d.ts +14 -0
  31. package/dist/src/index.js +11 -0
  32. package/dist/src/model.d.ts +21 -0
  33. package/dist/src/model.js +96 -0
  34. package/dist/src/names.d.ts +22 -0
  35. package/dist/src/names.js +43 -0
  36. package/dist/src/nudges.d.ts +23 -0
  37. package/dist/src/nudges.js +117 -0
  38. package/dist/src/postgres.d.ts +20 -0
  39. package/dist/src/postgres.js +62 -0
  40. package/dist/src/schedules.d.ts +60 -0
  41. package/dist/src/schedules.js +249 -0
  42. package/dist/src/skills.d.ts +12 -0
  43. package/dist/src/skills.js +69 -0
  44. package/dist/src/store.d.ts +214 -0
  45. package/dist/src/store.js +448 -0
  46. package/dist/src/talk.d.ts +57 -0
  47. package/dist/src/talk.js +166 -0
  48. package/dist/src/time.d.ts +71 -0
  49. package/dist/src/time.js +170 -0
  50. package/dist/src/tools.d.ts +43 -0
  51. package/dist/src/tools.js +26 -0
  52. package/dist/src/traces.d.ts +31 -0
  53. package/dist/src/traces.js +303 -0
  54. package/dist/src/types.d.ts +260 -0
  55. package/dist/src/types.js +2 -0
  56. package/dist/testing/src/account.d.ts +4 -0
  57. package/dist/testing/src/account.js +580 -0
  58. package/dist/testing/src/chat.d.ts +3 -0
  59. package/dist/testing/src/chat.js +117 -0
  60. package/dist/testing/src/check-endpoint.d.ts +31 -0
  61. package/dist/testing/src/check-endpoint.js +158 -0
  62. package/dist/testing/src/chinwag.d.ts +2 -0
  63. package/dist/testing/src/chinwag.js +6 -0
  64. package/dist/testing/src/cli.d.ts +2 -0
  65. package/dist/testing/src/cli.js +592 -0
  66. package/dist/testing/src/client.d.ts +117 -0
  67. package/dist/testing/src/client.js +956 -0
  68. package/dist/testing/src/flow-login.d.ts +143 -0
  69. package/dist/testing/src/flow-login.js +249 -0
  70. package/dist/testing/src/http-agent.d.ts +47 -0
  71. package/dist/testing/src/http-agent.js +155 -0
  72. package/dist/testing/src/index.d.ts +3 -0
  73. package/dist/testing/src/index.js +2 -0
  74. package/dist/testing/src/new.d.ts +13 -0
  75. package/dist/testing/src/new.js +147 -0
  76. package/dist/testing/src/print-run.d.ts +3 -0
  77. package/dist/testing/src/print-run.js +22 -0
  78. package/dist/testing/src/protocol.d.ts +159 -0
  79. package/dist/testing/src/protocol.js +28 -0
  80. package/dist/testing/src/report.d.ts +74 -0
  81. package/dist/testing/src/report.js +90 -0
  82. package/dist/testing/src/show.d.ts +36 -0
  83. package/dist/testing/src/show.js +280 -0
  84. package/dist/testing/src/suite.d.ts +87 -0
  85. package/dist/testing/src/suite.js +198 -0
  86. package/docs/agents.md +85 -0
  87. package/examples/cafe/agent/AGENTS.md +10 -0
  88. package/examples/cafe/agent/skills/allergens/SKILL.md +7 -0
  89. package/examples/cafe/src/agent.ts +43 -0
  90. package/examples/cafe/src/channels.ts +20 -0
  91. package/examples/cafe/src/main.ts +30 -0
  92. package/examples/cafe/src/tools.ts +62 -0
  93. package/examples/cafe/tests/scenarios/latte-correction.yaml +30 -0
  94. package/examples/cafe/tests/scenarios/order-fails.yaml +26 -0
  95. package/examples/cafe/tests/tools/get_menu.yaml +14 -0
  96. package/examples/cafe/tests/tools/place_order.yaml +29 -0
  97. package/examples/http-agent/server.mjs +32 -0
  98. package/examples/http-agent/tests/scenarios/opening-hours.yaml +16 -0
  99. package/flow-messaging/README.md +191 -0
  100. package/package.json +63 -8
  101. package/spec/agent-endpoint.openapi.yaml +148 -0
  102. package/spec/nudges.openapi.yaml +167 -0
  103. package/spec/request.schema.json +121 -0
  104. package/spec/response.schema.json +284 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ name: allergens
3
+ description: questions about allergies, dairy, nuts, gluten or other dietary needs
4
+ ---
5
+ - Answer only from the allergens listed for each item in get_menu; if an item lists none, say you're not sure and suggest asking the barista at pickup.
6
+ - Oat and almond milk are made in the same area as dairy: say so to anyone with a severe dairy allergy.
7
+ - Never say an item is "safe" for an allergy; say what it contains and what it's made near.
@@ -0,0 +1,43 @@
1
+ // Builds Pip. The playground (main.ts) and the tests build it the same way; tests pass their own
2
+ // model, events and (simulated) tools.
3
+
4
+ import { fileURLToPath } from "node:url";
5
+ import { createAgent, customUI, openAICompatible, sms, whatsapp, type AgentOptions, type Channel, type EventSink, type Model, type Tool } from "../../../src/index.ts";
6
+ import { tools as cafeTools } from "./tools.ts";
7
+
8
+ export interface BuildOptions {
9
+ model?: Model;
10
+ events?: EventSink[];
11
+ tools?: Tool[];
12
+ /** Channels that are connected, such as Telegram through Flow Messaging (main.ts adds them when .env has its key); one named whatsapp replaces the unconnected one. */
13
+ channels?: Channel[];
14
+ /** Where Pip runs, for a host with no file system (Cloudflare): its files as text, its database, no schedule timer. */
15
+ host?: Pick<AgentOptions, "files" | "database" | "scheduleCheckMs">;
16
+ }
17
+
18
+ export const tools = cafeTools;
19
+
20
+ // The café's numbers and web chat are not connected yet, so every provider refuses to send. Test
21
+ // people write from the playground, and replies to them go back there, never through these.
22
+ const notConnected = async () => {
23
+ throw new Error("not connected yet: only test people in the playground reach Pip for now");
24
+ };
25
+ const provider = { sendMessage: notConnected, react: notConnected, typing: notConnected, markRead: notConnected, status: notConnected };
26
+
27
+ export function buildAgent(options: BuildOptions = {}) {
28
+ const connected = options.channels ?? [];
29
+ const unconnected = [whatsapp(provider), sms(provider), customUI("web", provider)].filter((c) => !connected.some((k) => k.name === c.name));
30
+ return createAgent({
31
+ ...(options.host ?? { dir: fileURLToPath(new URL("../agent", import.meta.url)) }),
32
+ model: options.model ?? openAICompatible({ model: process.env.CAFE_MODEL }),
33
+ tools: options.tools ?? cafeTools,
34
+ events: options.events ?? [],
35
+ channels: [...unconnected, ...connected],
36
+ testPeople: [
37
+ { name: "Maria", phone: "+15555550101" },
38
+ { name: "Sam", phone: "+15555550102" },
39
+ { name: "Priya", phone: "+15555550103" },
40
+ ],
41
+ timeZone: "America/New_York",
42
+ });
43
+ }
@@ -0,0 +1,20 @@
1
+ // Which Flow Messaging channels Pip is on: Telegram always, WhatsApp and iMessage when .env names
2
+ // their sender (FLOW_MESSAGING_WHATSAPP_SENDER, FLOW_MESSAGING_IMESSAGE_SENDER).
3
+
4
+ import type { flowMessaging } from "../../../flow-messaging/src/index.ts";
5
+
6
+ type FlowMessaging = Pick<ReturnType<typeof flowMessaging>, "telegram" | "whatsapp" | "imessage">;
7
+
8
+ export function flowChannels(fm: FlowMessaging, env: Record<string, string | undefined> = process.env) {
9
+ return [
10
+ fm.telegram(),
11
+ ...(env.FLOW_MESSAGING_WHATSAPP_SENDER ? [fm.whatsapp()] : []),
12
+ ...(env.FLOW_MESSAGING_IMESSAGE_SENDER ? [fm.imessage()] : []),
13
+ ];
14
+ }
15
+
16
+ /** The channels as the log line names them: "Telegram, WhatsApp, iMessage". */
17
+ export function channelNames(channels: Array<{ name: string }>): string {
18
+ const label: Record<string, string> = { telegram: "Telegram", whatsapp: "WhatsApp", imessage: "iMessage" };
19
+ return channels.map((c) => label[c.name] ?? c.name).join(", ");
20
+ }
@@ -0,0 +1,30 @@
1
+ // Pip in the playground: `npm start`, then open the printed link and pick a test person. With
2
+ // FLOW_MESSAGING_KEY in .env (`npx @flow-engineer/flow link` puts it there), Pip is on Telegram
3
+ // too (and WhatsApp and iMessage when .env names their senders), through Flow Messaging's event
4
+ // stream: an outbound connection, so no public URL or tunnel.
5
+
6
+ import { flowMessaging } from "../../../flow-messaging/src/index.ts";
7
+ import { simulators } from "../../../simulators/src/index.ts";
8
+ import { buildAgent } from "./agent.ts";
9
+ import { channelNames, flowChannels } from "./channels.ts";
10
+
11
+ const play = simulators({ title: "Pip · Bean & Leaf" });
12
+ const fm = process.env.FLOW_MESSAGING_KEY ? flowMessaging() : undefined;
13
+ const channels = fm ? flowChannels(fm) : [];
14
+ let agent: ReturnType<typeof buildAgent>;
15
+ try {
16
+ agent = buildAgent({ events: [play.events], channels });
17
+ } catch (error) {
18
+ console.error((error as Error).message); // such as no model key in .env
19
+ process.exit(1);
20
+ }
21
+ const port = Number(process.env.PORT ?? 8788);
22
+ const { url } = await play.listen(agent, port);
23
+ console.log(`Pip's playground: ${url}/`);
24
+ console.log(`Every channel side by side: ${url}/person`);
25
+ if (fm) {
26
+ // Events sent while Pip was not running come when it starts again (.flow/ keeps the last one handled).
27
+ const live = fm.start(agent, { cursorFile: ".flow/flow-messaging-cursor", port: port + 1 });
28
+ console.log(`On ${channelNames(channels)} through Flow Messaging (${live.mode}). The phone link: npx @flow-engineer/flow link`);
29
+ live.stopped.then((why) => console.error(`Flow Messaging stopped: ${why.message} Then: npx @flow-engineer/flow setup`));
30
+ }
@@ -0,0 +1,62 @@
1
+ // Pip's tools, over a tiny in-memory café. Placing an order is slow on purpose (a real point-of-sale
2
+ // takes seconds), so it shows a tool moving to the background.
3
+
4
+ import { tool, z } from "../../../src/index.ts";
5
+
6
+ const MENU = [
7
+ { item: "Latte", sizes: { small: 4.0, large: 4.8 }, milks: ["whole", "oat", "almond"], allergens: ["dairy (unless plant milk)"] },
8
+ { item: "Flat white", sizes: { small: 4.2 }, milks: ["whole", "oat"], allergens: ["dairy (unless oat)"] },
9
+ { item: "Cold brew", sizes: { small: 4.5, large: 5.2 }, milks: [], allergens: [] },
10
+ { item: "Chai", sizes: { small: 4.0, large: 4.6 }, milks: ["whole", "oat"], allergens: ["dairy (unless oat)"] },
11
+ { item: "Almond croissant", sizes: { one: 4.25 }, milks: [], allergens: ["gluten", "nuts", "dairy", "egg"] },
12
+ { item: "Banana bread", sizes: { slice: 3.75 }, milks: [], allergens: ["gluten", "egg", "walnuts"] },
13
+ ];
14
+
15
+ let nextOrder = 1040;
16
+ const orders = new Map<string, { items: unknown; readyBy: string }>();
17
+ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
18
+
19
+ export const tools = [
20
+ tool({
21
+ name: "get_menu",
22
+ description: "The full menu: items, sizes with prices in USD, milk options and allergens.",
23
+ input: z.object({}),
24
+ run: async () => {
25
+ await wait(150);
26
+ return { menu: MENU, note: "This is the whole menu." };
27
+ },
28
+ }),
29
+ tool({
30
+ name: "place_order",
31
+ description: "Place a confirmed pickup order. Returns the order number and when it will be ready.",
32
+ input: z.object({
33
+ name: z.string().describe("The name for the order"),
34
+ items: z.array(
35
+ z.object({
36
+ item: z.string(),
37
+ size: z.string(),
38
+ milk: z.string().optional(),
39
+ quantity: z.number().int().min(1).max(20).default(1),
40
+ }),
41
+ ),
42
+ }),
43
+ run: async ({ items }) => {
44
+ await wait(5_000);
45
+ const id = `BL-${nextOrder++}`;
46
+ const readyBy = new Date(Date.now() + 10 * 60_000).toISOString();
47
+ orders.set(id, { items, readyBy });
48
+ return { order_id: id, ready_by: readyBy, pay_at_pickup: true };
49
+ },
50
+ }),
51
+ tool({
52
+ name: "order_status",
53
+ description: "Where an order is: received, being made, or ready.",
54
+ input: z.object({ order_id: z.string() }),
55
+ run: async ({ order_id }) => {
56
+ await wait(200);
57
+ const order = orders.get(order_id);
58
+ if (!order) return { found: false };
59
+ return { found: true, status: Date.now() > Date.parse(order.readyBy) ? "ready" : "being made", ready_by: order.readyBy };
60
+ },
61
+ }),
62
+ ];
@@ -0,0 +1,30 @@
1
+ # A spec: one situation to put the agent in, and what has to be true at the end.
2
+ id: latte-correction
3
+ title: Orders a latte, corrects the milk mid-thought, confirms
4
+ channels: [whatsapp] # each platform it runs on, with that simulator's rules
5
+
6
+ person: # played by a model that sees only what a person would see
7
+ persona: Sam, in a hurry on the way to work; types in short bursts, no capitals.
8
+ goal: Get a large oat latte for pickup, under the name Sam.
9
+ knows: # told only if the agent asks
10
+ - Their name is Sam.
11
+
12
+ opening: # the first messages, sent exactly like this; then the person plays on
13
+ - say: "can i get a large latte"
14
+ - say: "oat milk actually"
15
+ after_ms: 1200
16
+
17
+ world: | # what is true in this test; the tool simulator plays the tools from it
18
+ The menu is as in tests/tools/get_menu.yaml. Oat milk is in stock. The café is busy this
19
+ morning, so the point of sale is slow to take orders, but they go through.
20
+
21
+ max_turns: 8
22
+
23
+ expect:
24
+ outcome: # judged against what the tools did (the simulator's log)
25
+ - Exactly one order is placed, for one large latte with oat milk, under the name Sam.
26
+ judge: # read by the judge from the transcript, each pass or fail with a reason
27
+ - The order read back to Sam uses oat milk, not whole milk.
28
+ - The agent asks Sam to confirm before placing the order.
29
+ - Sam is told the order number and when it will be ready.
30
+ - Prices match the menu.
@@ -0,0 +1,26 @@
1
+ id: order-fails
2
+ title: The point of sale is down when the order is placed
3
+ channels: [sms]
4
+
5
+ person:
6
+ persona: Jo, polite, a regular; writes full sentences.
7
+ goal: Order a small cold brew for pickup under the name Jo.
8
+ knows:
9
+ - Their name is Jo.
10
+
11
+ opening:
12
+ - say: "Hi, could I order a small cold brew for pickup please? Name's Jo."
13
+
14
+ world: |
15
+ The menu is as in tests/tools/get_menu.yaml. The café's point of sale is down this morning and
16
+ stays down for the whole conversation.
17
+
18
+ max_turns: 6
19
+
20
+ expect:
21
+ outcome:
22
+ - No order is placed.
23
+ judge:
24
+ - Jo is told plainly that the order could not go through, without technical detail.
25
+ - The agent does not claim the order was placed.
26
+ - Jo is offered a way forward (try again shortly, or order at the counter).
@@ -0,0 +1,14 @@
1
+ tool: get_menu
2
+ behaviour: |
3
+ Returns the whole menu every time. If the scenario's world says what is on the menu, answer
4
+ with that; otherwise use the menu below.
5
+ responses:
6
+ - when: Always
7
+ latency_ms: 150
8
+ input: {}
9
+ output:
10
+ menu:
11
+ - { item: Latte, sizes: { small: 4.0, large: 4.8 }, milks: [whole, oat, almond], allergens: ["dairy (unless plant milk)"] }
12
+ - { item: Cold brew, sizes: { small: 4.5, large: 5.2 }, milks: [], allergens: [] }
13
+ - { item: Almond croissant, sizes: { one: 4.25 }, milks: [], allergens: [gluten, nuts, dairy, egg] }
14
+ note: This is the whole menu.
@@ -0,0 +1,29 @@
1
+ # Everything place_order can do, for the tool simulator. During a test no real tool runs: a model
2
+ # plays the tool. On each call it reads the scenario's world and picks the response (and how long
3
+ # the call takes) that fits, from the kinds below. Scenarios never set responses or latencies;
4
+ # they describe the situation ("the point of sale is slow this morning") and the simulator plays
5
+ # it, possibly differently on each call, as a real system would.
6
+ #
7
+ # behaviour what the tool does, in plain words, beyond its description
8
+ # responses the kinds of response it gives, each with an example and how long it takes
9
+ # (recorded from real calls once observability records them; by hand until then)
10
+ tool: place_order
11
+ behaviour: |
12
+ Order numbers are BL- followed by four digits and go up by one per order. ready_by is about
13
+ ten minutes after the order is placed. Fails if an item, size or milk is not on the menu.
14
+ responses:
15
+ - when: The order goes through (usual)
16
+ latency_ms: 4800
17
+ input:
18
+ name: Sam
19
+ items: [{ item: Latte, size: large, milk: oat, quantity: 1 }]
20
+ output: { order_id: BL-1040, ready_by: "{{now + 10m}}", pay_at_pickup: true }
21
+ - when: The point of sale is busy; the order still goes through
22
+ latency_ms: 11000
23
+ output: { order_id: BL-1041, ready_by: "{{now + 15m}}", pay_at_pickup: true }
24
+ - when: The point of sale is down
25
+ latency_ms: 3000
26
+ error: Point of sale unavailable
27
+ - when: Something in the order is not on the menu
28
+ latency_ms: 600
29
+ error: "Unknown item: Matcha latte"
@@ -0,0 +1,32 @@
1
+ // An agent you already have, wrapped for `flow test --agent-url`: one POST per message from the
2
+ // person, answered with the replies to send. No dependencies. Put your own agent where reply() is.
3
+ //
4
+ // node server.mjs listens on http://localhost:3000/flow (PORT to change it)
5
+ // npx @flow-engineer/flow test --agent-url http://localhost:3000/flow from this folder, in another terminal
6
+
7
+ import { createServer } from "node:http";
8
+
9
+ /** Each conversation's messages, as your agent keeps them per phone number. */
10
+ const histories = new Map();
11
+
12
+ /** Your agent's turn: the person's message in, the replies out. Its tools run here, for real. */
13
+ async function reply(conversationId, channel, message) {
14
+ const history = histories.get(conversationId) ?? [];
15
+ histories.set(conversationId, history);
16
+ history.push({ from: "person", text: message.text });
17
+ const text = history.length === 1 ? "Hi! We're open 8am to 6pm every day. Can I help with anything else?" : "Happy to help. Have a good day!";
18
+ history.push({ from: "agent", text });
19
+ return [{ type: "message", text }];
20
+ }
21
+
22
+ createServer(async (req, res) => {
23
+ if (req.method !== "POST" || req.url !== "/flow") return res.writeHead(404).end();
24
+ // Set FLOW_FRAMEWORK_AGENT_TOKEN on both sides to require it.
25
+ const token = process.env.FLOW_FRAMEWORK_AGENT_TOKEN ?? process.env.CHINWAG_AGENT_TOKEN;
26
+ if (token && req.headers.authorization !== `Bearer ${token}`) return res.writeHead(401).end();
27
+ let body = "";
28
+ for await (const chunk of req) body += chunk;
29
+ const { conversationId, channel, message } = JSON.parse(body);
30
+ const replies = await reply(conversationId, channel, message);
31
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ replies }));
32
+ }).listen(Number(process.env.PORT ?? 3000), () => console.log(`Listening on http://localhost:${process.env.PORT ?? 3000}/flow`));
@@ -0,0 +1,16 @@
1
+ id: opening-hours
2
+ title: Asks when the shop is open
3
+ channels: [whatsapp]
4
+
5
+ person:
6
+ persona: Alex, friendly, writes short messages.
7
+ goal: Find out when the shop is open today.
8
+
9
+ opening:
10
+ - say: "hi, what time are you open today?"
11
+
12
+ max_turns: 4
13
+
14
+ expect:
15
+ judge:
16
+ - Alex is told the opening hours.
@@ -0,0 +1,191 @@
1
+ # Flow Messaging channels
2
+
3
+ Put a Flow Framework (`npm i @flow-engineer/flow`) agent on real WhatsApp, Telegram, iMessage and US SMS
4
+ through [Flow Messaging](https://api.flow.engineer) (API version 2026-11-01). `@flow-engineer/flow/flow-messaging` gives you
5
+ channels that send through Flow's senders, and two ways to receive Flow's events: the live event
6
+ stream, an outbound WebSocket that needs no public URL, and a webhook that checks Flow's
7
+ signatures. It needs nothing beyond the framework (it uses `fetch`, Node's `WebSocket` and `node:crypto`).
8
+
9
+ On a laptop, `npx @flow-engineer/flow link` signs in, writes `FLOW_MESSAGING_KEY` and the Telegram
10
+ sender into `.env` and prints the link for your phone, so you can skip step 1 below.
11
+
12
+ ```sh
13
+ npm install @flow-engineer/flow
14
+ ```
15
+
16
+ Node 22.18 or later. The adapter ships inside `@flow-engineer/flow`; import it from `@flow-engineer/flow/flow-messaging`.
17
+
18
+ ## Quick start
19
+
20
+ ### 1. Get a key
21
+
22
+ Ask the API for a test key. You don't need an account:
23
+
24
+ ```sh
25
+ curl -X POST https://api.flow.engineer/v1/sandbox/keys
26
+ ```
27
+
28
+ The answer holds your test key (`key`, `fk_test_...`, shown once) and the sandbox senders (`senders`).
29
+ Each sender has an ID (`snd_...`), a link to open on your phone, and a join code to send it.
30
+
31
+ The sandbox has Telegram today. WhatsApp and iMessage come as they open. A sandbox key lasts 7 days
32
+ and covers one contact. Live keys (`fk_live_...`) and your own WhatsApp, Telegram or
33
+ SMS senders are set up from the dashboard at https://api.flow.engineer. iMessage lines, Flow's
34
+ shared one included, are arranged with the Flow team.
35
+
36
+ Open the Telegram sandbox sender's link on your phone and send its join code. You are now the
37
+ sandbox contact your agent can talk to.
38
+
39
+ ### 2. Run the agent
40
+
41
+ ```ts
42
+ // main.ts
43
+ import { createAgent } from "@flow-engineer/flow";
44
+ import { flowMessaging } from "@flow-engineer/flow/flow-messaging";
45
+
46
+ const fm = flowMessaging(); // reads FLOW_MESSAGING_KEY (and FLOW_MESSAGING_WEBHOOK_SECRET for a webhook)
47
+
48
+ const agent = createAgent({
49
+ dir: "./agent", // your agent's folder (AGENTS.md, ...)
50
+ model: yourModel,
51
+ // only the channels you have senders for
52
+ channels: [fm.telegram(), fm.whatsapp(), fm.imessage(), fm.sms()],
53
+ });
54
+ await agent.resume();
55
+
56
+ const live = fm.start(agent, { cursorFile: ".flow/flow-messaging-cursor" });
57
+ live.stopped.then((why) => console.error(`Flow Messaging refused the stream: ${why.message}`));
58
+ ```
59
+
60
+ That's it on a laptop: with no webhook secret, `fm.start` opens the event stream (`fm.stream(agent)`
61
+ does only that).
62
+
63
+ ### The event stream
64
+
65
+ `GET /v1/stream` is a WebSocket the agent opens to Flow, with the key offered as a subprotocol
66
+ (Node's `WebSocket` cannot set headers). It asks only for the events the adapter handles and hands
67
+ each, in log order, to the same handler as the webhook.
68
+
69
+ - **Acknowledged once handled.** The stream has no ack frame for events; it resumes from `after`.
70
+ The adapter moves its cursor past an event only once the agent has taken it, so an event that
71
+ fails, or arrives as the socket drops, comes again.
72
+ - **Reconnects.** A dropped socket, Flow's `reconnect` frame (it restarts, or a connection reaches
73
+ 24 hours) and a failed event reconnect with `after` set to the last handled event: at once the
74
+ first time, then backing off to 30 s, or after Flow's `retry_after` (too many streams for the
75
+ key, Flow busy). An event that fails 5 times in a row is passed over, so it cannot hold up every
76
+ later one; it stays in Flow's log (`GET /v1/events`).
77
+ - **Offline messages.** With `cursorFile`, the last handled event's ID is kept on disk, so after a
78
+ restart Flow replays everything that came while the agent was stopped, for as long as your plan
79
+ keeps events (30 days by default). Without it, a dropped stream still resumes, but a restart
80
+ starts from now. A cursor older than Flow's log starts again from now, and says so.
81
+ - **Stops for good** on a refused key or request (close codes 4401, 4403, 4400): `stopped`
82
+ resolves with the code, type and Flow's message. `close()` ends the stream.
83
+ - **Replies go over HTTP**, with their Idempotency-Keys, so they never wait for the stream.
84
+ - The stream cannot send `Flow-Version`, so its events come in the version your app was pinned to.
85
+
86
+ ### Or a webhook
87
+
88
+ Already have an HTTP server? Hand the raw body and headers to `fm.receive`, or serve `fm.webhook(agent)`
89
+ (with a webhook secret set, `fm.start` serves it on `port`, default `PORT` else 8080):
90
+
91
+ ```ts
92
+ const { status, body } = await fm.receive(agent, { body: rawBody, headers: req.headers });
93
+ ```
94
+
95
+ Flow posts events to a public HTTPS URL. Create a webhook endpoint for it:
96
+
97
+ ```sh
98
+ curl https://api.flow.engineer/v1/webhook_endpoints \
99
+ -H "Authorization: Bearer $FLOW_MESSAGING_KEY" \
100
+ -H "Content-Type: application/json" \
101
+ -d '{"url": "https://your-agent.example.com/", "events": ["message.received", "message.failed", "reaction.added", "contact.deleted"]}'
102
+ ```
103
+
104
+ The answer holds the signing secret (`secret`, `whsec_...`), shown once. Save it as
105
+ `FLOW_MESSAGING_WEBHOOK_SECRET`.
106
+
107
+ On a laptop there is no public URL: use the event stream instead.
108
+
109
+ ## Environment
110
+
111
+ | Variable | What it is |
112
+ |---|---|
113
+ | `FLOW_MESSAGING_KEY` | Your app's API key, `fk_test_...` or `fk_live_...`. Keep it on your server. |
114
+ | `FLOW_MESSAGING_WEBHOOK_SECRET` | The webhook endpoint's signing secret, `whsec_...`. While you rotate it, give both, comma-separated. Without it, `fm.start` uses the event stream. |
115
+ | `FLOW_MESSAGING_TELEGRAM_SENDER`, `FLOW_MESSAGING_WHATSAPP_SENDER`, `FLOW_MESSAGING_IMESSAGE_SENDER`, `FLOW_MESSAGING_SMS_SENDER` | Each channel's sender, `snd_...`. Needed to start conversations (nudges, templates) and to tell senders apart. Replies to people who wrote first work without it. |
116
+ | `FLOW_MESSAGING_BASE_URL` | Optional. Default `https://api.flow.engineer`. |
117
+
118
+ Each of these can also be passed in code: `flowMessaging({ apiKey, webhookSecret, baseUrl })`, and
119
+ `fm.telegram({ sender, name, instructions })` (the same for `whatsapp`, `imessage` and `sms`).
120
+
121
+ ## What it maps
122
+
123
+ **Out (agent to person):**
124
+
125
+ - Text: Markdown on Telegram, plain text on WhatsApp, iMessage and SMS.
126
+ - Reply buttons and lists, sent as Flow `buttons`. WhatsApp shows up to 3 reply buttons, or a list
127
+ for 4 to 10. Telegram shows an inline keyboard. iMessage has none, so the agent is not offered them.
128
+ - A link: one URL button (WhatsApp's call-to-action button); on iMessage, the URL on its own line.
129
+ - A photo: a media message with the text as its caption. Flow's button messages have no image
130
+ header, so a photo with buttons or a link goes just before them.
131
+ - Quotes (`reply_to`), reactions (tapbacks on iMessage), typing, and read receipts (WhatsApp, iMessage).
132
+ - WhatsApp templates, by name or `tpl_` ID, with their header (text, or media by URL or file ID),
133
+ body values (`{{1}}`, `{{2}}`, ...) and button values (URL suffixes, quick-reply payloads).
134
+
135
+ **In (person to agent):**
136
+
137
+ - Text.
138
+ - Taps on buttons, list rows and template quick replies, as a choice. A quick reply's payload is
139
+ its ID.
140
+ - Quotes, read from Flow.
141
+ - Reactions to the agent's messages.
142
+ - Late failures (`message.failed`), told to the agent as "Not delivered".
143
+ - Photos, voice notes, audio and documents, as attachments. Their bytes are fetched from
144
+ `GET /v1/files/{id}` only when the agent can use them. A voice note Flow already transcribed
145
+ arrives as its transcript.
146
+ - A video, a file Flow did not keep, a location or a contact card arrives as a note in the text.
147
+
148
+ ## What it does for you
149
+
150
+ - Every send carries its own Idempotency-Key. A 429 or 5xx is tried again with the same key after
151
+ a short Retry-After, so a message never goes out twice.
152
+ - Flow's `409 outside_window` becomes the framework's `outside_window`, so the agent knows to send a
153
+ template.
154
+ - A permanent refusal (opted out, no consent, ...) reaches the agent once, with "don't resend it".
155
+ - Signatures are checked: HMAC-SHA256, five minutes' tolerance, and any of the secrets you gave.
156
+ - An event Flow delivers twice (or by both the stream and a webhook) reaches the agent once. On the
157
+ webhook a failure answers 500, so Flow delivers the event again; on the stream it comes again on
158
+ the next connection.
159
+ - `contact.deleted` makes it forget the contact.
160
+ - Keys and message text are never logged.
161
+
162
+ What it remembers (conversations, contacts, event IDs) is kept in memory, except the stream's
163
+ `cursorFile`. After a restart, the
164
+ first send to someone starts from the channel's sender, and Flow puts it into the open conversation
165
+ if there is one.
166
+
167
+ The framework has no Telegram or iMessage channel kind yet, so both are custom channels. On iMessage
168
+ a person is their phone number, or their email address when that is their handle; they are not joined
169
+ with the same number on WhatsApp or SMS.
170
+
171
+ **iMessage replies only.** On Flow's shared iMessage sender (as on WhatsApp after 24 hours) the agent
172
+ can only answer someone who wrote first. A nudge or scheduled message to anyone else fails, and
173
+ reaches the agent as "Not delivered" (`outside_window`). Ask people to write first with the line's
174
+ opt-in link. Text takes up to 9999 characters; there are no buttons, lists or templates.
175
+
176
+ ## Also exported
177
+
178
+ - `verifySignature(body, header, secrets)`: checks a `Flow-Signature` header yourself.
179
+ - `FlowMessagingError`: the API's typed error (`status`, `type`, `channelCode`, `retryAfter`).
180
+ - `API_VERSION`: the API version every request asks for, `2026-11-01`.
181
+ - `TELEGRAM`, `IMESSAGE`: the Telegram and iMessage channels' limits, as framework `ChannelInfo`s.
182
+
183
+ ## Links
184
+
185
+ - Flow Messaging: https://api.flow.engineer
186
+ - API reference: https://api.flow.engineer/openapi.yaml
187
+ - Flow Framework: https://www.npmjs.com/package/flow
188
+
189
+ ## License
190
+
191
+ Apache-2.0, as the rest of this repository.
package/package.json CHANGED
@@ -1,13 +1,68 @@
1
1
  {
2
2
  "name": "@flow-engineer/flow",
3
- "version": "0.0.1",
4
- "description": "Flow Framework (coming soon)",
5
- "main": "index.js",
3
+ "version": "0.2.1",
4
+ "description": "An open-source runtime for chat agents that feel like a person texting back.",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/flow-engineer/flow.git"
9
+ },
10
+ "homepage": "https://github.com/flow-engineer/flow",
11
+ "type": "module",
12
+ "engines": {
13
+ "node": ">=22.18"
14
+ },
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/src/index.d.ts",
18
+ "default": "./dist/src/index.js"
19
+ },
20
+ "./simulators": {
21
+ "types": "./dist/simulators/src/index.d.ts",
22
+ "default": "./dist/simulators/src/index.js"
23
+ },
24
+ "./flow-messaging": {
25
+ "types": "./dist/flow-messaging/src/index.d.ts",
26
+ "default": "./dist/flow-messaging/src/index.js"
27
+ },
28
+ "./testing": {
29
+ "types": "./dist/testing/src/index.d.ts",
30
+ "default": "./dist/testing/src/index.js"
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "bin": {
35
+ "flow": "dist/testing/src/cli.js",
36
+ "chinwag": "dist/testing/src/chinwag.js"
37
+ },
38
+ "files": [
39
+ "dist",
40
+ "examples/cafe/agent",
41
+ "examples/cafe/src",
42
+ "examples/cafe/tests",
43
+ "examples/http-agent",
44
+ "flow-messaging/README.md",
45
+ "docs/agents.md",
46
+ "coding-agents",
47
+ "spec",
48
+ "README.md",
49
+ "LICENSE"
50
+ ],
6
51
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
52
+ "build": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json && node -e \"for (const f of ['page.html', 'person.html']) fs.copyFileSync('simulators/src/' + f, 'dist/simulators/src/' + f)\"",
53
+ "prepack": "npm run build",
54
+ "test": "node --test --test-timeout=30000 \"test/**/*.test.ts\"",
55
+ "test:changed": "node scripts/test-changed.ts",
56
+ "new": "node scripts/new-agent.ts",
57
+ "typecheck": "tsc --noEmit"
58
+ },
59
+ "dependencies": {
60
+ "openai": "^7.32.0",
61
+ "yaml": "^2.9.1",
62
+ "zod": "^4.6.5"
8
63
  },
9
- "keywords": [],
10
- "author": "",
11
- "license": "ISC",
12
- "type": "commonjs"
64
+ "devDependencies": {
65
+ "@types/node": "^26.6.5",
66
+ "typescript": "^7.0.2"
67
+ }
13
68
  }