@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/README.md +1 -1
- package/coding-agents/skill.md +14 -5
- package/dist/src/business-events/engine.d.ts +316 -0
- package/dist/src/business-events/engine.js +547 -0
- package/dist/src/business-events/index.d.ts +5 -0
- package/dist/src/business-events/index.js +5 -0
- package/dist/src/business-events/phrasing.d.ts +13 -0
- package/dist/src/business-events/phrasing.js +33 -0
- package/dist/src/business-events/quiet.d.ts +8 -0
- package/dist/src/business-events/quiet.js +25 -0
- package/dist/src/business-events/signature.d.ts +8 -0
- package/dist/src/business-events/signature.js +36 -0
- package/dist/src/business-events/testlist.d.ts +15 -0
- package/dist/src/business-events/testlist.js +24 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/testing/src/business.js +3 -3
- package/dist/testing/src/deploy.d.ts +4 -2
- package/dist/testing/src/deploy.js +138 -33
- package/docs/agents.md +34 -9
- package/docs/events.md +243 -0
- package/package.json +2 -1
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
|
+
"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",
|