@flow-engineer/flow 0.2.3 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +3 -3
  2. package/coding-agents/skill.md +24 -9
  3. package/coding-agents/test-skill.md +50 -1
  4. package/dist/flow-messaging/src/index.js +8 -5
  5. package/dist/simulators/src/server.d.ts +2 -0
  6. package/dist/simulators/src/server.js +7 -1
  7. package/dist/src/agent.d.ts +32 -0
  8. package/dist/src/agent.js +38 -8
  9. package/dist/src/channels.d.ts +2 -0
  10. package/dist/src/channels.js +1 -0
  11. package/dist/src/conversation.d.ts +2 -0
  12. package/dist/src/conversation.js +8 -2
  13. package/dist/src/http.js +5 -2
  14. package/dist/src/index.d.ts +1 -0
  15. package/dist/src/index.js +1 -0
  16. package/dist/src/local.d.ts +22 -0
  17. package/dist/src/local.js +85 -0
  18. package/dist/src/types.d.ts +6 -0
  19. package/dist/testing/src/account.d.ts +1 -1
  20. package/dist/testing/src/account.js +107 -29
  21. package/dist/testing/src/business.d.ts +42 -0
  22. package/dist/testing/src/business.js +145 -0
  23. package/dist/testing/src/cli.js +10 -8
  24. package/dist/testing/src/client.d.ts +4 -2
  25. package/dist/testing/src/client.js +20 -0
  26. package/dist/testing/src/index.d.ts +2 -2
  27. package/dist/testing/src/index.js +1 -1
  28. package/dist/testing/src/new.d.ts +9 -0
  29. package/dist/testing/src/new.js +44 -5
  30. package/dist/testing/src/protocol.d.ts +99 -1
  31. package/dist/testing/src/protocol.js +9 -0
  32. package/dist/testing/src/suite.d.ts +142 -3
  33. package/dist/testing/src/suite.js +94 -10
  34. package/docs/agents.md +40 -10
  35. package/examples/cafe/src/agent.ts +3 -3
  36. package/examples/cafe/src/channels.ts +1 -1
  37. package/examples/cafe/src/main.ts +23 -8
  38. package/examples/cafe/src/mcp.ts +24 -6
  39. package/examples/cafe/src/tools.ts +1 -1
  40. package/package.json +1 -1
package/README.md CHANGED
@@ -27,17 +27,17 @@ Node 22.18 or later. Your agent is TypeScript that Node runs as it is, with no b
27
27
 
28
28
  ```bash
29
29
  npx @flow-engineer/flow new my-agent --display-name "My agent" && cd my-agent && npm install
30
- npx @flow-engineer/flow link # signs in; writes Flow's free trial model key and a phone link (or put your own key in .env)
30
+ npx @flow-engineer/flow link --channel telegram # or whatsapp, imessage: signs in; writes Flow's free trial model key and a phone link (or put your own key in .env)
31
31
  npm start
32
32
  ```
33
33
 
34
- `flow new <name>` makes `./<name>/` from the café example (lowercase letters, digits and dashes); `--display-name` is what people call it, kept as `displayName` in its `package.json`, and a phone reaches it by sending `Hi <that name> (ref <code>)`. Open the link `npm start` prints and text Pip, a café ordering assistant: your starting point. Pick a platform at the top (SMS, WhatsApp, iMessage, Telegram, web), and turn on **Debug** to see every tool call and how fast each message was answered. A model call that fails (a wrong or expired key, say) shows in the chat, in Debug and on the terminal. The café's `src/agent.ts` reads its model id from `CAFE_MODEL` (default: the model for the key you set, see [Models](#models)). The new folder has the coding-agent skills in `.claude/skills/` and an `AGENTS.md` pointing at them, so you can tell Claude Code, Codex or any coding agent what to build.
34
+ `flow new <name>` makes `./<name>/` from the café example (lowercase letters, digits and dashes); `--display-name` is what people call it, kept as `displayName` in its `package.json`, and a phone reaches it by sending `Hi <that name> (ref <code>)`. Open the link `npm start` prints (it uses `PORT`, else 8788 or the next free port) and text your agent, the café's ordering assistant under the name you gave it (Pip in the example): your starting point; `flow link --display-name <name>` renames it later. Pick a platform at the top (SMS, WhatsApp, iMessage, Telegram, web), and turn on **Debug** to see every tool call and how fast each message was answered. A model call that fails (a wrong or expired key, say) shows in the chat, in Debug and on the terminal. The café's `src/agent.ts` reads its model id from `CAFE_MODEL` (default: the model for the key you set, see [Models](#models)). The new folder has the coding-agent skills in `.claude/skills/` and an `AGENTS.md` pointing at them, so you can tell Claude Code, Codex or any coding agent what to build.
35
35
 
36
36
  ### On your phone, from your laptop
37
37
 
38
38
  ```bash
39
39
  npx @flow-engineer/flow init # the same starter in the current folder; then npm install
40
- npx @flow-engineer/flow link # signs in (opens the browser), writes its keys and Flow's trial model key into .env, prints a link
40
+ npx @flow-engineer/flow link --channel telegram # signs in (opens the browser), writes its keys and Flow's trial model key into .env, prints a link
41
41
  npm start # try it in the playground link it prints, then open the phone link and text the agent
42
42
  npx @flow-engineer/flow status # what the folder has and lacks
43
43
  ```
@@ -20,7 +20,7 @@ and `flow-framework-auth` (`auth-skill.md`) when tools need to know who the pers
20
20
  `npx @flow-engineer/flow new <name>` (lowercase letters, digits and dashes) makes `./<name>/` from the café
21
21
  example: a complete agent with tools and tests that depends on the `@flow-engineer/flow` package and imports
22
22
  `@flow-engineer/flow` and `@flow-engineer/flow/simulators`, with these skills in `.claude/skills/`. Run `npm install` there.
23
- Build in it, changing Pip's prompt, skills, tools and tests into yours. A provider key is optional:
23
+ Build in it, changing the café agent's prompt, skills, tools and tests into yours. A provider key is optional:
24
24
  `npx @flow-engineer/flow link` (or `setup`) writes `FLOW_KEY`, the agent's one Flow key, into `.env` (never
25
25
  read or print it), and the agent calls models through Flow with it on $2 of Flow trial credit. A key of the owner's own wins over it, made
26
26
  at the provider: `OPENROUTER_API_KEY` at https://openrouter.ai/keys,
@@ -63,21 +63,25 @@ skill, not in AGENTS.md.
63
63
  ## buildAgent
64
64
 
65
65
  Build the agent in one function that the playground and the tests both call, so they never
66
- drift apart. Tests pass their own model, events and simulated tools:
66
+ drift apart. Tests pass their own model, events and simulated tools; a test the business starts
67
+ also passes `channels` and `testing` (the run's clock), which go on to `createAgent` as they are
68
+ (the `flow-framework-tests` skill):
67
69
 
68
70
  ```ts
69
71
  import { fileURLToPath } from "node:url";
70
- import { createAgent, openAICompatible, type EventSink, type Model, type Tool } from "@flow-engineer/flow";
72
+ import { createAgent, openAICompatible, type AgentOptions, type Channel, type EventSink, type Model, type Tool } from "@flow-engineer/flow";
71
73
  import { tools as myTools } from "./tools.ts";
72
74
 
73
75
  export const tools = myTools;
74
76
 
75
- export function buildAgent(options: { model?: Model; events?: EventSink[]; tools?: Tool[] } = {}) {
77
+ export function buildAgent(options: { model?: Model; events?: EventSink[]; tools?: Tool[]; channels?: Channel[]; testing?: Partial<AgentOptions> } = {}) {
76
78
  return createAgent({
77
79
  dir: fileURLToPath(new URL("../agent", import.meta.url)),
78
80
  model: options.model ?? openAICompatible({ model: "anthropic/claude-haiku-5.5" }),
79
81
  tools: options.tools ?? myTools,
80
82
  events: options.events ?? [],
83
+ ...(options.channels ? { channels: options.channels } : {}),
84
+ ...options.testing,
81
85
  });
82
86
  }
83
87
  ```
@@ -93,7 +97,12 @@ and are `message.in`/`message.out` events marked `answered: true`);
93
97
  `deadlineMs` (default 60,000) is the longest a reply waits for a tool before it moves to the
94
98
  background (set a tool's own `deadlineMs` for a known-slow one); `partialAfterMs` (default 5,000)
95
99
  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
96
- guidance with a string.
100
+ guidance with a string; `routes` (`{ "/v1/events": (request) => Response }`) are HTTP routes the
101
+ agent serves beside its chat, such as a business's event push, answered by `agent.fetch(request)`
102
+ (and by the test cloud with a test's events); `onTick(now)` is work of your own on the agent's clock
103
+ (held sends, reminders timed from your data), run with each check for due scheduled messages.
104
+ `agent.reach({ channel, address })` makes the conversation of someone the business may message
105
+ before they have written; until they write, only a template reaches them on WhatsApp.
97
106
 
98
107
  Models are reached through any OpenAI-compatible API. `openAICompatible()` with no key reads the
99
108
  first of `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY` and
@@ -205,6 +214,9 @@ to send later. Prefer `prompt`; use `text` only when the wording must be exact.
205
214
  see it.", no values) so a nudge is held until they reply instead of refused (`outside_window`); the
206
215
  agent may send a fitting template itself (`send_template`), and `template.missing` events say which
207
216
  messages want one. Held nudges (quiet hours, a closed window) settle as `held`, then `sent` or `expired`.
217
+ For a call to action use `link` (`text` with `link: { label, url }`, or the agent's send with
218
+ `link`), never a URL in the text: what the business starts goes with link previews off, since on
219
+ WhatsApp a preview card's title becomes the phone's notification instead of the message.
208
220
 
209
221
  When the owner's backend is a separate service, mount `nudgeHandler(agent, { token })` (from
210
222
  `@flow-engineer/flow`; a token of at least 32 characters, from a secret; `base` is `/nudges` by default) in the
@@ -313,12 +325,13 @@ and every simulated message is logged with the person, channel, time and address
313
325
  To chat with the agent from the owner's phone, with no tunnel and no key made by hand, run these
314
326
  from the agent's folder (`npx @flow-engineer/flow init --display-name "<name>"` makes a starter in
315
327
  an empty one: first ask the owner what people should call their agent, such as "FlatX agent"; the
316
- phone's first message is `Hi FlatX agent (ref <code>)`, and `package.json`'s `displayName` keeps it).
328
+ phone's first message is `Hi FlatX agent (ref <code>)`, and `package.json`'s `displayName` keeps it;
329
+ `link --display-name "<name>"` renames it later, in `package.json` and `agent/AGENTS.md`).
317
330
  Only `chat` reads stdin; `init` asks for the name only in a terminal, and nothing else asks for input.
318
331
 
319
332
  ```sh
320
- npx @flow-engineer/flow link --json # signs in and runs setup when needed (Flow's trial model key too), then the channel's own link (t.me, wa.me, sms:); give its say_to_person to the owner
321
- npm start # the agent: its playground link, and the owner texts it while it runs
333
+ npx @flow-engineer/flow link --channel telegram --json # or whatsapp, imessage: signs in and runs setup when needed (Flow's trial model key too), then the channel's own link (t.me, wa.me, sms:); give its say_to_person to the owner, and run its next
334
+ npm start # after link: its playground link, and the owner texts it while it runs (PORT, else 8788 or the next free port)
322
335
  npx @flow-engineer/flow status --json # what the folder has and lacks, at any time
323
336
  printf 'hi\nA latte please\n' | npx @flow-engineer/flow chat # your own check: one message per line, replies printed
324
337
  npx @flow-engineer/flow test --synthetic # your own check: the tests, on the key setup wrote
@@ -336,7 +349,9 @@ kept: give the owner the link and code, and run `link` again (it polls the same
336
349
  `setup`, which writes the test cloud, traces and Flow Messaging keys into `.env`, prints names and
337
350
  last 4 characters only, keeps values already there (`--replace` takes the new ones), and is safe to
338
351
  run again. `--channel telegram|whatsapp|imessage` picks the channel; a channel flow.engineer cannot
339
- open yet exits 3 with its `next`. `login`, `setup` and `link` also run on their own.
352
+ open yet exits 3 with its `next`. When only observe's part of setup fails (traces and the test cloud),
353
+ `link` still prints the link and exits 0, with one warning saying what is missing and the server's
354
+ fix (`warnings` in `--json`): pass it on to the owner. `login`, `setup` and `link` also run on their own.
340
355
 
341
356
  In `src/main.ts`, put `fm.telegram()` from `@flow-engineer/flow/flow-messaging` in the agent's
342
357
  channels and call `fm.start(agent, { cursorFile: ".flow/flow-messaging-cursor" })`: with no webhook
@@ -74,7 +74,7 @@ expect:
74
74
  - `timezone` (default UTC) and `now`: the test's clock. `now` is when each run starts,
75
75
  `YYYY-MM-DDTHH:MM[:SS]`, local to `timezone` unless it ends in `Z` or an offset (`-04:00`); left
76
76
  out, it is the real time. Placeholders are filled from it, and the simulated tools and the judge
77
- 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
77
+ are told it; outside a business run (below) your agent's clock is not changed: the agent still reads the real time. So set `now` only when the agent takes
78
78
  its dates from its tools or its context; otherwise leave it out and placeholders follow the real
79
79
  date.
80
80
 
@@ -98,6 +98,55 @@ time of day, then `| iso` (the default, with its offset), `| date` (2026-10-16),
98
98
  `person`, `opening`, `expect` and the tool files' `responses`; the cloud fills them on each run. Any other
99
99
  `{{...}}` is plain text.
100
100
 
101
+ ### Scenarios the business starts
102
+
103
+ A scenario can start with the business instead of the person: an event its backend sends, a nudge,
104
+ or time passing. Such a run (a business run) gets an agent of its own, built by `buildAgent` with
105
+ the run's clock and the agent's own channels (pass `options.channels` and `options.testing` on to
106
+ `createAgent`, `testing` spread as it is: `now` and `scheduleCheckMs: 0`), so the agent runs on
107
+ the test's clock: quiet hours, held sends, `expiresAt` and caps over days follow it.
108
+
109
+ ```yaml
110
+ timezone: Asia/Kolkata
111
+ now: 2026-10-20T23:30
112
+ person:
113
+ address: "+15550100001" # who the events and nudges are for (a made-up number)
114
+ persona: ...
115
+ goal: ...
116
+ opening:
117
+ - event: { type: buying.bid.outbid, delivery: critical, subject: "user:u1", data: { user: { phone: "+15550100001" } } }
118
+ - wait: 8h30m # time passes; what came due runs, a minute at a time
119
+ - say: "ok thanks" # the person can still write
120
+ - event: { id: evt_dup, type: ..., subject: ..., data: {...} }
121
+ signature: bad # or missing; answer: 401
122
+ answer: 401 # what the route must answer (default: any 2xx); { status: 400, field: data.user }
123
+ - nudge: { prompt: "Tell them the listing ends soon." } # or text, or template: { name, body: [...] }
124
+ at: 2026-10-21T15:00 # moves the clock first: a date and time, HH:MM (the next one), or {{now ...}}
125
+ max_turns: 1 # 1: no turns played after the opening
126
+ tools: { real: [keep_me_posted] } # their own code runs, for state the agent keeps itself
127
+ expect:
128
+ sent: # what went out, in order (other sends may come between)
129
+ - { at: "23:30", template: outbid }
130
+ - { at: "08:00", phrased: true, text: "₹53,000" }
131
+ not_sent:
132
+ - { template: outbid_reminder } # {} is anything at all
133
+ ```
134
+
135
+ - An `event` is sent exactly as a business's backend sends a Flow event: one envelope
136
+ `{ id, type, occurredAt, subject, delivery?, data }` (`id` and `occurredAt` default to one per
137
+ step and the clock), as `POST /v1/events` (or the step's `path`) to the agent's `routes`, with
138
+ `Content-Type: application/json`, `Idempotency-Key: <id>` and
139
+ `Flow-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.{t}.{body}">`, signed with the
140
+ run's own test secret (`options.testing.eventsSecret`: check signatures with it in a business run).
141
+ The same `id` twice is a duplicate, as it would be live.
142
+ - A `nudge` is `agent.nudge` to the run's person; `wait` and `at` move the clock (never back).
143
+ - `expect.sent` items: `at` (HH:MM on any day, or a date and time; to the minute), `template`
144
+ (its name), `phrased: true` (the agent's own words, not a template), `text` (in the message, or in
145
+ the template's values). Checked by code, like `calls`.
146
+ - A business run where the agent sent nothing is not a failure by itself: `expect` decides.
147
+ - Timers of your own (held sends, reminders timed from your data) go in `createAgent`'s `onTick`,
148
+ which runs on the agent's clock, never in a `setInterval` of your own.
149
+
101
150
  How fast the agent first replies to each message is measured on every run from timestamps, not
102
151
  written in scenarios: the dashboard shows it for the whole run (average, p95, min and max).
103
152
 
@@ -97,16 +97,17 @@ export function flowMessaging(options = {}) {
97
97
  return { phone: to.startsWith("+") ? to : `+${to}` };
98
98
  }
99
99
  /** One message: into the person's conversation, or a new one from the channel's sender. */
100
- async function send(route, to, content, replyTo, about = replyTo, idempotencyKey) {
100
+ async function send(route, to, content, replyTo, about = replyTo, idempotencyKey, channelOptions) {
101
101
  try {
102
102
  const conversation = await conversationFor(route.name, to, about);
103
+ const options = channelOptions ? { channel_options: channelOptions } : {};
103
104
  let sent;
104
105
  if (conversation)
105
- sent = await client.post(`/v1/conversations/${idSegment("conv", conversation)}/messages`, { content, ...(replyTo ? { reply_to: replyTo } : {}) }, undefined, idempotencyKey);
106
+ sent = await client.post(`/v1/conversations/${idSegment("conv", conversation)}/messages`, { content, ...(replyTo ? { reply_to: replyTo } : {}), ...options }, undefined, idempotencyKey);
106
107
  else {
107
108
  if (!route.sender)
108
109
  throw new Error(`no conversation with them on ${route.name} yet, and no sender to start one (set FLOW_MESSAGING_${route.kind.toUpperCase()}_SENDER)`);
109
- sent = await client.post("/v1/messages", { sender: route.sender, to: recipient(route.kind, to), content }, undefined, idempotencyKey);
110
+ sent = await client.post("/v1/messages", { sender: route.sender, to: recipient(route.kind, to), content, ...options }, undefined, idempotencyKey);
110
111
  }
111
112
  flowConversation.set(key(route.name, to), sent.conversation);
112
113
  }
@@ -175,11 +176,13 @@ export function flowMessaging(options = {}) {
175
176
  function provider(route) {
176
177
  return {
177
178
  sendMessage: async (to, message) => {
179
+ // A message the business started: on WhatsApp, no link preview (its card's title would be the phone's notification).
180
+ const options = (content) => (message.preview === false && route.kind === "whatsapp" && content.type === "text" ? { preview_url: false } : undefined);
178
181
  const [first, ...rest] = contents(route.kind, message);
179
182
  const key = message.idempotencyKey;
180
- await send(route, to, first, message.replyTo, message.replyTo, key);
183
+ await send(route, to, first, message.replyTo, message.replyTo, key, options(first));
181
184
  for (const [i, next] of rest.entries())
182
- await send(route, to, next, undefined, undefined, key && `${key}:${i + 1}`);
185
+ await send(route, to, next, undefined, undefined, key && `${key}:${i + 1}`, options(next));
183
186
  },
184
187
  react: (to, messageId, emoji) => send(route, to, { type: "reaction", message_id: messageId, emoji }, undefined, messageId),
185
188
  typing: (to, on) => courtesy(route, to, "typing", { state: on ? "on" : "off" }),
@@ -136,6 +136,8 @@ export interface Playground {
136
136
  }
137
137
  export interface TestCloudSimulators {
138
138
  events: EventSink;
139
+ /** Where the agent's sends to one person's thread go, before they have written (a run the business starts). */
140
+ deliverer(platform: string, person: string): Deliver;
139
141
  listen(agent: AgentConnection, port: number): Promise<Listening>;
140
142
  handler(agent: AgentConnection, base?: string): Handler;
141
143
  }
@@ -393,7 +393,9 @@ export function simulators(options = {}) {
393
393
  }
394
394
  function serve(handle, port) {
395
395
  const server = createServer(handle);
396
- return new Promise((resolve) => {
396
+ return new Promise((resolve, reject) => {
397
+ // A port taken since it was chosen rejects, as an error the caller can print, never an unhandled 'error' event.
398
+ server.once("error", reject);
397
399
  server.listen(port, "127.0.0.1", () => resolve({
398
400
  // The bound port, so port 0 (any free port) works too.
399
401
  url: `http://127.0.0.1:${server.address().port}`,
@@ -537,6 +539,10 @@ export function simulators(options = {}) {
537
539
  }
538
540
  const testCloud = {
539
541
  events: cloudEvents,
542
+ deliverer: (platform, person) => {
543
+ const p = platformOf(platform);
544
+ return deliverer(p, key(p.id, person));
545
+ },
540
546
  listen: (agent, port) => serve(guarded((req, res) => handle(agent, req, res, "")), port),
541
547
  handler: (agent, base = "") => guarded((req, res) => handle(agent, req, res, base)),
542
548
  };
@@ -140,6 +140,18 @@ export interface AgentOptions {
140
140
  keepMessageIdsDays?: number;
141
141
  /** The clock (tests move it). */
142
142
  now?: () => number;
143
+ /**
144
+ * Work of your own that runs on the agent's clock: called (and awaited) each time due scheduled
145
+ * messages are looked for (every `scheduleCheckMs`, and each `runDue()`), with the time now. A
146
+ * test that moves the clock runs it at each step, so your own timers (held sends, reminders
147
+ * timed from your data) follow the test's clock. An error is logged, never thrown.
148
+ */
149
+ onTick?: (now: number) => Promise<void> | void;
150
+ /**
151
+ * HTTP routes the agent serves beside its chat, by path ("/v1/events": a business's event push),
152
+ * answered by `agent.fetch(request)`. The test cloud calls them in-process with a test's events.
153
+ */
154
+ routes?: Record<string, (request: Request) => Response | Promise<Response>>;
143
155
  /**
144
156
  * The people who may write from the test playground (`simulate`), and no one else. Each is a
145
157
  * real person to the agent, by their phone number: the same conversation whether they write from
@@ -197,6 +209,11 @@ export interface NudgeInput {
197
209
  };
198
210
  /** Sent as written, with no model call. */
199
211
  text?: string;
212
+ /** With `text`: its call to action, sent as a link button (on channels without one, the URL after the text). Never a URL in the text. */
213
+ link?: {
214
+ label: string;
215
+ url: string;
216
+ };
200
217
  /** The agent gets a turn with this note and writes the message itself, or sends nothing. */
201
218
  prompt?: string;
202
219
  /** A template the owner already set up with the channel, sent as is (also outside WhatsApp's window). */
@@ -515,6 +532,21 @@ export declare class Agent {
515
532
  }>;
516
533
  /** Whether the agent has a profile (get_user_profile), so the test cloud knows to play it. */
517
534
  hasProfile(): boolean;
535
+ /**
536
+ * A person the business may message before they have written (they gave it their number): their
537
+ * conversation, made the first time, with no message in it. Until they write, their WhatsApp
538
+ * window is closed, so only a template reaches them there.
539
+ */
540
+ reach(to: {
541
+ channel: string;
542
+ address: string;
543
+ }): Promise<{
544
+ conversationId: string;
545
+ }>;
546
+ /** A channel the agent was given, by name, as it was given (the test cloud plays a copy of it). */
547
+ channel(name: string): Channel | undefined;
548
+ /** Answers a request to one of `routes` by its path; 404 for any other. */
549
+ fetch(request: Request): Promise<Response>;
518
550
  nudge(input: NudgeInput): Promise<NudgeResult>;
519
551
  /**
520
552
  * Records a nudge and resolves at once, without waiting for its send or turn: `queued`, or what
package/dist/src/agent.js CHANGED
@@ -63,6 +63,9 @@ export class Agent {
63
63
  if (this.channelsByName.has(name))
64
64
  throw new Error(`flow: "${name}" is reserved; give the channel another name`);
65
65
  this.now = options.now ?? Date.now;
66
+ for (const path of Object.keys(options.routes ?? {}))
67
+ if (!path.startsWith("/"))
68
+ throw new Error(`flow: a route is a path starting with /, not "${path}"`);
66
69
  if (options.fallbackTemplate) {
67
70
  const declared = declaredTemplate(options.templates, options.fallbackTemplate);
68
71
  if (!declared)
@@ -313,7 +316,7 @@ export class Agent {
313
316
  // Released because they just wrote: their window is open, though their message may not have landed yet.
314
317
  const inbox = await this.store.releaseNudge(writer ? { ...n, body: { ...n.body, theyWrote: true } } : n);
315
318
  if (inbox !== null)
316
- this.wake(n.conversationId, inbox);
319
+ await this.wake(n.conversationId, inbox);
317
320
  }
318
321
  }
319
322
  /** Sends the traces of finished turns still waiting for the traces endpoint; call it before the process exits. */
@@ -701,6 +704,30 @@ export class Agent {
701
704
  hasProfile() {
702
705
  return this.options.profile !== undefined;
703
706
  }
707
+ /**
708
+ * A person the business may message before they have written (they gave it their number): their
709
+ * conversation, made the first time, with no message in it. Until they write, their WhatsApp
710
+ * window is closed, so only a template reaches them there.
711
+ */
712
+ async reach(to) {
713
+ await this.settings.ready;
714
+ const adapter = this.channelOf(to.channel);
715
+ const identity = await this.conversationFor(adapter, to.channel, to.address);
716
+ const conversation = this.open(identity.conversationId);
717
+ conversation.link({ name: to.channel, info: adapter.info, params: adapter.params, instructions: adapter.instructions, address: to.address, deliver: this.deliverer(adapter, to.address, identity.conversationId), lastMessageAt: identity.lastMessageAt });
718
+ return { conversationId: identity.conversationId };
719
+ }
720
+ /** A channel the agent was given, by name, as it was given (the test cloud plays a copy of it). */
721
+ channel(name) {
722
+ return this.channelsByName.get(name);
723
+ }
724
+ /** Answers a request to one of `routes` by its path; 404 for any other. */
725
+ async fetch(request) {
726
+ const route = this.options.routes?.[new URL(request.url).pathname];
727
+ if (!route)
728
+ return Response.json({ error: "no such route" }, { status: 404 });
729
+ return route(request);
730
+ }
704
731
  async nudge(input) {
705
732
  // outcome() waits while it is queued, so what it resolves is settled.
706
733
  return (await this.outcome(await this.recordNudge(input)));
@@ -715,6 +742,8 @@ export class Agent {
715
742
  const kinds = [input.text, input.prompt, input.template].filter((v) => v !== undefined).length;
716
743
  if (kinds !== 1)
717
744
  throw new Error("flow: a nudge has exactly one of text, prompt or template");
745
+ if (input.link && (input.text === undefined || input.at !== undefined))
746
+ throw new Error("flow: a nudge's link goes with text, sent now (not with `at`)");
718
747
  // A declared template whose values don't fit it never goes: Meta would refuse it, the playground would show nonsense.
719
748
  const declared = input.template && declaredTemplate(this.options.templates, input.template);
720
749
  if (declared)
@@ -755,7 +784,7 @@ export class Agent {
755
784
  const added = await add({ ...record, body: { text: input.text, prompt: input.prompt, at: input.at }, status: "scheduled" }, schedule);
756
785
  return typeof added === "number" ? { id, status: "scheduled" } : added;
757
786
  }
758
- const body = { address: input.to.address, ...(input.template ? { template: input.template } : input.text !== undefined ? { text: input.text } : { prompt: input.prompt }) };
787
+ const body = { address: input.to.address, ...(input.template ? { template: input.template } : input.text !== undefined ? { text: input.text, ...(input.link ? { link: input.link } : {}) } : { prompt: input.prompt }) };
759
788
  const now = this.now();
760
789
  const quietEnds = input.respectQuietHours === false ? null : await this.quietEnd(conversationId, now);
761
790
  if (quietEnds !== null) {
@@ -780,7 +809,7 @@ export class Agent {
780
809
  const added = await add({ ...record, body, status: "queued" });
781
810
  if (typeof added !== "number")
782
811
  return added;
783
- this.wake(conversationId, added);
812
+ void this.wake(conversationId, added);
784
813
  return { id, status: "queued" };
785
814
  }
786
815
  /** A nudge's state now, or null for an id the store does not hold. */
@@ -826,9 +855,11 @@ export class Agent {
826
855
  return `no way to reach them on ${s.channel}`;
827
856
  return windowClosed({ info: this.infoOf(s.channel, s.conversationId), lastMessageAt: identity.lastMessageAt }, now) ? "outside_window" : null;
828
857
  });
829
- for (const q of queued)
830
- this.wake(q.conversationId, q.inbox);
858
+ // Awaited, so a caller that then waits for the conversation (a test moving the clock) sees the turn.
859
+ await Promise.all(queued.map((q) => this.wake(q.conversationId, q.inbox)));
831
860
  await this.releaseHeld(now);
861
+ if (this.options.onTick)
862
+ await Promise.resolve().then(() => this.options.onTick(now)).catch((error) => console.warn("flow: onTick:", error));
832
863
  await this.pruneMessageIds(now).catch((error) => console.warn("flow: pruning message ids:", error));
833
864
  return queued.length;
834
865
  }
@@ -872,14 +903,13 @@ export class Agent {
872
903
  wake(conversationId, inbox) {
873
904
  const resident = this.conversations.get(conversationId);
874
905
  if (!resident) {
875
- void this.store.conversation(conversationId).then((row) => {
906
+ return this.store.conversation(conversationId).then((row) => {
876
907
  if (row)
877
908
  this.rows.set(conversationId, row);
878
909
  this.open(conversationId);
879
910
  });
880
- return;
881
911
  }
882
- void this.store.openInbox(conversationId).then((rows) => {
912
+ return this.store.openInbox(conversationId).then((rows) => {
883
913
  const row = rows.find((r) => r.id === inbox);
884
914
  if (row)
885
915
  resident.enqueue(row);
@@ -76,6 +76,8 @@ export interface Message {
76
76
  image?: {
77
77
  url: string;
78
78
  };
79
+ /** false: send it with link previews off (a message the business started). */
80
+ preview?: false;
79
81
  /** Given for a send the business starts (a nudge, a scheduled text): the same in every process, so a provider that takes one sends it once. */
80
82
  idempotencyKey?: string;
81
83
  }
@@ -159,6 +159,7 @@ function message(outbound) {
159
159
  ...(outbound.list ? { list: outbound.list } : {}),
160
160
  ...(outbound.link ? { link: outbound.link } : {}),
161
161
  ...(outbound.image ? { image: outbound.image } : {}),
162
+ ...(outbound.preview === false ? { preview: false } : {}),
162
163
  ...(outbound.idempotencyKey ? { idempotencyKey: outbound.idempotencyKey } : {}),
163
164
  };
164
165
  }
@@ -117,6 +117,8 @@ export declare class Conversation {
117
117
  private readonly tasks;
118
118
  private taskCount;
119
119
  private idleSince;
120
+ /** The turn running now was not started by the person (a nudge, a scheduled message, a background result). */
121
+ private businessTurn;
120
122
  private settled;
121
123
  /** Channels where nothing has reached the person since their latest message there. */
122
124
  private readonly unanswered;
@@ -101,6 +101,8 @@ export class Conversation {
101
101
  tasks = new Map();
102
102
  taskCount = 0;
103
103
  idleSince = Date.now();
104
+ /** The turn running now was not started by the person (a nudge, a scheduled message, a background result). */
105
+ businessTurn = false;
104
106
  settled = [];
105
107
  /** Channels where nothing has reached the person since their latest message there. */
106
108
  unanswered = new Set();
@@ -463,7 +465,7 @@ export class Conversation {
463
465
  this.notes.push({ text, inbox: row.id, owed: { nudge: p.nudge, mode: "prompt", channel: p.channel, address: p.address } });
464
466
  }
465
467
  else
466
- this.direct.push({ inbox: row.id, channel: p.channel, address: p.address, text: p.text, template: p.template, nudge: p.nudge, ...(p.theyWrote ? { theyWrote: true } : {}) });
468
+ this.direct.push({ inbox: row.id, channel: p.channel, address: p.address, text: p.text, ...(p.link ? { link: p.link } : {}), template: p.template, nudge: p.nudge, ...(p.theyWrote ? { theyWrote: true } : {}) });
467
469
  }
468
470
  if (!this.running && this.hasWork())
469
471
  this.schedule(0);
@@ -803,6 +805,7 @@ export class Conversation {
803
805
  this.absorb();
804
806
  // A turn the person started (as against a background result, a scheduled message or a nudge).
805
807
  const theirs = this.unanswered.size > 0;
808
+ this.businessTurn = !theirs;
806
809
  if (!this.channels.size) {
807
810
  // No way to reach them right now (a channel with no adapter, after a restart).
808
811
  for (const o of this.owed.splice(0))
@@ -1049,6 +1052,8 @@ export class Conversation {
1049
1052
  ...(input.link ? { link: input.link } : {}),
1050
1053
  ...(input.image ? { image: input.image } : {}),
1051
1054
  ...(hasFields ? { fields } : {}),
1055
+ // A turn the person did not start: no link preview leads the notification (types.ts, `preview`).
1056
+ ...(this.businessTurn ? { preview: false } : {}),
1052
1057
  });
1053
1058
  const failure = await this.send(channel, outbound);
1054
1059
  if (failure) {
@@ -1175,9 +1180,10 @@ export class Conversation {
1175
1180
  }
1176
1181
  if (covered)
1177
1182
  return settle("dropped", `no-contact rule ${covered.id}: ${covered.rule}`);
1183
+ // The business started it: its link goes as a button, and no preview leads the notification.
1178
1184
  // The same key in every process, so the channel's provider sends it once.
1179
1185
  const idempotencyKey = d.nudge ? `nudge:${d.nudge}` : d.run ? `run:${d.run}` : `inbox:${this.id}:${d.inbox}`;
1180
- const outbound = d.template ? { type: "template", ...d.template, idempotencyKey } : { type: "message", text: d.text ?? "", idempotencyKey };
1186
+ const outbound = d.template ? { type: "template", ...d.template, idempotencyKey } : fitMessage(link.info, { type: "message", text: d.text ?? "", ...(d.link ? { link: d.link } : {}), preview: false, idempotencyKey });
1181
1187
  let failure = null;
1182
1188
  if (this.closed)
1183
1189
  return settle("failed", "this person was forgotten");
package/dist/src/http.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // bearer token each.
4
4
  //
5
5
  // nudgeHandler:
6
- // POST {base} { to: { channel, address }, text | prompt | template, at?, idempotency_key? }
6
+ // POST {base} { to: { channel, address }, text (+ link?) | prompt | template, at?, idempotency_key? }
7
7
  // → 202 { id, status, reason? } as soon as it is recorded, before any send or turn
8
8
  // GET {base}/:id → 200 { id, status, reason? }, its state now; 404 for an id the agent never recorded
9
9
  //
@@ -68,6 +68,8 @@ const Body = z
68
68
  .object({
69
69
  to: z.object({ channel: z.string().min(1), address: z.string().min(1) }).strict(),
70
70
  text: z.string().min(1).optional(),
71
+ /** With text: its call to action, as a link button. */
72
+ link: z.object({ label: z.string().min(1), url: z.url({ protocol: /^https$/ }) }).strict().optional(),
71
73
  prompt: z.string().min(1).optional(),
72
74
  template: Template.optional(),
73
75
  /** Milliseconds since the epoch, as `agent.nudge()` takes it. */
@@ -80,7 +82,8 @@ const Body = z
80
82
  })
81
83
  .strict()
82
84
  .refine((b) => [b.text, b.prompt, b.template].filter((v) => v !== undefined).length === 1, { message: "give exactly one of text, prompt or template" })
83
- .refine((b) => !(b.template && b.at !== undefined), { message: "a template nudge cannot be scheduled yet: leave out at" });
85
+ .refine((b) => !(b.template && b.at !== undefined), { message: "a template nudge cannot be scheduled yet: leave out at" })
86
+ .refine((b) => !(b.link && (b.text === undefined || b.at !== undefined)), { message: "link goes with text, sent now (no at)" });
84
87
  /**
85
88
  * Nudges over HTTP, as a request handler for your own server: hand it the requests under `base`.
86
89
  * Requests outside `base` get 404 without a token check. A nudge refused or dropped as it is
@@ -5,6 +5,7 @@ export { nudgeHandler, peopleHandler, type NudgeHandlerOptions, type PeopleHandl
5
5
  export { sqlite, type Database } from "./db.ts";
6
6
  export { postgres, type PostgresPool } from "./postgres.ts";
7
7
  export { jsonlSink, type EventSink, type FlowEvent, type ChinwagEvent } from "./events.ts";
8
+ export { choosePort, markRunning } from "./local.ts";
8
9
  export { flowModel, flowModelURL, openAICompatible, type OpenAICompatibleOptions } from "./model.ts";
9
10
  export { Store, type StoreApi } from "./store.ts";
10
11
  export type { AgentFiles } from "./skills.ts";
package/dist/src/index.js CHANGED
@@ -5,6 +5,7 @@ export { nudgeHandler, peopleHandler } from "./http.js";
5
5
  export { sqlite } from "./db.js";
6
6
  export { postgres } from "./postgres.js";
7
7
  export { jsonlSink } from "./events.js";
8
+ export { choosePort, markRunning } from "./local.js";
8
9
  export { flowModel, flowModelURL, openAICompatible } from "./model.js";
9
10
  export { Store } from "./store.js";
10
11
  export { tool, toSpec } from "./tools.js";
@@ -0,0 +1,22 @@
1
+ /** True when nothing listens on 127.0.0.1:`port` (the address the playground and the MCP server bind). */
2
+ export declare function portFree(port: number): Promise<boolean>;
3
+ /**
4
+ * The port to listen on. `env[name]` set: that port, or an error naming a free one when it is taken
5
+ * (the caller prints its message and exits, so no stack). Not set: `fallback`, else the next free
6
+ * port above it, with `note` saying so. `command` is how the agent is started, for the error's
7
+ * "Run: PORT=8798 npm start".
8
+ */
9
+ export declare function choosePort(name: string, fallback: number, options: {
10
+ command: string;
11
+ env?: Record<string, string | undefined>;
12
+ }): Promise<{
13
+ port: number;
14
+ note?: string;
15
+ }>;
16
+ /** Writes .flow/running.json in `dir` (this process and its URL) and removes it when the process exits. */
17
+ export declare function markRunning(url: string, dir?: string): void;
18
+ /** The agent running from `dir` (its marker names a live process), or undefined. */
19
+ export declare function runningAgent(dir?: string): {
20
+ pid: number;
21
+ url: string;
22
+ } | undefined;