@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/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
- Coming soon: `npx @flow-engineer/flow deploy`, to keep the agent answering while the laptop sleeps.
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.3",
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",