@flow-engineer/flow 0.0.1 → 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 +595 -0
  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 +62 -8
  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,321 @@
1
+ ---
2
+ name: flow-framework
3
+ description: Build or change a chat agent on Flow Framework (WhatsApp, SMS, iMessage, Telegram, web). Load when creating a Flow Framework agent, editing its prompt or skills, wiring it into the playground, or when code imports @flow-engineer/flow (formerly chinwag). Also load the flow-framework-tools skill (tools-skill.md) before writing tools and flow-framework-tests (test-skill.md) before writing tests.
4
+ ---
5
+
6
+ # Building an agent on Flow Framework
7
+
8
+ Flow Framework runs chat agents that feel like a person texting back: it folds double texts into one
9
+ reply, acknowledges people fast, runs tools in parallel and waits for them, moving one to the
10
+ background only when the person writes again or it passes its limit.
11
+ You write the agent's prompt, skills and tools; Flow Framework handles the conversation.
12
+
13
+ Two more skills go with this one: `flow-framework-tools` (`tools-skill.md`) before writing or changing a
14
+ tool, and `flow-framework-tests` (`test-skill.md`) before writing or changing a test.
15
+
16
+ ## Start a new agent
17
+
18
+ `npx @flow-engineer/flow new <name>` (lowercase letters, digits and dashes) makes `./<name>/` from the café
19
+ example: a complete agent with tools and tests that depends on the `@flow-engineer/flow` package and imports
20
+ `@flow-engineer/flow` and `@flow-engineer/flow/simulators`, with these skills in `.claude/skills/`. Run `npm install` there.
21
+ Build in it, changing Pip's prompt, skills, tools and tests into yours. The owner copies
22
+ `.env.example` to `.env` and puts one model key in it (never read or print it). Flow Framework does not
23
+ issue that key; it is made at the provider: `OPENROUTER_API_KEY` at https://openrouter.ai/keys,
24
+ `OPENAI_API_KEY` at https://platform.openai.com/api-keys, `ANTHROPIC_API_KEY` at
25
+ https://console.anthropic.com/settings/keys, or `GEMINI_API_KEY` at https://aistudio.google.com/apikey.
26
+ The first one with a value is used, and `CAFE_MODEL` sets the café's model id. Then `npm start`
27
+ opens the playground; `npm run typecheck` checks the types.
28
+
29
+ Inside a clone of Flow Framework itself, `npm run new -- <name>` copies the café to `agents/<name>/`
30
+ instead (git-ignored, so it never collides with a pull). Its imports of `../../../src/` and
31
+ `../../../simulators/src/` stay as they are, so changes to Flow Framework show at once; the key goes in
32
+ `agents/<name>/.env`. The snippets below import `@flow-engineer/flow` and `@flow-engineer/flow/simulators`; in a clone's
33
+ agent, keep its relative imports.
34
+
35
+ ## Layout
36
+
37
+ ```
38
+ my-agent/
39
+ agent/AGENTS.md the agent's prompt (the model reads it every turn)
40
+ agent/skills/<name>/SKILL.md skills the agent loads when it needs them
41
+ src/tools.ts the agent's tools (see tools-skill.md)
42
+ src/agent.ts buildAgent(): the one place the agent is built
43
+ src/main.ts runs it in the playground
44
+ tests/tools/*.yaml how each tool behaves in tests (see tools-skill.md)
45
+ tests/scenarios/*.yaml the tests (see test-skill.md)
46
+ ```
47
+
48
+ `agent/AGENTS.md` is the agent's prompt, not instructions for coding agents. Keep it short: what
49
+ the agent is for, what it may and may not do. Don't explain messaging in it: Flow Framework adds how to
50
+ talk to the person (send_<channel>, react, acknowledging first, channel formatting) on its own.
51
+
52
+ A skill's `SKILL.md` starts with `name` and `description`; the agent sees every description and
53
+ loads the full skill only when it decides it needs it. Put rules for one kind of request in a
54
+ skill, not in AGENTS.md.
55
+
56
+ ## buildAgent
57
+
58
+ Build the agent in one function that the playground and the tests both call, so they never
59
+ drift apart. Tests pass their own model, events and simulated tools:
60
+
61
+ ```ts
62
+ import { fileURLToPath } from "node:url";
63
+ import { createAgent, openAICompatible, type EventSink, type Model, type Tool } from "@flow-engineer/flow";
64
+ import { tools as myTools } from "./tools.ts";
65
+
66
+ export const tools = myTools;
67
+
68
+ export function buildAgent(options: { model?: Model; events?: EventSink[]; tools?: Tool[] } = {}) {
69
+ return createAgent({
70
+ dir: fileURLToPath(new URL("../agent", import.meta.url)),
71
+ model: options.model ?? openAICompatible({ model: "anthropic/claude-haiku-5.5" }),
72
+ tools: options.tools ?? myTools,
73
+ events: options.events ?? [],
74
+ });
75
+ }
76
+ ```
77
+
78
+ Other `createAgent` options: `files` replaces `dir` on hosts with no file system (the folder's
79
+ text by path: `{ "AGENTS.md": ..., "skills/<name>/SKILL.md": ... }`); `profile({ conversationId, person, channel })` is what the business knows about
80
+ the person, offered as the built-in tool `get_user_profile` and run only when the agent calls it (put
81
+ lookups here, not in `context`); `context(conversationId)` adds small facts to the system prompt at
82
+ every turn, before every reply, so it must not call APIs; Flow Framework already tells the agent the time; `intercept(conversationId, text, message)` answers a
83
+ message without the model (return the reply, `""` for silence, or `null` to let the agent answer;
84
+ the message and the reply are kept in the history, so the next turn knows, and in the transcript,
85
+ and are `message.in`/`message.out` events marked `answered: true`);
86
+ `deadlineMs` (default 60,000) is the longest a reply waits for a tool before it moves to the
87
+ background (set a tool's own `deadlineMs` for a known-slow one); `partialAfterMs` (default 5,000)
88
+ is how long a finished tool's result waits for the others of its response; `shareUpdates` turns live status lines on web and app UIs off, or replaces their
89
+ guidance with a string.
90
+
91
+ Models are reached through any OpenAI-compatible API. `openAICompatible()` with no key reads the
92
+ first of `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` and `GEMINI_API_KEY` that has a
93
+ value, and uses that provider's default model (`MODEL` overrides it). With none it stops at once and
94
+ names them. Pass `apiKey` and `baseURL` for anywhere else (`baseURL` alone for a local server that needs no key).
95
+
96
+ Which model: recommend Claude Haiku 5.5 (`anthropic/claude-haiku-5.5`, the default above) or OpenAI
97
+ GPT-6.1 Sol (`openai/gpt-6.1-sol`, about 3x the cost, the most exact). If the owner asks for Luna
98
+ (`~openai/gpt-luna-latest`, `openai/gpt-6-luna`), advise against it before using it: with tools
99
+ that run in the background it repeats tool calls, sends extra "on it" messages and answers twice,
100
+ where Haiku and Sol send one acknowledgement and one answer (measured on Flow Framework's own checks, 12 Oct
101
+ 2026). Use it only if they still want it.
102
+
103
+ ## Channels, people and the database
104
+
105
+ In production, give the agent its channels once and hand it each incoming message. Each channel
106
+ is a definition in its own file; Flow Framework knows the platform (WhatsApp's limits, SMS segments), your
107
+ provider only says how to send:
108
+
109
+ ```ts
110
+ // src/channels/whatsapp.ts: one sendMessage, plus whatever else your provider can do
111
+ export const wa = whatsapp({
112
+ sendMessage: (to, m) => api.send(to, m), // m: { text, replyTo?, buttons?, list?, link?, image? }
113
+ react: (to, id, emoji) => api.react(to, id, emoji),
114
+ // no typing() or markRead(): those features are off, and the agent is never offered them
115
+ }, { disable: ["lists"] });
116
+
117
+ // src/channels/app.ts: your own web or mobile UI, with params of your own
118
+ export const app = customUI("app", {
119
+ params: { app_link: z.string().describe("A link a tool result gave you; the app shows it as a button") },
120
+ instructions: "Only fill app_link with a URL from a tool result in this conversation; never make one up.",
121
+ sendMessage: (to, m) => myApp.push(to, m), // m.text, m.app_link
122
+ });
123
+
124
+ createAgent({ ..., channels: [wa, sms(twilioLike), app], database: sqlite("data/chinwag.db"),
125
+ resolve: ({ channel, address }) => members.idFor(channel, address), timeZone: "America/New_York" });
126
+ // A person's time zone is never guessed from timeZone (the business's): set it when you know it,
127
+ // agent.setTimeZone({ to, timeZone }) or peopleHandler over HTTP; else the agent asks them.
128
+ await agent.resume(); // a durable database: always before the first message
129
+ await agent.message({ channel: "whatsapp", from: "+15550100100", message: "hi" });
130
+ ```
131
+
132
+ - Several processes or machines need Postgres, not a SQLite file: `database: postgres(pool)`
133
+ with the app's own node-postgres `Pool` (Flow Framework adds no Postgres dependency). An agent that must
134
+ not hold database credentials gets `store` instead of `database`: any object with Store's
135
+ methods (`StoreApi`), such as a client that forwards each call to a service running `new
136
+ Store(postgres(pool), agentId)`.
137
+ - Every send tool can carry a `link` ({ label, url }, https): a URL button where the channel has
138
+ one (`linkButton`, WhatsApp's cta_url), else Flow Framework puts the URL on a line after the text. Channels
139
+ with `images` also offer an `image` ({ url }, https), the text its caption; elsewhere the text
140
+ goes alone. WhatsApp's rules hold everywhere: a link never with buttons or a list, an image never
141
+ with a list. A provider without them says so (`features: { links: false, images: false }`) and
142
+ never gets them; the URLs come from your tools' results, like app_link's.
143
+ - The simulators and the test cloud play an agent's own channels with these definitions (the web
144
+ simulator plays a customUI), so the model sees the production tools; they show params such as
145
+ app_link under the message instead of calling your sendMessage.
146
+ - `instructions` (on `customUI`, and in `whatsapp()`'s and `sms()`'s options) is your guidance for
147
+ the agent on that channel, in its prompt only while the person uses it. Data a param carries,
148
+ such as a link, comes from your tools' results: a tool that has a screen returns its link
149
+ (`{ ..., app_link }`), and the agent only passes it on.
150
+ - One person is one conversation across every channel they use. Channels are joined only by what
151
+ each verified: your `resolve` key, else the same phone number on channels marked `phone`. Never
152
+ join people any other way. When the SMS provider gives national numbers, set
153
+ `defaultCountryCode` ("+91") so they match the full number WhatsApp gives.
154
+ - Tools key a person's data by `context.person`, their stable id: `key:<resolve's value>`, else
155
+ `phone:<E.164>`, else `<channel>:<address>`. `context.key` is `resolve`'s value as returned, or
156
+ null without one: look them up in the backend by that, never by `context.person`.
157
+ - With several channels the agent gets a send tool per channel (`send_sms`, `send_web`) and
158
+ answers each message where it came. Don't add your own.
159
+ - Run exactly one copy of the agent per database (one instance, kept warm, CPU always on): a
160
+ conversation lives in one process, so two copies would run overlapping turns for it.
161
+ - After a restart, `resume()` carries on: cut-off turns run again, background tools that were
162
+ running are reported as interrupted (never rerun).
163
+ - `agent.receive(conversationId, message, deliver)` still works for one channel at a time (the
164
+ test cloud uses it).
165
+ - Photos, voice notes and documents: hand them in on the message, `message: { text: caption, attachments: [{ kind: "image" | "audio" | "document", mime, name?, data: () => provider.download(id) }] }`.
166
+ `data` is a loader your provider fetches with its own credentials; never put a media URL where
167
+ the model sees it. Photos (JPEG, PNG, WebP, GIF, up to 5 MB) and PDFs (up to 10 MB) go to the
168
+ model when it reads them (`openAICompatible({ images, pdfs })`: images default on, PDFs on with
169
+ OpenRouter; set `images: false` for a text-only model). Voice notes need
170
+ `createAgent({ transcribe: async ({ mime, data }) => text })` with the speech-to-text the owner
171
+ picks; Flow Framework has none. Anything the agent can't read reaches it named, with why, so it asks
172
+ for text. The latest 3 photos and PDFs stay shown; their bytes are never stored.
173
+
174
+ Built in, so don't build them as tools: what the business knows about the person (`profile`, as
175
+ `get_user_profile`), scheduled messages (`schedule_message`, `list_scheduled`,
176
+ `cancel_scheduled`, in the person's local time), and what a person asked not to be messaged about
177
+ (`add_no_contact`, `remove_no_contact`), quiet hours they set (`set_quiet_hours`) and pauses
178
+ (`pause_messages`). STOP, START and HELP are built in on `sms()` and
179
+ `whatsapp()`: don't build them in `intercept`. STOP stops every message the business starts, with
180
+ no model call, until START; set the replies with `keywords: { replies: { stop, start, help } }`.
181
+ `intercept` sees them first, with `message.keyword` `"stop"`, `"start"` or `"help"` (else `null`)
182
+ and `message.channel` (the channel's name in `channels`, on every message): an agent with its own
183
+ opt-out flag sets it there
184
+ (`if (message.keyword === "stop") optedOut.add(conversationId)`; only on SMS:
185
+ `message.channel === "sms" && ...`). Flow Framework keeps or lifts the STOP
186
+ whatever the hook returns or throws; a reply it returns goes instead of Flow Framework's, never both.
187
+ SMS takes only STOP by default; the words carriers or regulators expect differ by country, so
188
+ check the market's rules (`keywords: US_CARRIER_KEYWORDS` adds STOPALL, UNSUBSCRIBE, CANCEL, END
189
+ and QUIT, as some US carrier rules expect; `keywords: { stop: [...] }` sets your own).
190
+
191
+ Messages the business starts are nudges: `agent.nudge({ to: { channel, address }, text })` sends
192
+ as written; `prompt` instead lets the agent write it (or decide not to); `template` sends a
193
+ WhatsApp template you already set up. Pass `idempotencyKey` when your system may retry, and `at`
194
+ to send later. Prefer `prompt`; use `text` only when the wording must be exact. Outside WhatsApp's
195
+ 24-hour window only templates go out: declare a `fallbackTemplate` ("You have a new message. Reply to
196
+ see it.", no values) so a nudge is held until they reply instead of refused (`outside_window`); the
197
+ agent may send a fitting template itself (`send_template`), and `template.missing` events say which
198
+ messages want one. Held nudges (quiet hours, a closed window) settle as `held`, then `sent` or `expired`.
199
+
200
+ When the owner's backend is a separate service, mount `nudgeHandler(agent, { token })` (from
201
+ `@flow-engineer/flow`; a token of at least 32 characters, from a secret; `base` is `/nudges` by default) in the
202
+ agent's server rather than writing a route: `POST /nudges` takes the same fields with
203
+ `idempotency_key` and `at` in ms since the epoch, and answers `202 { id, status }` once recorded,
204
+ before any send or turn (`queued`, or `refused`/`dropped`/`scheduled` decided then: still a 202);
205
+ `GET /nudges/<id>` reads its state. Both need `Authorization: Bearer <token>`. Contract:
206
+ `spec/nudges.openapi.yaml`.
207
+
208
+ Templates are WhatsApp only. Declare the ones the owner set up with Meta beside `testPeople`, so
209
+ Flow Framework checks each send against them and the playground shows them as WhatsApp does:
210
+
211
+ ```ts
212
+ createAgent({
213
+ ...,
214
+ templates: [{
215
+ name: "order_ready", language: "en", category: "utility", // or marketing, authentication
216
+ header: { kind: "text", text: "Order {{1}}" }, // or { kind: "image" | "video" | "document" }
217
+ body: "Hi {{1}}, your {{2}} is ready.",
218
+ footer: "Reply STOP to opt out",
219
+ buttons: [
220
+ { type: "quick_reply", text: "On my way" },
221
+ { type: "url", text: "Track", url: "https://example.com/o/{{1}}" },
222
+ { type: "phone", text: "Call us", phone: "+15555550100" },
223
+ ],
224
+ }],
225
+ });
226
+
227
+ await agent.nudge({ to, template: {
228
+ name: "order_ready",
229
+ header: { kind: "text", text: "1042" }, // media: { kind: "image", url } or { kind, fileId }
230
+ body: ["Maria", "latte"], // {{1}}, {{2}}, ... in order
231
+ buttons: [{ index: 0, value: "omw_1042" }, { index: 1, value: "1042" }], // a quick reply's payload, a URL's end
232
+ } });
233
+ ```
234
+
235
+ A declared template sent with the wrong values (a body value too few or many, a missing header or
236
+ URL value) throws from `nudge` before anything is sent, in the playground and live alike. One not
237
+ declared goes as it is (the provider checks it) and the playground shows its name and values. A
238
+ quick-reply tap comes back as the person's message with `choice: { id: payload, title }`, like a
239
+ reply button, and the agent is told what the template said. An ordinary message has one kind of
240
+ button: up to 3 reply buttons OR one link button, never both. A template may mix quick replies and
241
+ URL buttons.
242
+
243
+ ## Ask the owner when you set an agent up
244
+
245
+ Before the agent sends anything people did not ask for (nudges, follow-ups), ask the owner these
246
+ two, with these options, and build what they pick:
247
+
248
+ 1. **Quiet hours** (what the business starts waits for them, in each person's own time):
249
+ "22:00–07:00 (recommended)", "other hours", or "none" (`quietHours: false`). For US marketing,
250
+ say that 8am–9pm the recipient's time is, as far as we know, the legal window, and check theirs.
251
+ 2. **Where people's time zones come from**, since quiet hours apply only where it is known:
252
+ "our database" (sync it with `agent.setTimeZone` or `peopleHandler`), or "we don't know" (the
253
+ agent asks people when it needs it; until then nothing is held for quiet hours).
254
+
255
+ ## The playground
256
+
257
+ Every agent ships a test playground: people test it as real users before its numbers are live.
258
+ Declare who may be played in `createAgent({ testPeople: [{ name: "Maria", phone: "+15555550101" }] })`;
259
+ no one else can be. A test person is a real person to the agent: the same identity, `resolve`,
260
+ `intercept`, history and schedules as when that number writes through a provider (the playground
261
+ is just another phone; they may be test members in your real backend). On phone channels their
262
+ address is the number; on others `test:<number>`, joined to the same person. One person is one
263
+ conversation across every channel: switch channels and the chat resumes. A listed number may also
264
+ write from a real provider once a channel is live; replies, scheduled messages and nudges go back
265
+ wherever that person last wrote from on that channel.
266
+
267
+ `src/main.ts` serves it with the simulators (SMS, WhatsApp, and the web simulator for a custom UI),
268
+ each with that platform's real rules, on the agent's own `channels` (declare them, with a provider
269
+ that is not connected yet if the numbers are not ready: test people never reach it):
270
+
271
+ ```ts
272
+ import { simulators } from "@flow-engineer/flow/simulators";
273
+ import { buildAgent } from "./agent.ts";
274
+
275
+ const play = simulators({ title: "My agent" });
276
+ const agent = buildAgent({ events: [play.events] });
277
+ await play.listen(agent, 8788); // 127.0.0.1 only, its own page only; no token needed there
278
+ ```
279
+
280
+ Open the printed link, pick a test person and a channel, and chat; `/person` shows every channel
281
+ side by side. The page shows the person's conversation from the agent's store, so it survives
282
+ restarts (with a durable `database`), and Clear history forgets the person entirely
283
+ (`agent.forgetPerson(conversationId)`: history, schedules, everything). The side panel shows how
284
+ fast each message was acknowledged and a trace of every model call and tool. `cards` says how a
285
+ custom UI shows a link in its params: `{ match: "/video/", label: "Start video call", kind: "call" }`;
286
+ any other link is "Open in app".
287
+
288
+ The Person tab also moves the agent's clock when you give it one (`const clock = playgroundClock()`,
289
+ `simulators({ clock })` and `createAgent({ now: clock.now })`), lists what is scheduled or held for
290
+ the person, sets their time zone and quiet hours, and sends nudges with every field the API takes.
291
+
292
+ To demo what your backend does (an order ships), send the nudge it would send from the Person tab.
293
+
294
+ On a deployed agent, mount it in your own server with a token of at least 32 characters, from a
295
+ secret: `const playground = play.handler(agent, { token: process.env.PLAYGROUND_TOKEN!, base: "/playground" })`,
296
+ and hand it the requests under `/playground` (`if (url.pathname.startsWith("/playground")) return playground(req, res)`).
297
+ Open `/playground/?token=...` once (it becomes an HttpOnly cookie and leaves the URL) or call the API
298
+ with `Authorization: Bearer <token>`. Wrong tokens are limited per address, and every simulated
299
+ message is logged with the person, channel, time and address.
300
+
301
+ ## Send traces to a dashboard
302
+
303
+ Every turn can go to a traces endpoint as an OTLP/JSON trace, posted to `<url>/v1/traces`, so
304
+ playground and real chats show up in a dashboard and can be graded. No code: set these in the
305
+ agent's environment. The address is https://flow.engineer/observe unless `FLOW_FRAMEWORK_TRACES_URL`
306
+ names another; the trace key is made on that same site (never print it):
307
+
308
+ ```sh
309
+ FLOW_FRAMEWORK_TRACES_KEY=...
310
+ FLOW_FRAMEWORK_TRACES_URL=https://flow.engineer/observe # the default; set it only to send them elsewhere
311
+ FLOW_FRAMEWORK_TRACES_CONTENT=1 # send what was written; without it, metadata only
312
+ ```
313
+
314
+ Without `FLOW_FRAMEWORK_TRACES_CONTENT=1` the dashboard
315
+ gets ids, timing, model calls and tool names but no messages, so nothing to show or grade. Or in
316
+ code: `createAgent({ ..., traces: { url, key, content: true, version: "v3" } })`. Traces are sent in the
317
+ background after each turn and a failed send is dropped (logged once), so a dashboard outage never
318
+ touches a chat; call `await agent.flushTraces()` before the process exits. Tests run under
319
+ `node --test` never send from the environment. Under `flow test` the traces say they are a test's
320
+ (`chinwag.run_kind` is "test", else "production", and each turn names the test session as
321
+ `chinwag.test.session`), so the dashboard keeps them apart from real conversations.
@@ -0,0 +1,223 @@
1
+ ---
2
+ name: flow-framework-tests
3
+ description: Write or change tests for a Flow Framework agent (tests/scenarios/*.yaml). Load when adding a test scenario, deciding what a Flow Framework agent should be checked on, or turning a bad conversation into a test.
4
+ ---
5
+
6
+ # Writing tests for a Flow Framework agent
7
+
8
+ Read the `flow-framework` skill (`skill.md`) first if you haven't.
9
+
10
+ A test is a scenario: a situation to put the agent in, played through a platform simulator by a
11
+ model acting as the person, with the agent's tools played by a model from `tests/tools/*.yaml`
12
+ (see the `flow-framework-tools` skill, `tools-skill.md`). At the end, what happened is checked against `expect`.
13
+
14
+ ```yaml
15
+ id: latte-correction
16
+ title: Orders a latte, corrects the milk mid-thought, confirms
17
+ channels: [whatsapp, sms] # runs once on each, with a result for each
18
+
19
+ person:
20
+ persona: Sam, in a hurry on the way to work; types in short bursts, no capitals.
21
+ goal: Get a large oat latte for pickup, under the name Sam.
22
+ knows:
23
+ - Their name is Sam.
24
+
25
+ opening:
26
+ - say: "can i get a large latte"
27
+ - say: "oat milk actually"
28
+ after_ms: 1200
29
+
30
+ world: |
31
+ The menu is as in tests/tools/get_menu.yaml. Oat milk is in stock. The café is busy this
32
+ morning, so the point of sale is slow to take orders, but they go through.
33
+
34
+ max_turns: 8
35
+
36
+ expect:
37
+ outcome:
38
+ - Exactly one order is placed, for one large latte with oat milk, under the name Sam.
39
+ judge:
40
+ - The order read back to Sam uses oat milk, not whole milk.
41
+ - The agent asks Sam to confirm before placing the order.
42
+ - Sam is told the order number and when it will be ready.
43
+ ```
44
+
45
+ ## The parts
46
+
47
+ - `channels`: one or more of sms, whatsapp, imessage, telegram and web; each is a run of its own.
48
+ The run uses that simulator's real rules (SMS has no buttons or reactions; WhatsApp has both).
49
+ - `person`: who the model playing the person is (`persona`), what they want (`goal`), and facts
50
+ they only give if asked (`knows`). It sees only what a person would see.
51
+ - `opening`: the first messages, sent exactly as written; `after_ms` sends one that long after
52
+ the previous one (a double text). Then the person plays on until `goal` is met or `max_turns`
53
+ (default 8). A run that uses every turn never passes.
54
+ A step may send a file: `- { say: "here is my receipt", attach: { kind: image, file: fixtures/receipt.jpg } }`
55
+ (`kind`: image, audio or document; `file` a path in `tests/`; `say` is its caption and may be
56
+ left out). The file is read on your machine and never uploaded; the person and the judge see it
57
+ by name only.
58
+ - `world`: everything true in this test, in plain words. It is the only place the situation is
59
+ set: stock, outages, slowness, the person's history. The tool simulator plays every tool from
60
+ it, choosing among the responses in `tests/tools/*.yaml`. Never write tool responses or
61
+ latencies in a scenario. An agent with a `profile` gets it played from here too
62
+ (`get_user_profile`): say who the person is ("Sam, a member since 2024, on the monthly plan").
63
+ - `expect`:
64
+ - `outcome`: what must have happened, checked against what the tools did ("exactly one
65
+ order is placed", "no order is placed").
66
+ - `judge`: what the conversation must show, one checkable sentence each; a judge model reads
67
+ the transcript and marks each pass or fail with a reason.
68
+ - `calls`: tools that must be called at least once (whatever the call returned); `never`: tools
69
+ that must not be called. Checked by code from the recorded tool calls, not by a model. Each
70
+ entry is a tool name, or `{ tool, args }`: the call matches when every field in `args` equals
71
+ the call's input (nested objects match in part; arrays and strings must be equal).
72
+ - `timezone` (default UTC) and `now`: the test's clock. `now` is when each run starts,
73
+ `YYYY-MM-DDTHH:MM[:SS]`, local to `timezone` unless it ends in `Z` or an offset (`-04:00`); left
74
+ out, it is the real time. Placeholders are filled from it, and the simulated tools and the judge
75
+ are told it; your agent's clock is not changed: the agent still reads the real time. So set `now` only when the agent takes
76
+ its dates from its tools or its context; otherwise leave it out and placeholders follow the real
77
+ date.
78
+
79
+ ```yaml
80
+ timezone: America/New_York
81
+ now: 2026-10-16T09:00
82
+ world: Sam's class is on {{now + 6d @ 10:00}}. There is a free slot s-2 the day after.
83
+ expect:
84
+ calls:
85
+ - cancel_session
86
+ - tool: move_session
87
+ args: { slot_id: s-2 }
88
+ never:
89
+ - delete_account
90
+ ```
91
+
92
+ Dates in test files are placeholders for the test's clock, never written out (a written date goes
93
+ stale): `{{now}}`, then shifts `+` or `-` a number with `m`, `h`, `d` or `w`, then `@ HH:MM` for the
94
+ time of day, then `| iso` (the default, with its offset), `| date` (2026-10-16), `| time` (09:00) or
95
+ `| weekday` (Thursday): `{{now + 7d | date}}`, `{{now - 2h + 30m | time}}`. They work in `world`,
96
+ `person`, `opening`, `expect` and the tool files' `responses`; the cloud fills them on each run. Any other
97
+ `{{...}}` is plain text.
98
+
99
+ How fast the agent first replies to each message is measured on every run from timestamps, not
100
+ written in scenarios: the dashboard shows it for the whole run (average, p95, min and max).
101
+
102
+ ## Choosing what to test
103
+
104
+ - One situation per scenario, named for it (`order-fails`, `latte-correction`).
105
+ - Test what would hurt a real person if it broke: wrong order, a claim the order was placed when
106
+ it wasn't, a missed escalation, a slow or missing acknowledgement.
107
+ - One fact per check. "Called X" or "never called Z" is a `calls` or `never` entry, not an
108
+ `outcome` or `judge` sentence: code checks it the same way every run.
109
+ - Write `judge` items as facts a reader can check from the transcript, not taste ("asks Sam to
110
+ confirm before placing the order", not "is helpful").
111
+ - Cover the unhappy paths the tool files list (busy, down, unknown item) as their own scenarios.
112
+ - When a real conversation went wrong, turn it into a scenario: its world is what was true then,
113
+ its opening is what the person sent.
114
+
115
+ ## Running the tests
116
+
117
+ Tests run in the Flow Framework cloud: your agent stays on your machine with its own model and key and
118
+ connects out to the cloud, which plays the person and the tools and judges.
119
+
120
+ 1. Have a test cloud: the hosted one at https://flow.engineer/observe, the default (see "Get a test
121
+ cloud" below), or another with `--cloud <url>` or `FLOW_FRAMEWORK_CLOUD`.
122
+ 2. From the agent's folder (it must export `buildAgent` and `tools` from `src/agent.ts`), which reads
123
+ the agent's `.env` itself: `npx flow test --synthetic [--runs 3]` (`npm test -- --synthetic`
124
+ in an agent `flow new` made).
125
+ 3. It first reads every test file as the cloud does; a mistake stops it with
126
+ `tests/scenarios/<file>.yaml:<line>: <field>: <what is wrong>`, one line each, and nothing is sent.
127
+ Then it prints PASS or FAIL per run, with its run id, every failed check and the judge's reason,
128
+ and a link to watch each conversation in the simulator. The cloud's dashboard, at its own
129
+ address, shows every session over time and each scenario's history.
130
+ 4. `--json` prints one JSON report on stdout at the end, whatever the exit (everything else goes to
131
+ stderr): `outcome`, `exitCode`, `sessionId`, `agent`, `error`, `counts`, `costUsd` and per run
132
+ `runId`, `scenario`, `channel`, `run`, `verdict`, `error` (the cloud's code when it could not play
133
+ or judge the run, such as `judge_failed`; the run is then `errored` and its line reads `ERROR`,
134
+ whatever it ended as), `ended`, `turns`, `watch` and `failedChecks` (`kind`, `text` and the judge's
135
+ `reason`). Each session that opened also leaves
136
+ `.flow/last-session.json` in the agent's folder (its path is printed on every exit): `sessionId`,
137
+ `agent`, `dashboard`, `suiteHash`, `endedAt`, `ended` (`finished`, `stopped` or `interrupted`),
138
+ `only` (the scenarios it was limited to, else `null`) and per run `scenario` (file name without
139
+ `.yaml`), `channel`, `run`, `runId`, `verdict` (`passed`, `failed`, `errored`: the cloud could not
140
+ play or judge it, such as a failed tool simulator or person model, which is not a failure of your
141
+ agent, or `not_played`: the session ended first) and `failedChecks` (with reasons).
142
+ Read these instead of parsing the printed lines.
143
+ Exit status: 0 all passed; 1 a run failed, or nothing was sent (a wrong flag, a test file that does
144
+ not parse, no key); 2 the connection or the cloud failed, or runs errored and none failed; 3 the
145
+ cloud refused (key, busy, spend cap, capacity); 130 Ctrl-C. On 2 or 3 the agent is not at fault:
146
+ say what the cloud said (`error` in the report), do not change the agent for it.
147
+ 5. `--only a,b` runs only those scenarios (file names without `.yaml`). `--failed` runs only the ones
148
+ that did not pass in the last session, on every channel each plays; it goes with `--runs`, never
149
+ with `--only`. It prints `Nothing failed in the last session (<id>).` and exits 0 when there is
150
+ nothing to run again, and exits 1 when there is no last session.
151
+
152
+ Work in this loop, and never finish on a partial run:
153
+
154
+ 1. `npx flow test --synthetic`: the whole suite.
155
+ 2. Fix what failed (the agent, or a test that asks for the wrong thing; say which).
156
+ 3. `npx flow test --synthetic --failed`, again until every run in it passes (exit 0).
157
+ 4. `npx flow test --synthetic`: the whole suite once more, since a fix can break another scenario.
158
+
159
+ ### When a run fails
160
+
161
+ 1. `npx flow test --synthetic --json > report.json`, and read the runs whose `verdict` is not
162
+ `passed` (`jq '.runs[] | select(.verdict != "passed")' report.json`).
163
+ 2. `npx flow show last` (the first run that did not pass; it names the others) or
164
+ `npx flow show <runId>`: the checks with the judge's reasons, then the conversation in time
165
+ order with every tool call the simulator played, its input, output and what it changed.
166
+ `--json` gives the whole record.
167
+ 3. Decide which is wrong, and say which:
168
+ - the agent: it said or did the wrong thing; fix its prompt, tools or code;
169
+ - the test: a check asks for the wrong thing, or the scenario cannot reach its goal; fix the YAML;
170
+ - the simulator: a tool output that could not happen, or a person who left the scenario; fix
171
+ `tests/tools/*.yaml` or the scenario's `world` and `person`.
172
+ 4. `npx flow test --synthetic --failed`, then `--runs 3 --only <scenario>` on what you fixed, so
173
+ a pass is not luck.
174
+ 5. The whole suite once more.
175
+
176
+ ### Get a test cloud
177
+
178
+ The owner does these; never ask for the key in chat or print it:
179
+
180
+ 1. Sign in at https://flow.engineer/observe (Google, GitHub or an email link).
181
+ 2. Workspace → New agent.
182
+ 3. On the agent's Developers page, "Make a CI key". It starts with `oci_` and is shown once.
183
+ 4. Put it in the agent's `.env` as `FLOW_FRAMEWORK_CLOUD_KEY` (never on the command line).
184
+ 5. `npx flow test --cloud https://flow.engineer/observe --synthetic` from the agent's folder (`--cloud` is that address by default).
185
+
186
+ Your agent runs on your machine with your own model key. The cloud's person simulator, tool simulator and judge are free up to $5 a day per workspace, with 2 sessions at once; test data only (`--synthetic`), and records are deleted after 30 days. `--synthetic` is the promise that the test files hold no real customer or member data;
187
+ the CLI refuses a hosted cloud without it and the key. `--environment staging` or `production` is
188
+ kept with the session.
189
+
190
+ The open goes to the front door with the key; the session then plays at the tunnel the cloud
191
+ answers, with the session's own token. `--agent-version` (else `GITHUB_SHA`) and `--ref` (else
192
+ GitHub's branch) are kept with the session. A CI step (GitHub Actions shown):
193
+
194
+ ```yaml
195
+ - run: npx flow test --cloud https://flow.engineer/observe --synthetic --environment staging
196
+ env:
197
+ FLOW_FRAMEWORK_CLOUD_KEY: ${{ secrets.FLOW_FRAMEWORK_CLOUD_KEY }}
198
+ OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
199
+ ```
200
+
201
+ Only the files in your `tests/` folder run; the cloud keeps each file by its hash and compares
202
+ runs only while the tests are the same. Every run calls real models, so it costs a little.
203
+
204
+ ## An agent on another framework, in any language
205
+
206
+ An agent not built on Flow Framework is tested through an HTTP endpoint: `npx @flow-engineer/flow test --agent-url <url>`.
207
+ To implement one:
208
+
209
+ 1. Read the contract, `spec/agent-endpoint.openapi.yaml` (in the @flow-engineer/flow package or the repository),
210
+ with `spec/request.schema.json` and `spec/response.schema.json`. Implement exactly that: one
211
+ POST per message of the person's, answered in the same response with status 200 and
212
+ `{ "replies": [...] }` (`[]` for no reply, never 204). Keep history by `conversationId`, ignore
213
+ request fields you do not know, and check `authorization: Bearer` when `FLOW_FRAMEWORK_AGENT_TOKEN` is set.
214
+ 2. Only send what the run's platform shows (`x-chinwag-platforms` in the spec): no buttons on SMS,
215
+ text within `maxLength`. Name the person's messages by the `messageId` they came with.
216
+ 3. Start the endpoint and run `npx @flow-engineer/flow check-endpoint <url>` (`--token-env NAME` when it needs
217
+ a token). Fix every FAIL line; it must end "5 of 5 cases passed". Give the agent a mode with no
218
+ model call (the examples' `FAKE_MODEL=1`) so the check costs nothing.
219
+ 4. Then write scenarios as above, with one difference: the agent's own tools run for real, so there
220
+ are no `tests/tools/*.yaml`, no `world` for tools and no `outcome`, `calls` or `never` checks
221
+ (the cloud sees no tool calls); write what must be true as `judge` items. Run `npx @flow-engineer/flow test --agent-url <url>`.
222
+
223
+ `examples/http-agent-python` and `examples/http-agent-go` are the café's agent done this way.
@@ -0,0 +1,89 @@
1
+ ---
2
+ name: flow-framework-tools
3
+ description: Write or change a Flow Framework agent's tools and their test files (tests/tools/*.yaml). Load when adding a tool to a Flow Framework agent, changing what a tool returns, or describing how a tool behaves in tests.
4
+ ---
5
+
6
+ # Writing tools for a Flow Framework agent
7
+
8
+ Read the `flow-framework` skill (`skill.md`) first if you haven't. Tests that use these tool files are
9
+ in the `flow-framework-tests` skill (`test-skill.md`).
10
+
11
+ ## The tool
12
+
13
+ ```ts
14
+ import { tool, z } from "@flow-engineer/flow";
15
+
16
+ export const findSlots = tool({
17
+ name: "find_session_slots",
18
+ description: "Every open time to move the session to, for a date range.",
19
+ input: z.object({
20
+ from_date: z.string().optional().describe("First day to search, YYYY-MM-DD; default today"),
21
+ to_date: z.string().optional().describe("Last day, YYYY-MM-DD; default a week later"),
22
+ }),
23
+ run: async ({ from_date, to_date }, { conversationId }) => backend.findSlots(conversationId, from_date, to_date),
24
+ });
25
+ ```
26
+
27
+ - The name and description are what the model reads to choose the tool: say what it does and
28
+ what it returns, in one line.
29
+ - `input` is a zod schema; Flow Framework checks the model's input against it before `run` is called and
30
+ sends the model the error if it doesn't fit.
31
+ - `run` returns any JSON-able value; throw to report a failure (the agent sees `Failed: <message>`).
32
+ - Don't make tools for talking to the person (send_<channel>, react, share_updates), for reminders
33
+ or scheduled messages, or for opting out of messages: Flow Framework has them.
34
+ - Key a person's data by `context.person` (who Flow Framework verified they are) or `context.conversationId`,
35
+ never by an id the model passed. `context.person` is `key:<resolve's value>`, else
36
+ `phone:<E.164>` (`phone:+15550100100`), else `<channel>:<address>`. To look the person up in the
37
+ owner's backend use `context.key`: exactly what `resolve` returned (`customer-7`), or null when
38
+ they are known only by phone or channel.
39
+ - A tool that changes something (books, pays, sends) passes `context.idempotencyKey` to its backend:
40
+ it is the same for the same input in the same turn, also when the turn runs again after a
41
+ restart, so the booking happens once. `context.callId` is the model's id for the call;
42
+ `context.signal` aborts when the person is forgotten.
43
+ - Don't decide what runs in the background. The reply waits for every call; one moves to the
44
+ background only if the person writes again meanwhile or it passes its limit (the agent's
45
+ `deadlineMs`, 60 s by default), and its result comes back into the chat by itself. Set
46
+ `deadlineMs` on a tool for its own limit: shorter for one that should never hold a reply long,
47
+ longer for one known to be slow.
48
+ - Say what you covered. An agent reads a list as complete: return the range or filter you
49
+ searched ("Mon Oct 12 through Sun Oct 18", "all open times") and never cut results short
50
+ silently. If there are more, say so.
51
+
52
+ ## The tool's test file
53
+
54
+ In tests no real tool runs: a model plays each tool from the scenario's world. `tests/tools/<tool>.yaml`
55
+ tells it how the tool behaves: every kind of response it gives, each with an example and how long
56
+ it takes. Write one per tool.
57
+
58
+ ```yaml
59
+ tool: place_order
60
+ behaviour: |
61
+ Order numbers are BL- followed by four digits and go up by one per order. ready_by is about
62
+ ten minutes after the order is placed. Fails if an item, size or milk is not on the menu.
63
+ responses:
64
+ - when: The order goes through (usual)
65
+ latency_ms: 4800
66
+ input: { name: Sam, items: [{ item: Latte, size: large, milk: oat, quantity: 1 }] }
67
+ output: { order_id: BL-1040, ready_by: "{{now + 10m}}", pay_at_pickup: true }
68
+ - when: The point of sale is busy; the order still goes through
69
+ latency_ms: 11000
70
+ output: { order_id: BL-1041, ready_by: "{{now + 15m}}", pay_at_pickup: true }
71
+ - when: The point of sale is down
72
+ latency_ms: 3000
73
+ error: Point of sale unavailable
74
+ ```
75
+
76
+ - `behaviour`: what the tool does beyond its description, in plain words (ids, ordering, limits,
77
+ when it fails).
78
+ - `responses`: one entry per kind of outcome, the usual one first, then slow ones and each error
79
+ the real system can give. `when` says when it happens; `latency_ms` is how long it takes;
80
+ then `output` (what `run` returns) or `error` (what it throws). `input` is optional, as an
81
+ example of a call.
82
+ - Use real responses when you have them (copy them from the agent's recorded tool calls, with
83
+ personal data removed); otherwise write realistic ones in exactly the shape `run` returns.
84
+ - Examples show the shape, not the answer: the simulator takes ids, names and dates from the
85
+ scenario's world, the call's input and what the agent already said. Write dates as placeholders
86
+ for the test's clock (`{{now + 10m}}`, `{{now + 2d | date}}`; see the `flow-framework-tests` skill),
87
+ never as written dates, which go stale.
88
+ - Latency lives only here. Scenarios describe situations ("the point of sale is slow this
89
+ morning"); the simulator picks the matching response and latency on each call.