@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
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
  ```
@@ -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
@@ -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
 
@@ -28,8 +28,8 @@ export declare class Client {
28
28
  get<T>(path: string): Promise<T>;
29
29
  /** A file's bytes: GET /v1/files/{id} answers them or redirects to a signed URL, which fetch follows without the key. */
30
30
  download(fileId: string): Promise<Uint8Array>;
31
- /** A POST with its own Idempotency-Key, kept across the retries of this one call; `tries: 1` for courtesies. */
32
- post<T>(path: string, body: unknown, tries?: number): Promise<T>;
31
+ /** A POST with its own Idempotency-Key (the caller's, else a new one), kept across the retries of this one call; `tries: 1` for courtesies. */
32
+ post<T>(path: string, body: unknown, tries?: number, idempotencyKey?: string): Promise<T>;
33
33
  private request;
34
34
  }
35
35
  /**
@@ -66,9 +66,9 @@ export class Client {
66
66
  throw await errorOf(response);
67
67
  return new Uint8Array(await response.arrayBuffer());
68
68
  }
69
- /** A POST with its own Idempotency-Key, kept across the retries of this one call; `tries: 1` for courtesies. */
70
- post(path, body, tries = TRIES) {
71
- return this.request("POST", path, body, randomUUID(), tries);
69
+ /** A POST with its own Idempotency-Key (the caller's, else a new one), kept across the retries of this one call; `tries: 1` for courtesies. */
70
+ post(path, body, tries = TRIES, idempotencyKey = randomUUID()) {
71
+ return this.request("POST", path, body, idempotencyKey, tries);
72
72
  }
73
73
  async request(method, path, body, idempotencyKey, tries = TRIES) {
74
74
  if (!this.apiKey)
@@ -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) {
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 } : {}) });
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 });
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,20 +176,23 @@ 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
- await send(route, to, first, message.replyTo);
180
- for (const next of rest)
181
- await send(route, to, next);
182
+ const key = message.idempotencyKey;
183
+ await send(route, to, first, message.replyTo, message.replyTo, key, options(first));
184
+ for (const [i, next] of rest.entries())
185
+ await send(route, to, next, undefined, undefined, key && `${key}:${i + 1}`, options(next));
182
186
  },
183
187
  react: (to, messageId, emoji) => send(route, to, { type: "reaction", message_id: messageId, emoji }, undefined, messageId),
184
188
  typing: (to, on) => courtesy(route, to, "typing", { state: on ? "on" : "off" }),
185
189
  markRead: (to, messageIds) => courtesy(route, to, "read", { up_to: messageIds.at(-1) }, messageIds.at(-1)),
186
- sendTemplate: async (to, wanted) => {
190
+ sendTemplate: async (to, wanted, idempotencyKey) => {
187
191
  if (!route.sender)
188
192
  throw new Error(`a template needs the channel's sender (set FLOW_MESSAGING_${route.kind.toUpperCase()}_SENDER)`);
189
193
  const found = await template(route.sender, wanted);
190
194
  const params = templateParams(wanted);
191
- await send(route, to, { type: "template", template_id: found.id, language: wanted.language ?? found.language, ...(Object.keys(params).length ? { params } : {}) });
195
+ await send(route, to, { type: "template", template_id: found.id, language: wanted.language ?? found.language, ...(Object.keys(params).length ? { params } : {}) }, undefined, undefined, idempotencyKey);
192
196
  },
193
197
  };
194
198
  }
@@ -232,6 +236,7 @@ export function flowMessaging(options = {}) {
232
236
  ...(outbound.buttons && info.buttons ? { buttons: outbound.buttons } : {}),
233
237
  ...(outbound.link && info.linkButton ? { link: outbound.link } : {}),
234
238
  ...(outbound.image && info.images ? { image: outbound.image } : {}),
239
+ ...(outbound.idempotencyKey ? { idempotencyKey: outbound.idempotencyKey } : {}),
235
240
  });
236
241
  case "reaction":
237
242
  return p.react(to, outbound.messageId, outbound.emoji);
@@ -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,10 @@ 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;
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. */
82
+ idempotencyKey?: string;
79
83
  }
80
84
  export interface WhatsAppProvider {
81
85
  sendMessage(to: string, message: Message): Promise<void>;
@@ -90,12 +94,10 @@ export interface WhatsAppProvider {
90
94
  react?(to: string, messageId: string, emoji: string): Promise<void>;
91
95
  typing?(to: string, on: boolean): Promise<void>;
92
96
  markRead?(to: string, messageIds: string[]): Promise<void>;
93
- sendTemplate?(to: string, template: Template): Promise<void>;
97
+ sendTemplate?(to: string, template: Template, idempotencyKey?: string): Promise<void>;
94
98
  }
95
99
  export interface SmsProvider {
96
- sendMessage(to: string, message: {
97
- text: string;
98
- }): Promise<void>;
100
+ sendMessage(to: string, message: Pick<Message, "text" | "idempotencyKey">): Promise<void>;
99
101
  }
100
102
  /** Your own UI. `message` carries the text, any buttons, and your params by name. */
101
103
  export interface CustomUIProvider {
@@ -93,8 +93,8 @@ export function whatsapp(provider, options = {}) {
93
93
  case "template": {
94
94
  if (!provider.sendTemplate || off.has("templates"))
95
95
  throw new Error("this WhatsApp provider sends no templates");
96
- const { type: _type, ...template } = outbound;
97
- return provider.sendTemplate(to, template);
96
+ const { type: _type, idempotencyKey, ...template } = outbound;
97
+ return provider.sendTemplate(to, template, idempotencyKey);
98
98
  }
99
99
  case "status":
100
100
  return;
@@ -112,7 +112,7 @@ export function sms(provider, options = {}) {
112
112
  phone: true,
113
113
  send: (to, outbound) => {
114
114
  if (outbound.type === "message")
115
- return provider.sendMessage(to, { text: outbound.text });
115
+ return provider.sendMessage(to, { text: outbound.text, ...(outbound.idempotencyKey ? { idempotencyKey: outbound.idempotencyKey } : {}) });
116
116
  if (outbound.type === "template")
117
117
  throw new Error("SMS has no templates");
118
118
  // Reactions, typing, read receipts and status do not exist on SMS.
@@ -159,6 +159,8 @@ 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 } : {}),
163
+ ...(outbound.idempotencyKey ? { idempotencyKey: outbound.idempotencyKey } : {}),
162
164
  };
163
165
  }
164
166
  /**
@@ -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;
@@ -268,6 +270,7 @@ export declare class Conversation {
268
270
  private send;
269
271
  /** A fixed-text scheduled message or a text or template nudge, sent with no model turn. */
270
272
  private sendDirect;
273
+ private directSettled;
271
274
  /**
272
275
  * The history the model is sent: all of it while it fits the budget, else the latest entries
273
276
  * that do, from the start of one (never between a call and its result), after a line that says
@@ -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();
@@ -443,7 +445,7 @@ export class Conversation {
443
445
  }
444
446
  else if (row.kind === "schedule") {
445
447
  if (p.fixed)
446
- this.direct.push({ inbox: row.id, channel: p.channel, text: p.text, run: p.run });
448
+ this.direct.push({ inbox: row.id, channel: p.channel, text: p.text, run: p.run, schedule: p.schedule });
447
449
  else
448
450
  this.notes.push({ text: p.text, inbox: row.id, owed: { run: p.run, mode: p.mode, channel: p.channel } });
449
451
  }
@@ -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);
@@ -573,8 +575,14 @@ export class Conversation {
573
575
  this.note(`[Chinwag] ${task.tool} (${task.taskId}) was still running in the background when the agent restarted. It may or may not have finished; check before telling them it is done.`);
574
576
  this.persist(() => store.removeTask(this.id, task.taskId));
575
577
  }
576
- for (const row of await store.openInbox(this.id))
577
- this.enqueue(row);
578
+ for (const row of await store.openInbox(this.id)) {
579
+ // Already in the history (its row was not marked done): answered, so not answered again.
580
+ const p = row.payload;
581
+ if (row.kind === "message" && this.messageIds.get(p.id) === p.channel)
582
+ this.persist(() => store.doneInbox([row.id]));
583
+ else
584
+ this.enqueue(row);
585
+ }
578
586
  const row = await store.conversation(this.id);
579
587
  this.resumeTurn = row?.turnOpen === true;
580
588
  if ((this.resumeTurn || this.hasWork()) && !this.running)
@@ -797,6 +805,7 @@ export class Conversation {
797
805
  this.absorb();
798
806
  // A turn the person started (as against a background result, a scheduled message or a nudge).
799
807
  const theirs = this.unanswered.size > 0;
808
+ this.businessTurn = !theirs;
800
809
  if (!this.channels.size) {
801
810
  // No way to reach them right now (a channel with no adapter, after a restart).
802
811
  for (const o of this.owed.splice(0))
@@ -1043,6 +1052,8 @@ export class Conversation {
1043
1052
  ...(input.link ? { link: input.link } : {}),
1044
1053
  ...(input.image ? { image: input.image } : {}),
1045
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 } : {}),
1046
1057
  });
1047
1058
  const failure = await this.send(channel, outbound);
1048
1059
  if (failure) {
@@ -1136,6 +1147,12 @@ export class Conversation {
1136
1147
  }
1137
1148
  /** A fixed-text scheduled message or a text or template nudge, sent with no model turn. */
1138
1149
  async sendDirect(d) {
1150
+ // Settled already (by an earlier process, or another one running beside this): never sent twice.
1151
+ if (await this.directSettled(d)) {
1152
+ if (d.inbox !== undefined)
1153
+ this.persist(() => this.settings.store.doneInbox([d.inbox]));
1154
+ return;
1155
+ }
1139
1156
  const settle = (status, reason) => {
1140
1157
  this.settle(d, status, reason);
1141
1158
  this.flush([d]);
@@ -1163,7 +1180,10 @@ export class Conversation {
1163
1180
  }
1164
1181
  if (covered)
1165
1182
  return settle("dropped", `no-contact rule ${covered.id}: ${covered.rule}`);
1166
- const outbound = d.template ? { type: "template", ...d.template } : { type: "message", text: d.text ?? "" };
1183
+ // The business started it: its link goes as a button, and no preview leads the notification.
1184
+ // The same key in every process, so the channel's provider sends it once.
1185
+ const idempotencyKey = d.nudge ? `nudge:${d.nudge}` : d.run ? `run:${d.run}` : `inbox:${this.id}:${d.inbox}`;
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 });
1167
1187
  let failure = null;
1168
1188
  if (this.closed)
1169
1189
  return settle("failed", "this person was forgotten");
@@ -1195,6 +1215,18 @@ export class Conversation {
1195
1215
  this.settings.emit(this.id, "message.out", { text: d.text ?? d.template?.name, channel: d.channel, direct: true });
1196
1216
  settle("sent");
1197
1217
  }
1218
+ async directSettled(d) {
1219
+ const { store } = this.settings;
1220
+ if (d.nudge) {
1221
+ const status = (await store.nudge(d.nudge))?.status;
1222
+ return status !== undefined && !["queued", "held", "scheduled"].includes(status);
1223
+ }
1224
+ if (d.run && d.schedule) {
1225
+ const outcome = (await store.runs(d.schedule)).find((r) => r.id === d.run)?.outcome;
1226
+ return outcome !== undefined && outcome !== "queued" && outcome !== "held";
1227
+ }
1228
+ return false;
1229
+ }
1198
1230
  /**
1199
1231
  * The history the model is sent: all of it while it fits the budget, else the latest entries
1200
1232
  * that do, from the start of one (never between a call and its result), after a line that says