@flow-engineer/flow 0.3.4 → 0.4.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.
package/docs/events.md ADDED
@@ -0,0 +1,243 @@
1
+ # Business events
2
+
3
+ A business's backend posts what happened (an order shipped, an order is late, an item is back in
4
+ stock) to one signed route on the agent, `POST /v1/events`. The agent turns each event into a
5
+ WhatsApp message for one person, at the event's delivery level: some go at once, some wait for the
6
+ end of quiet hours, some are merged or dropped. Sends go through the agent's `nudge()`, so STOP and
7
+ the no-contact rules apply to every one.
8
+
9
+ `flowEvents` (from `@flow-engineer/flow`) is the engine. What your events mean (which types exist,
10
+ their templates and wording, your own rules) stays in your app.
11
+
12
+ ## The envelope
13
+
14
+ ```json
15
+ {
16
+ "id": "evt_01J9Z6",
17
+ "type": "order.shipped",
18
+ "occurredAt": "2026-10-20T14:05:00Z",
19
+ "subject": "order:4821",
20
+ "delivery": "critical",
21
+ "data": {
22
+ "user": { "phone": "+15550001234" },
23
+ "consent": { "whatsappUpdates": true },
24
+ "order": { "number": "4821", "carrier": "Parcelco", "trackingUrl": "https://shop.example/t/4821" }
25
+ }
26
+ }
27
+ ```
28
+
29
+ | Field | |
30
+ |---|---|
31
+ | `id` | Required. Unique per event: the same id twice is processed once. |
32
+ | `type` | Required. One of your catalog's types, or `notification.cancelled`. Any other is a 400. |
33
+ | `occurredAt` | Required. ISO time. |
34
+ | `subject` | Required. What it is about, as you name it (`order:4821`, `user:77`). |
35
+ | `delivery` | `critical`, `standard` or `optional`. The floor: never sent lower. Default: the type's level. |
36
+ | `data` | Required object. Your payload. |
37
+ | `sentBy` | You already sent this message yourself: kept as context, never sent (see Copies). |
38
+ | `expiresAt` | ISO time: not sent after it. Also read from `data.expiresAt`. |
39
+ | `collapseKey` | A newer event with the same key replaces one still held. Also read from `data.collapseKey`. |
40
+
41
+ By default the person is `data.user.phone` (E.164, required on a notification) and they agreed to
42
+ messages when `data.consent.whatsappUpdates === true`; without consent nothing is sent. Change
43
+ either with `addressOf(event)` and `consented(event)`. `data.wording: "exact"` always sends the
44
+ template, never the agent's words.
45
+
46
+ To cancel one not yet sent, post `notification.cancelled` with the id in `data.cancels`:
47
+
48
+ ```json
49
+ { "id": "evt_01J9Z7", "type": "notification.cancelled", "occurredAt": "2026-10-20T14:06:00Z",
50
+ "subject": "order:4821", "data": { "cancels": "evt_01J9Z6" } }
51
+ ```
52
+
53
+ ## Signing
54
+
55
+ Every POST carries `Flow-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>`, the HMAC of
56
+ `t.{t}.{raw body}` with the secret you gave the agent (`whsec_...`). `t` may be at most 5 minutes
57
+ off. The header may hold several `v1` values, so a secret can be rotated without dropping events.
58
+ `signEvent(secret, body)` makes the header; `verifyEventSignature(secret, header, body)` checks one.
59
+
60
+ From any Node backend, without the package:
61
+
62
+ ```js
63
+ import { createHmac } from "node:crypto";
64
+
65
+ const body = JSON.stringify(event);
66
+ const t = Math.floor(Date.now() / 1000);
67
+ const v1 = createHmac("sha256", process.env.FLOW_EVENTS_SECRET).update(`t.${t}.${body}`).digest("hex");
68
+ await fetch("https://agent.example/v1/events", {
69
+ method: "POST",
70
+ headers: { "content-type": "application/json", "flow-signature": `t=${t},v1=${v1}` },
71
+ body,
72
+ });
73
+ ```
74
+
75
+ Sign the exact bytes you send.
76
+
77
+ ## Answers
78
+
79
+ | Status | Body | When |
80
+ |---|---|---|
81
+ | 202 | `{"status":"accepted"}` or `{"status":"duplicate"}` | Recorded; processed in order in the background. A duplicate id was already taken. |
82
+ | 400 | `{"field","error"}` | Malformed; `field` names what is wrong (`data.user.phone`, `type`, a value your message needs). |
83
+ | 401 | `{"error"}` | Bad or missing `Flow-Signature`. |
84
+ | 405 | `{"error"}` | Not a POST. |
85
+ | 503 | `{"error"}` | The agent has no secret configured. |
86
+
87
+ On a network error, a 429 or a 5xx, retry with the same `id`: the id dedupes, so a retry never
88
+ sends twice. An `Idempotency-Key` header is honoured too.
89
+
90
+ ## Delivery levels
91
+
92
+ | Level | In quiet hours | Also |
93
+ |---|---|---|
94
+ | `critical` | Sent at once. | |
95
+ | `standard` | Held, sent when they end (on the agent's tick). | |
96
+ | `optional` | Held too. | One a day per `collapseKey`; dropped when its topic closes. |
97
+
98
+ - `liveOnly` on a type: its button acts on something live, so it is dropped once its topic closes,
99
+ whatever the level.
100
+ - `collapseKey`: a newer standard or optional event with the same key replaces the one held.
101
+ - `expiresAt`: a held one past it is dropped.
102
+ - Quiet hours are 22:00 to 08:00 in `timezone` (default UTC); `quietHours: { from, to }` changes
103
+ them, `null` turns them off.
104
+
105
+ A topic (`topic(event)` on the type, such as `order:4821`) closes with `events.close(topic)`.
106
+
107
+ ### Watches raise a message
108
+
109
+ When a person asked to be told about a topic (`events.watch({ address, topic, title })`, from one of
110
+ your tools), a message about it is raised: an optional one is kept as standard, it is never dropped
111
+ when the topic closes and it skips the caps. A watch with `urgent: true` ("tell me as soon as") makes
112
+ it critical. A message is never lowered. Watches last `watchDays` (30) and end a day after
113
+ `close(topic)`.
114
+
115
+ ## Wording
116
+
117
+ Outside WhatsApp's 24-hour window the template goes. Inside it the agent phrases the message to fit
118
+ the chat; every value and the link are checked by `events.phrasing.check` in the channel's send. A
119
+ message that drops a value or changes the link is refused, and the template goes instead; if the
120
+ template fails, its text goes as a message with a link button (`wording: { linkInText: true }` puts
121
+ the link in the text). `data.wording: "exact"` always uses the template.
122
+
123
+ ## Copies
124
+
125
+ `sentBy` says the business already sent this message itself (allowed only for the names in the
126
+ `sentBy` option). It is never sent. It is kept as the agent's context, read with
127
+ `events.context(conversationId)`, so the agent can answer when they reply about it. It counts as
128
+ already told: an identical message within a day, and a held one with the same `collapseKey`, are
129
+ not sent. If the agent has `reach()`, the person's conversation is opened, so the copy is there when
130
+ they write.
131
+
132
+ ## Nudges
133
+
134
+ Messages you start from what people watch (rather than from an event) go through
135
+ `events.schedule({ delivery: "nudge", nudge: "<kind>", eventId, address, topic, message, notBefore: Date.now(), exact: false })`.
136
+ They are held in quiet hours and capped: `caps: { perDay: 2, perKeyPerDay: 1, perWeek: 5 }` by
137
+ default (per person; `perKeyPerDay` per topic). A raised nudge skips the caps.
138
+ `events.notInterested(address)` mutes the topic last nudged and ends its watch;
139
+ `events.mute(address, topic)` mutes one; `events.stopped(address)` ends their watches and held nudges.
140
+
141
+ ## Test list
142
+
143
+ Off production, a send the business starts goes only to listed numbers, so a staging copy of real
144
+ data never reaches a real customer:
145
+
146
+ ```ts
147
+ testList: eventTestList({ testing: false, madeUp: /^\+1555/ })
148
+ ```
149
+
150
+ - `FLOW_ENVIRONMENT=production`: no list, everyone.
151
+ - Otherwise only `FLOW_TEST_NUMBERS` (comma-separated E.164); none set sends nothing.
152
+ - `testing: true` also allows the numbers `madeUp` matches (a test run's made-up numbers).
153
+
154
+ A stopped send calls `onUnlisted({ event, type, address })`, else it is logged. A held send is
155
+ checked again when it comes due. Replies to people who wrote first are never checked.
156
+
157
+ ## App event types
158
+
159
+ A catalog type with `kind: "event"` sends nothing itself: it is handled in your `onEvent(event, events)`,
160
+ in order with the rest. Its `validate(event)` returns `{ field, error }` for a 400, or null.
161
+
162
+ ## What stays in your app
163
+
164
+ The catalog (each type's level, `message`, `topic`, `liveOnly`), the templates and wording, and your
165
+ rules: `rules.notification` (return `"handled"` when you sent or dropped it yourself),
166
+ `rules.copy`, `onEvent` and `onTick`.
167
+
168
+ The engine keeps its state in memory (`createEventState()`); pass your own `state` to keep or share it.
169
+
170
+ ## Mount it
171
+
172
+ ```ts
173
+ import { createAgent, eventTestList, flowEvents, whatsapp, type BusinessEvent, type EventCatalog } from "@flow-engineer/flow";
174
+
175
+ const order = (e: BusinessEvent) => e.data.order ?? {};
176
+
177
+ const catalog: EventCatalog = {
178
+ "order.shipped": {
179
+ level: "critical",
180
+ topic: (e) => e.subject,
181
+ message: (e) => {
182
+ const o = order(e);
183
+ if (!o.trackingUrl) return { missing: "data.order.trackingUrl" };
184
+ return {
185
+ template: { name: "order_shipped", body: [o.number, o.carrier] },
186
+ text: `Order ${o.number} shipped with ${o.carrier}.`,
187
+ values: [o.number, o.carrier],
188
+ link: { label: "Track it", url: o.trackingUrl },
189
+ };
190
+ },
191
+ },
192
+ "order.delayed": {
193
+ level: "standard",
194
+ liveOnly: true,
195
+ topic: (e) => e.subject,
196
+ message: (e) => {
197
+ const o = order(e);
198
+ if (!o.newDate) return { missing: "data.order.newDate" };
199
+ return {
200
+ template: { name: "order_delayed", body: [o.number, o.newDate] },
201
+ text: `Order ${o.number} is running late; it should arrive ${o.newDate}.`,
202
+ values: [o.number, o.newDate],
203
+ link: { label: "See order", url: `https://shop.example/o/${o.number}` },
204
+ };
205
+ },
206
+ },
207
+ restock: {
208
+ level: "optional", // posted with a collapseKey, such as "restock:sku-19"
209
+ topic: (e) => e.subject,
210
+ message: (e) => ({
211
+ template: { name: "back_in_stock", body: [e.data.item] },
212
+ text: `${e.data.item} is back in stock.`,
213
+ values: [e.data.item],
214
+ link: { label: "Shop now", url: e.data.url },
215
+ }),
216
+ },
217
+ };
218
+
219
+ const events = flowEvents({ secret: process.env.FLOW_EVENTS_SECRET, catalog, timezone: "America/New_York", testList: eventTestList() });
220
+
221
+ // The channel's send checks a phrased notification before it goes; a throw refuses it.
222
+ const wa = whatsapp(provider);
223
+ const checked = {
224
+ ...wa,
225
+ send: (to: string, out: Parameters<typeof wa.send>[1]) => {
226
+ const why = events.phrasing.check(to, out);
227
+ if (why) throw new Error(why);
228
+ return wa.send(to, out);
229
+ },
230
+ };
231
+
232
+ const agent = createAgent({
233
+ // ...dir, model, tools, templates
234
+ channels: [checked],
235
+ routes: events.routes,
236
+ onTick: events.onTick,
237
+ context: (conversationId) => events.context(conversationId),
238
+ });
239
+ events.attach(agent);
240
+ ```
241
+
242
+ The agent then answers `POST /v1/events` through `agent.fetch(request)`, and held sends go out on
243
+ its tick.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flow-engineer/flow",
3
- "version": "0.3.4",
3
+ "version": "0.4.1",
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": {
@@ -51,6 +51,7 @@
51
51
  "examples/http-agent",
52
52
  "flow-messaging/README.md",
53
53
  "docs/agents.md",
54
+ "docs/events.md",
54
55
  "coding-agents",
55
56
  "spec",
56
57
  "README.md",