@flow-engineer/flow 0.0.0-stage → 0.2.0

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 (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +594 -2
  3. package/coding-agents/skill.md +321 -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 +34 -0
  7. package/dist/flow-messaging/src/client.js +130 -0
  8. package/dist/flow-messaging/src/index.d.ts +37 -0
  9. package/dist/flow-messaging/src/index.js +409 -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 +17 -0
  35. package/dist/src/names.js +38 -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/check-endpoint.d.ts +31 -0
  57. package/dist/testing/src/check-endpoint.js +158 -0
  58. package/dist/testing/src/chinwag.d.ts +2 -0
  59. package/dist/testing/src/chinwag.js +6 -0
  60. package/dist/testing/src/cli.d.ts +2 -0
  61. package/dist/testing/src/cli.js +563 -0
  62. package/dist/testing/src/client.d.ts +115 -0
  63. package/dist/testing/src/client.js +955 -0
  64. package/dist/testing/src/http-agent.d.ts +47 -0
  65. package/dist/testing/src/http-agent.js +155 -0
  66. package/dist/testing/src/index.d.ts +3 -0
  67. package/dist/testing/src/index.js +2 -0
  68. package/dist/testing/src/new.d.ts +9 -0
  69. package/dist/testing/src/new.js +122 -0
  70. package/dist/testing/src/print-run.d.ts +3 -0
  71. package/dist/testing/src/print-run.js +22 -0
  72. package/dist/testing/src/protocol.d.ts +159 -0
  73. package/dist/testing/src/protocol.js +28 -0
  74. package/dist/testing/src/report.d.ts +74 -0
  75. package/dist/testing/src/report.js +90 -0
  76. package/dist/testing/src/show.d.ts +36 -0
  77. package/dist/testing/src/show.js +280 -0
  78. package/dist/testing/src/suite.d.ts +87 -0
  79. package/dist/testing/src/suite.js +198 -0
  80. package/examples/cafe/agent/AGENTS.md +10 -0
  81. package/examples/cafe/agent/skills/allergens/SKILL.md +7 -0
  82. package/examples/cafe/src/agent.ts +39 -0
  83. package/examples/cafe/src/main.ts +16 -0
  84. package/examples/cafe/src/tools.ts +62 -0
  85. package/examples/cafe/tests/scenarios/latte-correction.yaml +30 -0
  86. package/examples/cafe/tests/scenarios/order-fails.yaml +26 -0
  87. package/examples/cafe/tests/tools/get_menu.yaml +14 -0
  88. package/examples/cafe/tests/tools/place_order.yaml +29 -0
  89. package/examples/http-agent/server.mjs +32 -0
  90. package/examples/http-agent/tests/scenarios/opening-hours.yaml +16 -0
  91. package/flow-messaging/README.md +152 -0
  92. package/package.json +65 -4
  93. package/spec/agent-endpoint.openapi.yaml +148 -0
  94. package/spec/nudges.openapi.yaml +167 -0
  95. package/spec/request.schema.json +121 -0
  96. package/spec/response.schema.json +284 -0
@@ -0,0 +1,198 @@
1
+ // A test suite on disk: tests/scenarios/*.yaml and tests/tools/*.yaml, each file hashed by its
2
+ // content so the cloud stores it once and a run records exactly which tests it ran.
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
5
+ import { extname, isAbsolute, join } from "node:path";
6
+ import { isMap, isSeq, LineCounter, parseDocument } from "yaml";
7
+ import { z } from "../../src/index.js";
8
+ export function hashOf(content) {
9
+ return createHash("sha256").update(content).digest("hex");
10
+ }
11
+ /** Every scenario and tool file under `testsDir`, in a stable order. */
12
+ export function loadSuite(testsDir) {
13
+ const files = [];
14
+ for (const folder of ["scenarios", "tools"]) {
15
+ const dir = join(testsDir, folder);
16
+ if (!existsSync(dir))
17
+ continue;
18
+ for (const name of readdirSync(dir).filter((n) => n.endsWith(".yaml")).sort()) {
19
+ const content = readFileSync(join(dir, name), "utf8");
20
+ files.push({ path: `${folder}/${name}`, hash: hashOf(content), content });
21
+ }
22
+ }
23
+ return files;
24
+ }
25
+ /** One hash for the whole suite: runs are compared only when it matches. */
26
+ export function suiteHash(files) {
27
+ return hashOf(files.map((f) => `${f.path}:${f.hash}`).sort().join("\n"));
28
+ }
29
+ /** `{{ now [±<n>m|h|d|w]* [@ HH:MM] [| iso|date|time|weekday] }}`, spaces optional between the parts. */
30
+ const CLOCK = /^\s*now(?:\s*[+-]\s*\d+[mhdw])*(?:\s*@\s*(?:[01]\d|2[0-3]):[0-5]\d)?(?:\s*\|\s*(?:iso|date|time|weekday))?\s*$/;
31
+ /** What is wrong with the `{{ now ... }}` placeholders in `text`, or null. A placeholder is the test's clock
32
+ * (`{{now}}`, `{{now + 7d | date}}`, `{{now + 6d @ 10:00}}`), filled by the cloud per run; any other `{{...}}` is left alone. */
33
+ export function placeholderProblem(text) {
34
+ for (const [whole, inner] of text.matchAll(/\{\{([\s\S]*?)\}\}/g)) {
35
+ if (/^\s*now\b/.test(inner) && !CLOCK.test(inner)) {
36
+ return `unknown placeholder ${whole}: write it like {{now + 3d}}, {{now - 2h | time}} or {{now + 1w @ 10:00 | date}}`;
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+ /** Every string in `value` whose placeholders are wrong, with where it is under `value`. */
42
+ function* placeholderProblems(value, path = []) {
43
+ if (typeof value === "string") {
44
+ const problem = placeholderProblem(value);
45
+ if (problem)
46
+ yield { path, problem };
47
+ }
48
+ else if (Array.isArray(value)) {
49
+ for (let i = 0; i < value.length; i++)
50
+ yield* placeholderProblems(value[i], [...path, i]);
51
+ }
52
+ else if (value && typeof value === "object") {
53
+ for (const [key, item] of Object.entries(value))
54
+ yield* placeholderProblems(item, [...path, key]);
55
+ }
56
+ }
57
+ /** A value whose strings may hold placeholders, each checked. */
58
+ const withClock = (schema) => schema.superRefine((value, ctx) => {
59
+ for (const { path, problem } of placeholderProblems(value))
60
+ ctx.addIssue({ code: "custom", message: problem, path });
61
+ });
62
+ /** A time zone Intl knows. */
63
+ function isTimeZone(zone) {
64
+ try {
65
+ new Intl.DateTimeFormat("en-US", { timeZone: zone });
66
+ return true;
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ /** YYYY-MM-DDTHH:MM[:SS], optionally Z or ±HH:MM, naming a real moment. */
73
+ function isDateTime(text) {
74
+ const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?(?:Z|[+-](\d{2}):(\d{2}))?$/.exec(text);
75
+ if (!m)
76
+ return false;
77
+ const [y, mo, d, h, mi, s = 0, oh = 0, om = 0] = m.slice(1).map((part) => (part === undefined ? undefined : Number(part)));
78
+ const day = new Date(Date.UTC(y, mo - 1, d));
79
+ return day.getUTCFullYear() === y && day.getUTCMonth() === mo - 1 && day.getUTCDate() === d && h < 24 && mi < 60 && s < 60 && oh < 24 && om < 60;
80
+ }
81
+ /** A tool call the run must (`calls`) or must not (`never`) make: a tool name, or the tool with input fields it must have. */
82
+ const expectedCall = z.union([
83
+ z.string().min(1),
84
+ z.strictObject({
85
+ tool: z.string().min(1),
86
+ /** Each field must equal the call's input field: nested objects in part, arrays and strings exactly. */
87
+ args: withClock(z.record(z.string(), z.unknown())).optional(),
88
+ }),
89
+ ], { error: "a tool name, or { tool: <name>, args: { <field>: <value> } }" });
90
+ const scenarioSchema = z.object({
91
+ id: z.string().min(1),
92
+ title: z.string().optional(),
93
+ /** Every platform it runs on; each is a run of its own, with its own result. */
94
+ channels: z.array(z.enum(["sms", "whatsapp", "imessage", "telegram", "web"])).min(1),
95
+ /** The IANA time zone of the test's clock; UTC when left out. */
96
+ timezone: z.string().refine(isTimeZone, "not a time zone: use an IANA name such as America/New_York or UTC").optional(),
97
+ /** The test's clock when each run starts, local to `timezone` unless it has an offset; the real time when left out.
98
+ * YAML's default schema reads it as a string; a Date from another schema is taken as its UTC time. */
99
+ now: z
100
+ .preprocess((v) => (v instanceof Date ? v.toISOString().replace(/\.\d+Z$/, "Z") : v), z.string().refine(isDateTime, "not a date and time: write YYYY-MM-DDTHH:MM, with :SS, Z or an offset such as -04:00 if needed"))
101
+ .optional(),
102
+ person: z.object({
103
+ persona: withClock(z.string()),
104
+ goal: withClock(z.string()),
105
+ knows: z.array(withClock(z.string())).optional(),
106
+ }),
107
+ opening: z
108
+ .array(z
109
+ .object({
110
+ say: withClock(z.string()).default(""),
111
+ /** A file the person sends with it (say is its caption): a path in the tests folder. */
112
+ attach: z.object({ kind: z.enum(["image", "audio", "document"]), file: z.string().refine(inTests, "a path inside the tests folder, such as fixtures/receipt.jpg") }).optional(),
113
+ after_ms: z.number().int().min(0).optional(),
114
+ })
115
+ .refine((step) => step.say || step.attach, "say something, attach a file, or both"))
116
+ .min(1),
117
+ world: withClock(z.string()).optional(),
118
+ max_turns: z.number().int().min(1).max(50).default(8),
119
+ expect: z
120
+ .object({
121
+ outcome: z.array(withClock(z.string())).optional(),
122
+ judge: z.array(withClock(z.string())).optional(),
123
+ /** Each must be called at least once in the run; checked by code from the recorded calls. */
124
+ calls: z.array(expectedCall).optional(),
125
+ /** None may be called. */
126
+ never: z.array(expectedCall).optional(),
127
+ })
128
+ .default({}),
129
+ });
130
+ const toolFileSchema = z.object({
131
+ tool: z.string().min(1),
132
+ behaviour: z.string().optional(),
133
+ responses: z
134
+ .array(z.object({
135
+ when: z.string(),
136
+ latency_ms: z.number().int().min(0),
137
+ input: withClock(z.unknown()).optional(),
138
+ output: withClock(z.unknown()).optional(),
139
+ error: withClock(z.string()).optional(),
140
+ }))
141
+ .min(1),
142
+ });
143
+ /** A relative path that stays inside the tests folder. */
144
+ function inTests(path) {
145
+ return !!path && !isAbsolute(path) && !path.split(/[\\/]/).includes("..");
146
+ }
147
+ /** The type of a file a scenario attaches, from its extension. */
148
+ export function mimeOf(path) {
149
+ const types = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", webp: "image/webp", gif: "image/gif", pdf: "application/pdf", ogg: "audio/ogg", opus: "audio/ogg", mp3: "audio/mpeg", m4a: "audio/mp4", wav: "audio/wav" };
150
+ return types[extname(path).slice(1).toLowerCase()] ?? "application/octet-stream";
151
+ }
152
+ /** Parse and check a scenario. The error has one line per problem: `file:line: field: what is wrong`. */
153
+ export function parseScenario(file) {
154
+ return parseFile(scenarioSchema, file);
155
+ }
156
+ export function parseToolFile(file) {
157
+ return parseFile(toolFileSchema, file);
158
+ }
159
+ function parseFile(schema, file) {
160
+ const lines = new LineCounter();
161
+ const doc = parseDocument(file.content, { lineCounter: lines });
162
+ const at = (offset) => `${file.path}:${offset === undefined ? 1 : lines.linePos(offset).line}`;
163
+ if (doc.errors.length) {
164
+ // The YAML parser's own message ends with where it is and a drawing of the line; the prefix says where.
165
+ throw new Error(doc.errors.map((e) => `${at(e.pos[0])}: ${e.message.split("\n")[0].replace(/ at line \d+, column \d+:?$/, "")}`).join("\n"));
166
+ }
167
+ const result = schema.safeParse(doc.toJS());
168
+ if (result.success)
169
+ return result.data;
170
+ throw new Error(result.error.issues.map((issue) => {
171
+ const field = issue.path.map((key, i) => (typeof key === "number" ? `[${key}]` : `${i ? "." : ""}${String(key)}`)).join("");
172
+ const what = /received undefined$/.test(issue.message) ? "missing" : issue.message;
173
+ return `${at(offsetOf(doc.contents, issue.path))}: ${field ? `${field}: ` : ""}${what}`;
174
+ }).join("\n"));
175
+ }
176
+ /** Where the deepest part of `path` that the file has starts: the value, or for a missing key the key that holds it. */
177
+ function offsetOf(root, path) {
178
+ let node = root;
179
+ let offset = root?.range?.[0];
180
+ let holder;
181
+ for (const key of path) {
182
+ if (isMap(node)) {
183
+ const pair = node.items.find((p) => p.key?.value === key);
184
+ if (!pair)
185
+ return holder ?? offset;
186
+ holder = pair.key.range?.[0];
187
+ node = pair.value;
188
+ }
189
+ else if (isSeq(node) && typeof key === "number" && node.items[key]) {
190
+ holder = undefined;
191
+ node = node.items[key];
192
+ }
193
+ else
194
+ break;
195
+ offset = node?.range?.[0] ?? offset;
196
+ }
197
+ return offset;
198
+ }
@@ -0,0 +1,10 @@
1
+ # Pip
2
+
3
+ You are Pip, the ordering assistant for Bean & Leaf, a small café. People text you to order drinks and food for pickup, ask what's on the menu, and check on their order.
4
+
5
+ - Use get_menu for anything about items, sizes, milks or prices; never guess a price.
6
+ - Before placing an order, read it back once with the total and ask them to confirm. Place it with place_order only after they say yes.
7
+ - When the order is placed, tell them the order number and when it will be ready.
8
+ - Keep messages short and friendly, like a barista texting.
9
+ - Anything about allergies or dietary needs: load the allergens skill first.
10
+ - You can't take payment by text; they pay at pickup.
@@ -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,39 @@
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 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
+ /** Where Pip runs, for a host with no file system (Cloudflare): its files as text, its database, no schedule timer. */
13
+ host?: Pick<AgentOptions, "files" | "database" | "scheduleCheckMs">;
14
+ }
15
+
16
+ export const tools = cafeTools;
17
+
18
+ // The café's numbers and web chat are not connected yet, so every provider refuses to send. Test
19
+ // people write from the playground, and replies to them go back there, never through these.
20
+ const notConnected = async () => {
21
+ throw new Error("not connected yet: only test people in the playground reach Pip for now");
22
+ };
23
+ const provider = { sendMessage: notConnected, react: notConnected, typing: notConnected, markRead: notConnected, status: notConnected };
24
+
25
+ export function buildAgent(options: BuildOptions = {}) {
26
+ return createAgent({
27
+ ...(options.host ?? { dir: fileURLToPath(new URL("../agent", import.meta.url)) }),
28
+ model: options.model ?? openAICompatible({ model: process.env.CAFE_MODEL }),
29
+ tools: options.tools ?? cafeTools,
30
+ events: options.events ?? [],
31
+ channels: [whatsapp(provider), sms(provider), customUI("web", provider)],
32
+ testPeople: [
33
+ { name: "Maria", phone: "+15555550101" },
34
+ { name: "Sam", phone: "+15555550102" },
35
+ { name: "Priya", phone: "+15555550103" },
36
+ ],
37
+ timeZone: "America/New_York",
38
+ });
39
+ }
@@ -0,0 +1,16 @@
1
+ // Pip in the playground: `npm start`, then open the printed link and pick a test person.
2
+
3
+ import { simulators } from "../../../simulators/src/index.ts";
4
+ import { buildAgent } from "./agent.ts";
5
+
6
+ const play = simulators({ title: "Pip · Bean & Leaf" });
7
+ let agent: ReturnType<typeof buildAgent>;
8
+ try {
9
+ agent = buildAgent({ events: [play.events] });
10
+ } catch (error) {
11
+ console.error((error as Error).message); // such as no model key in .env
12
+ process.exit(1);
13
+ }
14
+ const { url } = await play.listen(agent, Number(process.env.PORT ?? 8788));
15
+ console.log(`Pip's playground: ${url}/`);
16
+ console.log(`Every channel side by side: ${url}/person`);
@@ -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,152 @@
1
+ # Flow Messaging channels
2
+
3
+ Put a Flow Framework (`npm i @flow-engineer/flow`) agent on real WhatsApp, Telegram 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 a webhook that checks Flow's signed events and hands
6
+ them to the agent. It needs nothing beyond the framework (it uses `fetch` and `node:crypto`).
7
+
8
+ ```sh
9
+ npm install @flow-engineer/flow
10
+ ```
11
+
12
+ Node 22.18 or later. The adapter ships inside `@flow-engineer/flow`; import it from `@flow-engineer/flow/flow-messaging`.
13
+
14
+ ## Quick start
15
+
16
+ ### 1. Get a key
17
+
18
+ Ask the API for a test key. You don't need an account:
19
+
20
+ ```sh
21
+ curl -X POST https://api.flow.engineer/v1/sandbox/keys
22
+ ```
23
+
24
+ The answer holds your test key (`key`, `fk_test_...`, shown once) and the sandbox senders (`senders`).
25
+ Each sender has an ID (`snd_...`), a link to open on your phone, and a join code to send it.
26
+
27
+ The sandbox has Telegram today. WhatsApp and iMessage come as they open. A sandbox key lasts 7 days
28
+ and covers one contact. Live keys (`fk_live_...`) and your own WhatsApp, Telegram or
29
+ SMS senders are set up from the dashboard at https://api.flow.engineer.
30
+
31
+ Open the Telegram sandbox sender's link on your phone and send its join code. You are now the
32
+ sandbox contact your agent can talk to.
33
+
34
+ ### 2. Run the agent
35
+
36
+ ```ts
37
+ // main.ts
38
+ import { createServer } from "node:http";
39
+ import { createAgent } from "@flow-engineer/flow";
40
+ import { flowMessaging } from "@flow-engineer/flow/flow-messaging";
41
+
42
+ const fm = flowMessaging(); // reads FLOW_MESSAGING_KEY and FLOW_MESSAGING_WEBHOOK_SECRET
43
+
44
+ const agent = createAgent({
45
+ dir: "./agent", // your agent's folder (AGENTS.md, ...)
46
+ model: yourModel,
47
+ // only the channels you have senders for
48
+ channels: [fm.telegram(), fm.whatsapp(), fm.sms()],
49
+ });
50
+ await agent.resume();
51
+
52
+ createServer(fm.webhook(agent)).listen(Number(process.env.PORT ?? 8080));
53
+ ```
54
+
55
+ Already have an HTTP server? Hand the raw body and headers to `fm.receive` instead:
56
+
57
+ ```ts
58
+ const { status, body } = await fm.receive(agent, { body: rawBody, headers: req.headers });
59
+ ```
60
+
61
+ ### 3. Point Flow's webhook at it
62
+
63
+ Flow posts events to a public HTTPS URL. Create a webhook endpoint for it:
64
+
65
+ ```sh
66
+ curl https://api.flow.engineer/v1/webhook_endpoints \
67
+ -H "Authorization: Bearer $FLOW_MESSAGING_KEY" \
68
+ -H "Content-Type: application/json" \
69
+ -d '{"url": "https://your-agent.example.com/", "events": ["message.received", "message.failed", "reaction.added", "contact.deleted"]}'
70
+ ```
71
+
72
+ The answer holds the signing secret (`secret`, `whsec_...`), shown once. Save it as
73
+ `FLOW_MESSAGING_WEBHOOK_SECRET`.
74
+
75
+ On a laptop there is no public URL. Run a tunnel to the port (for example
76
+ `cloudflared tunnel --url http://localhost:8080`) and register that URL.
77
+
78
+ ## Environment
79
+
80
+ | Variable | What it is |
81
+ |---|---|
82
+ | `FLOW_MESSAGING_KEY` | Your app's API key, `fk_test_...` or `fk_live_...`. Keep it on your server. |
83
+ | `FLOW_MESSAGING_WEBHOOK_SECRET` | The webhook endpoint's signing secret, `whsec_...`. While you rotate it, give both, comma-separated. |
84
+ | `FLOW_MESSAGING_TELEGRAM_SENDER`, `FLOW_MESSAGING_WHATSAPP_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. |
85
+ | `FLOW_MESSAGING_BASE_URL` | Optional. Default `https://api.flow.engineer`. |
86
+
87
+ Each of these can also be passed in code: `flowMessaging({ apiKey, webhookSecret, baseUrl })`, and
88
+ `fm.telegram({ sender, name, instructions })` (the same for `whatsapp` and `sms`).
89
+
90
+ ## What it maps
91
+
92
+ **Out (agent to person):**
93
+
94
+ - Text: Markdown on Telegram, plain text on WhatsApp and SMS.
95
+ - Reply buttons and lists, sent as Flow `buttons`. WhatsApp shows up to 3 reply buttons, or a list
96
+ for 4 to 10. Telegram shows an inline keyboard.
97
+ - A link: one URL button (WhatsApp's call-to-action button).
98
+ - A photo: a media message with the text as its caption. Flow's button messages have no image
99
+ header, so a photo with buttons or a link goes just before them.
100
+ - Quotes (`reply_to`), reactions, typing, and read receipts (WhatsApp).
101
+ - WhatsApp templates, by name or `tpl_` ID, with their header (text, or media by URL or file ID),
102
+ body values (`{{1}}`, `{{2}}`, ...) and button values (URL suffixes, quick-reply payloads).
103
+
104
+ **In (person to agent):**
105
+
106
+ - Text.
107
+ - Taps on buttons, list rows and template quick replies, as a choice. A quick reply's payload is
108
+ its ID.
109
+ - Quotes, read from Flow.
110
+ - Reactions to the agent's messages.
111
+ - Late failures (`message.failed`), told to the agent as "Not delivered".
112
+ - Photos, voice notes, audio and documents, as attachments. Their bytes are fetched from
113
+ `GET /v1/files/{id}` only when the agent can use them. A voice note Flow already transcribed
114
+ arrives as its transcript.
115
+ - A video, a file Flow did not keep, a location or a contact card arrives as a note in the text.
116
+
117
+ ## What it does for you
118
+
119
+ - Every send carries its own Idempotency-Key. A 429 or 5xx is tried again with the same key after
120
+ a short Retry-After, so a message never goes out twice.
121
+ - Flow's `409 outside_window` becomes the framework's `outside_window`, so the agent knows to send a
122
+ template.
123
+ - A permanent refusal (opted out, no consent, ...) reaches the agent once, with "don't resend it".
124
+ - Signatures are checked: HMAC-SHA256, five minutes' tolerance, and any of the secrets you gave.
125
+ - An event Flow delivers twice reaches the agent once. A failure answers 500, so Flow delivers the
126
+ event again.
127
+ - `contact.deleted` makes it forget the contact.
128
+ - Keys and message text are never logged.
129
+
130
+ What it remembers (conversations, contacts, event IDs) is kept in memory. After a restart, the
131
+ first send to someone starts from the channel's sender, and Flow puts it into the open conversation
132
+ if there is one.
133
+
134
+ The framework has no Telegram channel kind yet, so Telegram is a custom channel. iMessage is not in this
135
+ package yet.
136
+
137
+ ## Also exported
138
+
139
+ - `verifySignature(body, header, secrets)`: checks a `Flow-Signature` header yourself.
140
+ - `FlowMessagingError`: the API's typed error (`status`, `type`, `channelCode`, `retryAfter`).
141
+ - `API_VERSION`: the API version every request asks for, `2026-11-01`.
142
+ - `TELEGRAM`: the Telegram channel's limits, as a framework `ChannelInfo`.
143
+
144
+ ## Links
145
+
146
+ - Flow Messaging: https://api.flow.engineer
147
+ - API reference: https://api.flow.engineer/openapi.yaml
148
+ - Flow Framework: https://www.npmjs.com/package/flow
149
+
150
+ ## License
151
+
152
+ MIT, as the rest of this repository.