@flow-engineer/flow 0.2.2 → 0.3.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 (40) hide show
  1. package/README.md +3 -3
  2. package/coding-agents/skill.md +20 -7
  3. package/coding-agents/test-skill.md +50 -1
  4. package/dist/flow-messaging/src/client.d.ts +2 -2
  5. package/dist/flow-messaging/src/client.js +3 -3
  6. package/dist/flow-messaging/src/index.js +13 -8
  7. package/dist/simulators/src/server.d.ts +2 -0
  8. package/dist/simulators/src/server.js +7 -1
  9. package/dist/src/agent.d.ts +32 -0
  10. package/dist/src/agent.js +38 -8
  11. package/dist/src/channels.d.ts +6 -4
  12. package/dist/src/channels.js +5 -3
  13. package/dist/src/conversation.d.ts +3 -0
  14. package/dist/src/conversation.js +37 -5
  15. package/dist/src/http.js +5 -2
  16. package/dist/src/index.d.ts +1 -0
  17. package/dist/src/index.js +1 -0
  18. package/dist/src/local.d.ts +22 -0
  19. package/dist/src/local.js +85 -0
  20. package/dist/src/types.d.ts +9 -0
  21. package/dist/testing/src/account.d.ts +1 -1
  22. package/dist/testing/src/account.js +78 -20
  23. package/dist/testing/src/business.d.ts +42 -0
  24. package/dist/testing/src/business.js +145 -0
  25. package/dist/testing/src/cli.js +10 -8
  26. package/dist/testing/src/client.d.ts +4 -2
  27. package/dist/testing/src/client.js +20 -0
  28. package/dist/testing/src/index.d.ts +2 -2
  29. package/dist/testing/src/index.js +1 -1
  30. package/dist/testing/src/new.d.ts +9 -0
  31. package/dist/testing/src/new.js +44 -5
  32. package/dist/testing/src/protocol.d.ts +99 -1
  33. package/dist/testing/src/protocol.js +9 -0
  34. package/dist/testing/src/suite.d.ts +142 -3
  35. package/dist/testing/src/suite.js +94 -10
  36. package/docs/agents.md +31 -9
  37. package/examples/cafe/src/main.ts +23 -8
  38. package/examples/cafe/src/mcp.ts +24 -6
  39. package/flow-messaging/README.md +3 -1
  40. package/package.json +1 -1
@@ -87,6 +87,87 @@ const expectedCall = z.union([
87
87
  args: withClock(z.record(z.string(), z.unknown())).optional(),
88
88
  }),
89
89
  ], { error: "a tool name, or { tool: <name>, args: { <field>: <value> } }" });
90
+ /** "8h30m", "2d", "45m": how long a `wait` step lets pass. */
91
+ const WAIT = /^(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$/;
92
+ /** A wait's length in ms, or null when it is not one. */
93
+ export function waitMs(text) {
94
+ const m = WAIT.exec(text.trim());
95
+ if (!m || !(m[1] || m[2] || m[3]))
96
+ return null;
97
+ const ms = (Number(m[1] ?? 0) * 24 * 60 + Number(m[2] ?? 0) * 60 + Number(m[3] ?? 0)) * 60_000;
98
+ return ms > 0 ? ms : null;
99
+ }
100
+ /** A moment on the test's clock: a date and time (local to the scenario's timezone unless it has an offset),
101
+ * HH:MM (the next time the clock shows it), or a `{{now ...}}` placeholder. */
102
+ const moment = z.string().refine((t) => isDateTime(t) || /^([01]\d|2[0-3]):[0-5]\d$/.test(t) || (/^\s*\{\{[\s\S]*\}\}\s*$/.test(t) && placeholderProblem(t) === null), "a time on the test's clock: 2026-10-20T23:30, 2026-10-20T23:30+05:30, 23:30, or {{now + 1d @ 08:00}}");
103
+ const template = z.strictObject({
104
+ name: z.string().min(1),
105
+ language: z.string().optional(),
106
+ body: z.array(z.string()).optional(),
107
+ buttons: z.array(z.strictObject({ index: z.number().int().min(0), value: z.string() })).optional(),
108
+ });
109
+ /** A Flow event as the business's backend sends it; `id` and `occurredAt` default to one per step and the clock. */
110
+ const flowEvent = withClock(z.strictObject({
111
+ id: z.string().min(1).optional(),
112
+ type: z.string().min(1),
113
+ occurredAt: z.string().optional(),
114
+ subject: z.string().min(1),
115
+ delivery: z.string().optional(),
116
+ data: z.record(z.string(), z.unknown()),
117
+ }));
118
+ const businessNudge = withClock(z
119
+ .strictObject({
120
+ text: z.string().min(1).optional(),
121
+ prompt: z.string().min(1).optional(),
122
+ template: template.optional(),
123
+ respectQuietHours: z.boolean().optional(),
124
+ expiresAt: moment.optional(),
125
+ })
126
+ .refine((n) => [n.text, n.prompt, n.template].filter((v) => v !== undefined).length === 1, "exactly one of text, prompt or template"));
127
+ /** What the agent's route must answer an event: a status, or a status and the field a 400 names. */
128
+ const answer = z.union([z.number().int().min(100).max(599), z.strictObject({ status: z.number().int().min(100).max(599), field: z.string().min(1).optional() })]);
129
+ /** One thing the agent sent (`expect.sent`), or must not have (`expect.not_sent`; `{}` is anything at all). */
130
+ const sentItem = z.strictObject({
131
+ /** When, on the test's clock: HH:MM (any day), or a date and time; to the minute. */
132
+ at: moment.optional(),
133
+ /** A template by name. */
134
+ template: z.string().min(1).optional(),
135
+ /** The agent's own words rather than a template. */
136
+ phrased: z.literal(true).optional(),
137
+ /** Text the message (or the template's values) holds. */
138
+ text: withClock(z.string().min(1)).optional(),
139
+ });
140
+ const openingStep = z
141
+ .object({
142
+ say: withClock(z.string()).optional(),
143
+ /** A file the person sends with it (say is its caption): a path in the tests folder. */
144
+ 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(),
145
+ after_ms: z.number().int().min(0).optional(),
146
+ /** The business's backend sends this event, to the agent's own route. */
147
+ event: flowEvent.optional(),
148
+ /** The route the event goes to; /v1/events when left out. */
149
+ path: z.string().regex(/^\/[^\s?#]*$/, "a path such as /v1/events").optional(),
150
+ /** bad: signed with another secret; missing: no Flow-Signature header. */
151
+ signature: z.enum(["bad", "missing"]).optional(),
152
+ /** What the route must answer; anything 2xx when left out. */
153
+ answer: answer.optional(),
154
+ /** The business nudges the person. */
155
+ nudge: businessNudge.optional(),
156
+ /** Time passes on the test's clock. */
157
+ wait: z.string().refine((t) => waitMs(t) !== null, 'a length of time such as 8h30m, 45m or 2d').optional(),
158
+ /** Move the test's clock to this time before the step. */
159
+ at: moment.optional(),
160
+ })
161
+ .superRefine((step, ctx) => {
162
+ const kinds = [step.say !== undefined || step.attach ? "say" : null, step.event ? "event" : null, step.nudge ? "nudge" : null, step.wait !== undefined ? "wait" : null].filter(Boolean);
163
+ if (kinds.length !== 1)
164
+ ctx.addIssue({ code: "custom", message: kinds.length ? `one of say, event, nudge or wait per step, not ${kinds.join(" and ")}` : "say something, attach a file, send an event, nudge, or wait" });
165
+ if (!step.event && (step.path || step.signature || step.answer !== undefined))
166
+ ctx.addIssue({ code: "custom", message: "path, signature and answer belong to an event step" });
167
+ if (kinds[0] === "say" && !step.say && !step.attach)
168
+ ctx.addIssue({ code: "custom", message: "say something, attach a file, or both" });
169
+ })
170
+ .transform((step) => (step.event || step.nudge || step.wait !== undefined ? step : { ...step, say: step.say ?? "" }));
90
171
  const scenarioSchema = z.object({
91
172
  id: z.string().min(1),
92
173
  title: z.string().optional(),
@@ -100,20 +181,15 @@ const scenarioSchema = z.object({
100
181
  .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
182
  .optional(),
102
183
  person: z.object({
184
+ /** Their address on the run's channel (a phone number on phone channels): who a business run's events and nudges are for. */
185
+ address: z.string().min(1).optional(),
103
186
  persona: withClock(z.string()),
104
187
  goal: withClock(z.string()),
105
188
  knows: z.array(withClock(z.string())).optional(),
106
189
  }),
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),
190
+ opening: z.array(openingStep).min(1),
191
+ /** Tools whose own code runs in this scenario, in place of the cloud's simulation (state the agent keeps itself). */
192
+ tools: z.strictObject({ real: z.array(z.string().min(1)).min(1) }).optional(),
117
193
  world: withClock(z.string()).optional(),
118
194
  max_turns: z.number().int().min(1).max(50).default(8),
119
195
  expect: z
@@ -124,6 +200,10 @@ const scenarioSchema = z.object({
124
200
  calls: z.array(expectedCall).optional(),
125
201
  /** None may be called. */
126
202
  never: z.array(expectedCall).optional(),
203
+ /** What the agent sent, in order (other sends may come between): checked by code, with the test's clock. */
204
+ sent: z.array(sentItem).optional(),
205
+ /** None of these was sent; `{}` is anything at all. */
206
+ not_sent: z.array(sentItem).optional(),
127
207
  })
128
208
  .default({}),
129
209
  });
@@ -149,6 +229,10 @@ export function mimeOf(path) {
149
229
  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
230
  return types[extname(path).slice(1).toLowerCase()] ?? "application/octet-stream";
151
231
  }
232
+ /** The business starts something in it (an event, a nudge, time passing), or it checks what was sent: a business run. */
233
+ export function isBusiness(scenario) {
234
+ return scenario.opening.some((s) => s.event || s.nudge || s.wait !== undefined || s.at !== undefined) || !!scenario.expect.sent || !!scenario.expect.not_sent || !!scenario.tools;
235
+ }
152
236
  /** Parse and check a scenario. The error has one line per problem: `file:line: field: what is wrong`. */
153
237
  export function parseScenario(file) {
154
238
  return parseFile(scenarioSchema, file);
package/docs/agents.md CHANGED
@@ -15,16 +15,24 @@ package.
15
15
  ```sh
16
16
  npx @flow-engineer/flow init --display-name "FlatX agent" # the starter in this (empty) folder; ask the owner what to call it first
17
17
  npm install
18
- npx @flow-engineer/flow link --json # signs in and sets up if needed (the keys, Flow's trial model key among them), then prints the phone link
19
- npm start # keep it running: the playground link it prints, and the phone link reaches it
18
+ npx @flow-engineer/flow link --channel telegram --json # or whatsapp, imessage: signs in and sets up if needed (the keys, Flow's trial model key among them), then prints the phone link
19
+ npm start # after link: keep it running; the playground link it prints, and the phone link reaches it
20
20
  npx @flow-engineer/flow status --json # any time: what the folder has and lacks
21
21
  printf 'hi\nA latte please\n' | npx @flow-engineer/flow chat # your own check: talk to it locally, one message per line
22
22
  ```
23
23
 
24
24
  Before `init` (or `flow new <folder>`), ask the owner what people should call their agent, such as
25
25
  "FlatX agent", and pass it as `--display-name`. It is kept as `displayName` in `package.json`, and
26
- the message their phone sends to reach the agent is `Hi FlatX agent (ref <code>)`; change it there
27
- any time. Without the flag, `init` asks in a terminal and otherwise uses the folder's name in words.
26
+ the message their phone sends to reach the agent is `Hi FlatX agent (ref <code>)`. The name takes the
27
+ starter's (Pip) place in the agent's prompt, `agent/AGENTS.md`, and the playground and MCP server
28
+ read it from `package.json`. To rename an agent made already, `link --display-name "<name>"` (or
29
+ `setup --display-name`) changes both; restart `npm start` after it. Without the flag, `init` asks in
30
+ a terminal and otherwise uses the folder's name in words.
31
+
32
+ `npm start` listens on `PORT`, else 8788: with 8788 taken and no `PORT` set it takes the next free
33
+ port and says so, and the playground link it prints has the port it uses. With `PORT` set to a port
34
+ that is taken it prints one line, `Port 8788 is in use. Run: PORT=8798 npm start`, and exits 1.
35
+ `npm run mcp` does the same with `MCP_PORT` (else 8790).
28
36
 
29
37
  ## How the owner tests it
30
38
 
@@ -64,7 +72,8 @@ before you hand it back; run them yourself, and do not offer them to the owner a
64
72
  - `link` does the rest when the folder is not set up (no `FLOW_FRAMEWORK_JOIN_CODE` or
65
73
  `FLOW_MESSAGING_KEY` in `.env`, or no sender for the WhatsApp or iMessage link asked for): it
66
74
  runs `login` (opens the owner's browser itself and prints the link and code at once), then
67
- `setup` (writes the keys and each open channel's sender into `.env`), then prints the link.
75
+ `setup` (writes the keys and each open channel's sender into `.env`, and the chosen channel's
76
+ page as `FLOW_FRAMEWORK_TRY_URL`), then prints the link and that channel's free-trial line only.
68
77
  `--channel telegram|whatsapp|imessage` picks the channel (Telegram by default). Run it in the
69
78
  agent's folder: `setup` and `link` refuse a folder with no `package.json` (`not_an_agent_folder`).
70
79
  - A channel that is not open yet is said once ("iMessage isn't open yet; here are your Telegram
@@ -75,9 +84,13 @@ before you hand it back; run them yourself, and do not offer them to the owner a
75
84
  24 hours of their last message); it cannot message them first. `link --json` says so
76
85
  (`reply_only: true`, and in `say_to_person`): tell the owner.
77
86
  - The starter's `npm start` is on Telegram, plus WhatsApp and iMessage when `.env` has their senders
78
- (`FLOW_MESSAGING_WHATSAPP_SENDER`, `FLOW_MESSAGING_IMESSAGE_SENDER`), and reads `.env` once. When
79
- `setup` or `link` adds a channel (or the model key) it says so: "If npm start is running, restart
80
- it to add iMessage."
87
+ (`FLOW_MESSAGING_WHATSAPP_SENDER`, `FLOW_MESSAGING_IMESSAGE_SENDER`), and reads `.env` once.
88
+ `npm start` marks itself running in `.flow/running.json`, so `link` ends with the step that fits:
89
+ `Next: npm start, then send the link above` when it is not running; when it is, `Next: restart npm
90
+ start (it runs at <url> and lacks iMessage), then send the link above` if `link` just wrote `.env`
91
+ or renamed it, else `Next: send the link above (npm start is running at <url>)` (`link --json`:
92
+ `running`, `false` or `{"url"}`, and `next`). `setup` alone says "If npm start is running, restart it to add
93
+ iMessage."
81
94
  - Give the owner `say_to_person` from `link --json`, and the sign-in link and code from the
82
95
  `pending` line while you wait.
83
96
  - `npm start` connects to Flow Messaging by an outbound event stream. Messages sent while it was
@@ -108,7 +121,8 @@ Every error is `{"error":{"code","message","next"}}` on stdout with `--json`, an
108
121
  `next` is the exact command or URL to run; when it came from flow.engineer it is the server's.
109
122
  A run gives one `next`: `setup --json`'s is a list in order (a model key first when there is
110
123
  none, then `npm start` for the playground, then `link` for the phone); inside `link` setup's line
111
- has none, and an error's `next` replaces it.
124
+ has none, and `link`'s `next` is one string (a model key first when there is none, then starting or
125
+ restarting `npm start`); an error's `next` replaces it.
112
126
  Run it, or give it to the owner when it needs them (a browser approval, a page to open). A
113
127
  `slow_down` error also has `retry_after` (seconds, or `null` when the server gave none). A
114
128
  trial limit's `next` is a sentence, not a command: it is printed on its own line as it came; pass
@@ -140,6 +154,14 @@ reach (`iMessage: 1 of 2 people`; `trial.imessage.contacts` `{limit, used}` in `
140
154
  with `FLOW_FRAMEWORK_LOGIN_URL` naming another server, commands stop with `signed_in_elsewhere`.
141
155
  - CI never signs in: `npx @flow-engineer/flow setup --ci` says how to give it the test cloud key.
142
156
 
157
+ ## Messages the business starts
158
+
159
+ A nudge, a scheduled message or any turn the person did not start goes with link previews off
160
+ (`preview: false` on the message; Flow Messaging's WhatsApp `preview_url: false`): on WhatsApp a
161
+ preview card's title becomes the phone's notification instead of the message's text. For a call to
162
+ action use `link` (`agent.nudge({ to, text, link: { label, url } })`, or the agent's send with
163
+ `link`), not a URL in the text.
164
+
143
165
  ## Shared test numbers and privacy
144
166
 
145
167
  The free trial runs on Flow's shared Telegram bot, WhatsApp number and iMessage line. Sending an
@@ -1,14 +1,19 @@
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
1
+ // The agent 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), it is on Telegram
3
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.
4
+ // stream: an outbound connection, so no public URL or tunnel. It listens on PORT, else 8788 or the
5
+ // next free port above it.
5
6
 
7
+ import { readFileSync } from "node:fs";
8
+ import { choosePort, markRunning } from "../../../src/index.ts";
6
9
  import { flowMessaging } from "../../../flow-messaging/src/index.ts";
7
10
  import { simulators } from "../../../simulators/src/index.ts";
8
11
  import { buildAgent } from "./agent.ts";
9
12
  import { channelNames, flowChannels } from "./channels.ts";
10
13
 
11
- const play = simulators({ title: "Pip · Bean & Leaf" });
14
+ // What people call it: package.json's displayName (`npx @flow-engineer/flow link --display-name <name>` changes it).
15
+ const { displayName: name = "Pip" } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { displayName?: string };
16
+ const play = simulators({ title: `${name} · Bean & Leaf` });
12
17
  const fm = process.env.FLOW_MESSAGING_KEY ? flowMessaging() : undefined;
13
18
  const channels = fm ? flowChannels(fm) : [];
14
19
  let agent: ReturnType<typeof buildAgent>;
@@ -18,12 +23,22 @@ try {
18
23
  console.error((error as Error).message); // such as no model key in .env
19
24
  process.exit(1);
20
25
  }
21
- const port = Number(process.env.PORT ?? 8788);
22
- const { url } = await play.listen(agent, port);
23
- console.log(`Pip's playground: ${url}/`);
26
+ let url: string;
27
+ try {
28
+ const { port, note } = await choosePort("PORT", 8788, { command: "npm start" });
29
+ if (note) console.log(note);
30
+ ({ url } = await play.listen(agent, port));
31
+ } catch (error) {
32
+ console.error((error as Error).message); // such as the port PORT names being in use
33
+ process.exit(1);
34
+ }
35
+ const port = Number(new URL(url).port);
36
+ // `npx @flow-engineer/flow link` reads it (.flow/running.json) to say whether to start or restart this.
37
+ markRunning(url);
38
+ console.log(`${name}'s playground: ${url}/`);
24
39
  console.log(`Every channel side by side: ${url}/person`);
25
40
  if (fm) {
26
- // Events sent while Pip was not running come when it starts again (.flow/ keeps the last one handled).
41
+ // Events sent while it was not running come when it starts again (.flow/ keeps the last one handled).
27
42
  const live = fm.start(agent, { cursorFile: ".flow/flow-messaging-cursor", port: port + 1 });
28
43
  console.log(`On ${channelNames(channels)} through Flow Messaging (${live.mode}). The phone link: npx @flow-engineer/flow link`);
29
44
  live.stopped.then((why) => console.error(`Flow Messaging stopped: ${why.message} Then: npx @flow-engineer/flow setup`));
@@ -1,9 +1,11 @@
1
- // Pip's tools and skills as an MCP server, for Claude, ChatGPT or any MCP host: `npm run mcp`.
1
+ // The agent's tools and skills as an MCP server, for Claude, ChatGPT or any MCP host: `npm run mcp`.
2
2
  // The same tools the chat agent uses; no model key needed, since the host brings its own model.
3
3
 
4
+ import { readFileSync } from "node:fs";
4
5
  import { createServer } from "node:http";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { mcpServer } from "../../../mcp/src/index.ts";
8
+ import { choosePort } from "../../../src/index.ts";
7
9
  import { tools } from "./tools.ts";
8
10
 
9
11
  export const mcp = mcpServer({
@@ -15,8 +17,24 @@ export const mcp = mcpServer({
15
17
  // auth: { issuer, resource, scopes, verify } (the chinwag-auth skill).
16
18
  });
17
19
 
18
- const port = Number(process.env.MCP_PORT ?? 8790);
19
- createServer((req, res) => void mcp.node(req, res)).listen(port, "127.0.0.1", () => {
20
- console.log(`Pip's MCP server: http://127.0.0.1:${port}/mcp`);
21
- console.log(`Try it in Claude Code: claude mcp add --transport http bean-and-leaf http://127.0.0.1:${port}/mcp`);
22
- });
20
+ // What people call it: package.json's displayName.
21
+ const { displayName: name = "Pip" } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { displayName?: string };
22
+ // MCP_PORT, else 8790 or the next free port above it.
23
+ let port: number;
24
+ try {
25
+ const chosen = await choosePort("MCP_PORT", 8790, { command: "npm run mcp" });
26
+ port = chosen.port;
27
+ if (chosen.note) console.log(`${chosen.note} The CLI then needs CAFE_SERVER=http://127.0.0.1:${port}/mcp.`);
28
+ } catch (error) {
29
+ console.error((error as Error).message);
30
+ process.exit(1);
31
+ }
32
+ createServer((req, res) => void mcp.node(req, res))
33
+ .once("error", (error) => {
34
+ console.error(`${error.message}. Run: MCP_PORT=<port> npm run mcp`);
35
+ process.exit(1);
36
+ })
37
+ .listen(port, "127.0.0.1", () => {
38
+ console.log(`${name}'s MCP server: http://127.0.0.1:${port}/mcp`);
39
+ console.log(`Try it in Claude Code: claude mcp add --transport http bean-and-leaf http://127.0.0.1:${port}/mcp`);
40
+ });
@@ -148,7 +148,9 @@ Each of these can also be passed in code: `flowMessaging({ apiKey, webhookSecret
148
148
  ## What it does for you
149
149
 
150
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.
151
+ a short Retry-After, so a message never goes out twice. A nudge or a scheduled text keeps one key
152
+ from its id (`nudge:<id>`, `run:<id>`) in every process, so a restart or a second instance does not
153
+ send it again.
152
154
  - Flow's `409 outside_window` becomes the framework's `outside_window`, so the agent knows to send a
153
155
  template.
154
156
  - A permanent refusal (opted out, no consent, ...) reaches the agent once, with "don't resend it".
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flow-engineer/flow",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "An open-source runtime for chat agents that feel like a person texting back.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {