@flow-engineer/flow 0.3.3 → 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 +2 -2
- package/coding-agents/skill.md +43 -1
- 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/account.js +4 -0
- package/dist/testing/src/business.js +3 -3
- package/dist/testing/src/cli.js +8 -2
- package/dist/testing/src/deploy.d.ts +36 -0
- package/dist/testing/src/deploy.js +640 -0
- package/docs/agents.md +63 -1
- package/docs/events.md +243 -0
- package/package.json +2 -1
package/docs/agents.md
CHANGED
|
@@ -173,6 +173,13 @@ preview card's title becomes the phone's notification instead of the message's t
|
|
|
173
173
|
action use `link` (`agent.nudge({ to, text, link: { label, url } })`, or the agent's send with
|
|
174
174
|
`link`), not a URL in the text.
|
|
175
175
|
|
|
176
|
+
When the business's backend should tell people what happened (an order shipped, an item is back),
|
|
177
|
+
mount `flowEvents` instead of writing a route: one signed `POST /v1/events`, each event sent by its
|
|
178
|
+
delivery level (critical at once, standard after quiet hours, optional merged or dropped), with the
|
|
179
|
+
agent's own wording checked against the template's values. The envelope, signing, answers and a
|
|
180
|
+
mounting sample are in `docs/events.md` in the package
|
|
181
|
+
(https://github.com/flow-engineer/flow/blob/main/docs/events.md).
|
|
182
|
+
|
|
176
183
|
## Shared test numbers and privacy
|
|
177
184
|
|
|
178
185
|
The free trial runs on Flow's shared Telegram bot, WhatsApp number and iMessage line. Sending an
|
|
@@ -190,4 +197,59 @@ version in `package.json` for production.
|
|
|
190
197
|
|
|
191
198
|
## Host it
|
|
192
199
|
|
|
193
|
-
|
|
200
|
+
`npx @flow-engineer/flow deploy` puts the agent on Flow's hosting, so it answers while the laptop
|
|
201
|
+
sleeps. It needs `package.json`, `package-lock.json` and `src/agent.ts`, whose `buildAgent` passes
|
|
202
|
+
`options.host` on to `createAgent`, and the agent's CI key in `FLOW_FRAMEWORK_CLOUD_KEY` (the
|
|
203
|
+
environment, else `.env`; `flow setup` writes it).
|
|
204
|
+
|
|
205
|
+
One command deploys, the first time included:
|
|
206
|
+
|
|
207
|
+
```sh
|
|
208
|
+
npx @flow-engineer/flow deploy
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
It sends the `.env`'s values as the agent's secrets, then builds and follows it to live. The rest:
|
|
212
|
+
|
|
213
|
+
```sh
|
|
214
|
+
npx @flow-engineer/flow deploy [--env staging|production] [--version <v>] [--no-secrets] [--json]
|
|
215
|
+
npx @flow-engineer/flow deploy promote [--no-secrets] [--json]
|
|
216
|
+
npx @flow-engineer/flow deploy status [--env staging|production] [--json]
|
|
217
|
+
npx @flow-engineer/flow logs [--env staging|production] [--since 1h] [--follow] [--json]
|
|
218
|
+
npx @flow-engineer/flow deploy secret NAME [--env staging|production] < value.txt
|
|
219
|
+
npx @flow-engineer/flow deploy secrets --from-env [<file>] [--env staging|production] [--json]
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
- `deploy` first sends each non-empty value in the agent's `.env` as a secret for that
|
|
223
|
+
environment, before the service starts, so the first live version has its keys: never the CI
|
|
224
|
+
key, the cloud's address or a name Flow sets itself (`FLOW_FRAMEWORK_TRACES_*`, `FLOW_STORE_*`,
|
|
225
|
+
`PORT`, ...). Later deploys send only the new or changed ones (`.flow/secrets.json` keeps a hash
|
|
226
|
+
per name, never a value). It prints the names it sent, never values; `--no-secrets` sends none.
|
|
227
|
+
A secret the host refuses stops the deploy before it starts, naming it.
|
|
228
|
+
- `deploy` packs the folder (without `node_modules`, `.git`, `.env*` and `.flow`, at most 50 MB),
|
|
229
|
+
uploads it, and follows the build until it is live, refused or failed. The default is staging;
|
|
230
|
+
production refuses an agent that ignores `host` or brings its own database, and the build
|
|
231
|
+
refuses an agent on a newer framework than Flow hosts yet. `--json` prints
|
|
232
|
+
`{ ok, deploy: { id, environment, status, version, findings, error }, url, next }` on stdout
|
|
233
|
+
(`url` is null when the cloud does not say it yet; the human output then says "URL: not available
|
|
234
|
+
on this Flow yet"); progress goes to stderr. The deploy's id is kept in `.flow/deploys.json`. The
|
|
235
|
+
version is the folder's git commit, `<sha>+dirty` (with a warning) when the folder has
|
|
236
|
+
uncommitted changes, since what is packed is the folder as it is; `--version` names it yourself.
|
|
237
|
+
- `promote` moves staging's live version to production, with no new build. Production keeps its
|
|
238
|
+
own secrets: promote sends the `.env`'s new or changed values there first (`--no-secrets` sends none).
|
|
239
|
+
- `status` shows, per environment (both unless `--env`), the deploy serving it (`status`, `version`,
|
|
240
|
+
`id`, `updated_at`), its URL and a newer one still building. `--json` prints
|
|
241
|
+
`{ ok, environments: { staging: { deploy, pending, url, from } | null, production: ... }, next }`;
|
|
242
|
+
`from` is `list` (the cloud's list) or `this_folder` (the last deploy from `.flow/deploys.json`, on
|
|
243
|
+
a Flow whose cloud does not list deploys to a CI key yet).
|
|
244
|
+
- `logs` prints the hosted agent's log lines from `--since` (30s, 15m, 1h, 2d; default 1h), and with
|
|
245
|
+
`--follow` keeps printing new ones; `--json` is one `{ time, severity, text }` per line. On a Flow
|
|
246
|
+
whose cloud has no logs route yet it says so: `error.code` `not_available`, exit 1.
|
|
247
|
+
- `secret NAME` (one value from stdin) and `secrets --from-env` (every value in a file, now) are for
|
|
248
|
+
changing a secret by hand; `deploy` already sends the `.env`. `--json` prints
|
|
249
|
+
`{ ok, environment, saved, skipped, refused, next }`. Before the agent's first deploy the host has
|
|
250
|
+
no agent to keep them for: they are refused, exit 1, and `next` is `deploy`. A secret takes effect
|
|
251
|
+
on the next deploy.
|
|
252
|
+
- Exit codes: 0 live or saved, 1 refused, failed or not reachable, 2 a usage problem (no CI key, a
|
|
253
|
+
missing file, a bad name). On an error, `--json` prints `{ ok: false, error: { code, message }, next }`.
|
|
254
|
+
|
|
255
|
+
Run each answer's `next`. Promote, or deploy to production, only when the owner says so.
|
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",
|