@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 CHANGED
@@ -50,7 +50,7 @@ Test it first in the playground (the link `npm start` prints: the test people an
50
50
 
51
51
  **Stability.** 0.x: the CLI's commands, `--json` fields and exit codes in [docs/agents.md](docs/agents.md) stay compatible within 0.2.x; a breaking change comes with a minor version bump and a release note. Pin the version in `package.json` for production.
52
52
 
53
- **Host it.** Coming soon: `npx @flow-engineer/flow deploy`, to keep the agent answering while the laptop sleeps.
53
+ **Host it.** `npx @flow-engineer/flow deploy` puts the agent on Flow's hosting, so it keeps answering while the laptop sleeps: staging first, then `deploy promote` to production; it sends the `.env`'s keys as the agent's secrets itself. `deploy status` shows what is live and its URL, `flow logs` its log lines. It needs the agent's CI key (`FLOW_FRAMEWORK_CLOUD_KEY`, which `flow setup` writes) and a `buildAgent` that passes `options.host` on to `createAgent` ([docs/agents.md](docs/agents.md), "Host it").
54
54
 
55
55
  ### In a folder of your own
56
56
 
@@ -648,7 +648,7 @@ cd agents/my-agent && npm start
648
648
 
649
649
  **Which channels are real?** WhatsApp, SMS and your own UI (`customUI`) have production definitions today; you plug in any provider by writing `sendMessage`. Telegram and iMessage are real through Flow Messaging (`fm.telegram()`, `fm.imessage()`, [flow-messaging/README.md](flow-messaging/README.md)); in core they are in the playground for now.
650
650
 
651
- **Does it host my agent?** No. It runs in your Node process, on your machine or servers. You pay your model and messaging providers directly.
651
+ **Does it host my agent?** The framework runs in your Node process, on your machine or servers, and you pay your model and messaging providers directly. Flow's hosting can run it instead, from the same `buildAgent` that `flow test` loads: `npx @flow-engineer/flow deploy`.
652
652
 
653
653
  **Is the data mine?** Yes. State lives in your database, and events go wherever you send them.
654
654
 
@@ -226,6 +226,14 @@ before any send or turn (`queued`, or `refused`/`dropped`/`scheduled` decided th
226
226
  `GET /nudges/<id>` reads its state. Both need `Authorization: Bearer <token>`. Contract:
227
227
  `spec/nudges.openapi.yaml`.
228
228
 
229
+ When the backend posts what happened (an order shipped, an item is back in stock) rather than what
230
+ to send, mount `flowEvents({ secret, catalog, timezone })` (from `@flow-engineer/flow`): its
231
+ `routes` (`POST /v1/events`, signed with `Flow-Signature`), `onTick` and `context(conversationId)`
232
+ go on `createAgent`, `events.phrasing.check(address, outbound)` in the channel's send (throw its
233
+ reason to refuse), then `events.attach(agent)`. Each type in the catalog has a delivery level
234
+ (critical, standard, optional), its template and its values; the guide is `docs/events.md` in the
235
+ package (`../docs/events.md` from this file).
236
+
229
237
  Templates are WhatsApp only. Declare the ones the owner set up with Meta beside `testPeople`, so
230
238
  Flow Framework checks each send against them and the playground shows them as WhatsApp does:
231
239
 
@@ -335,6 +343,7 @@ npm start # after link: its playground link,
335
343
  npx @flow-engineer/flow status --json # what the folder has and lacks, at any time
336
344
  printf 'hi\nA latte please\n' | npx @flow-engineer/flow chat # your own check: one message per line, replies printed
337
345
  npx @flow-engineer/flow test --synthetic # your own check: the tests, on the key setup wrote
346
+ npx @flow-engineer/flow deploy --json # when the owner wants it hosted: staging on Flow's cloud (see "Deploy it")
338
347
  ```
339
348
 
340
349
  When the owner asks how to test it, recommend first the playground (`npm start`, then open the
@@ -365,6 +374,39 @@ starter does this when `.env` has `FLOW_MESSAGING_KEY`. Each error is
365
374
  name the package (`npx @flow-engineer/flow ...`): `npx flow` on its own fetches another npm package.
366
375
  CI never logs in: `setup --ci` says how to give it the cloud key.
367
376
 
377
+ ## Deploy it
378
+
379
+ Flow hosts Flow Framework agents: `npx @flow-engineer/flow deploy` runs the agent's folder on Flow's
380
+ cloud, with its state kept for it, Flow Messaging's stream held, and its secrets kept apart from
381
+ every other agent's. Do not pick a VPS, Fly or Render on the owner's behalf: ask the owner whether
382
+ the agent goes on Flow's cloud or their own servers.
383
+
384
+ On Flow's cloud the agent needs only what `flow test` already loads: `src/agent.ts` exports
385
+ `buildAgent({ events, tools, host })` and passes `host` on to `createAgent` (`{ ...host }`), which
386
+ brings the store and, when the agent has `FLOW_MESSAGING_KEY`, `host.messaging`. An agent that drops
387
+ `host`, or brings a database of its own, is refused in production (a warning in staging). The CI
388
+ key is `FLOW_FRAMEWORK_CLOUD_KEY` (`flow setup` writes it into `.env`).
389
+
390
+ ```sh
391
+ npx @flow-engineer/flow deploy --json # staging, the first time too: sends .env's values as secrets
392
+ # (names printed, never values), builds, follows it to live; run its next
393
+ npx @flow-engineer/flow deploy promote --json # staging's live version to production, once the owner has tried it
394
+ npx @flow-engineer/flow deploy status --json # what is live in each environment: version, when, URL
395
+ npx @flow-engineer/flow logs --since 15m # the hosted agent's log lines (--follow for new ones)
396
+ printf '%s' "$VALUE" | npx @flow-engineer/flow deploy secret NAME --env production # one secret by hand, from stdin
397
+ ```
398
+
399
+ Promote, or `deploy --env production`, only when the owner says so. A refused deploy lists the
400
+ hosting lint's findings (`deploy.findings`): fix the errors and deploy again. One `deploy` is the whole first deploy: there is no
401
+ separate secrets step (it sends only new or changed `.env` values each time; promote sends them to
402
+ production). Commit first, or the version reads `<sha>+dirty`. Stop the laptop's `npm start` once the deployed one is live: two copies on one key
403
+ both answer every message.
404
+
405
+ On the owner's own servers: one instance, kept warm, CPU always on, a durable `database`
406
+ (`postgres(pool)`), `await agent.resume()` at start, the secrets from the platform's secret store
407
+ (never the `.env`), and Flow Messaging's stream (`fm.start`) with its cursor file on a disk that
408
+ survives a restart, or its webhook (`FLOW_MESSAGING_WEBHOOK_SECRET`) on a public https address.
409
+
368
410
  ## Send traces to a dashboard
369
411
 
370
412
  Every turn can go to a traces endpoint as an OTLP/JSON trace, posted to `<url>/v1/traces`, so
@@ -534,7 +576,7 @@ An operation already accepted by the agent may still complete after its client s
534
576
  Enable this only in local startup code. The runner is off by default, rejects non-loopback or
535
577
  forwarded connections, and is not part of the hosted test cloud. It calls the actual CLI runner
536
578
  only locally. Hosted CLI support is deferred pending security review; do not add it to cloud startup
537
- or deployment wiring. See the hosted CLI TODO in the README. The runner invokes the CLI
579
+ or deployment wiring (that is `flow deploy`). See the hosted CLI TODO in the README. The runner invokes the CLI
538
580
  without a shell: quoting works, pipelines/expansion do not. The browser cannot override server
539
581
  or token flags, or use `skill --write` to write onto the developer's computer.
540
582
  For other embedded clients, `CliConfig.directory` overrides credential/conversation storage;
@@ -0,0 +1,316 @@
1
+ import type { NudgeInput, NudgeResult } from "../agent.ts";
2
+ import type { Template } from "../types.ts";
3
+ import { type Phrasing } from "./phrasing.ts";
4
+ import { type EventQuietHours } from "./quiet.ts";
5
+ export type DeliveryLevel = "critical" | "standard" | "optional";
6
+ export declare const DELIVERY_LEVELS: readonly DeliveryLevel[];
7
+ /** The event cancelling one not yet sent: `data.cancels` is its id. */
8
+ export declare const CANCELLED_EVENT = "notification.cancelled";
9
+ /** One event, as the business posts it. */
10
+ export interface BusinessEvent {
11
+ /** Unique per event: the same id twice is processed once. */
12
+ id: string;
13
+ type: string;
14
+ occurredAt: string;
15
+ /** What it is about, as the business names it ("user:...", "order:..."). */
16
+ subject: string;
17
+ /** The floor: never sent at a lower level. Default: the type's in the catalog. */
18
+ delivery?: DeliveryLevel;
19
+ /** The business sent this message itself: kept as context, never sent. */
20
+ sentBy?: string;
21
+ /** Don't send after this (ISO time). Also read from `data.expiresAt`. */
22
+ expiresAt?: string;
23
+ /** A newer one with the same key replaces one still held. Also read from `data.collapseKey`. */
24
+ collapseKey?: string;
25
+ data: Record<string, any>;
26
+ }
27
+ /** A message ready either way: the template, its text as the person would see it, its values and its button link. */
28
+ export interface EventMessage {
29
+ template: Template;
30
+ text: string;
31
+ values: string[];
32
+ link: {
33
+ label: string;
34
+ url: string;
35
+ };
36
+ }
37
+ /** A type that becomes a message for one person. */
38
+ export interface NotificationType {
39
+ kind?: "notification";
40
+ /** Its default level, when the event names none. */
41
+ level: DeliveryLevel;
42
+ /** Its message, or the field it lacks (a 400 names it). */
43
+ message(event: BusinessEvent): EventMessage | {
44
+ missing: string;
45
+ };
46
+ /** Its button acts on a live topic: dropped once the topic closes, whatever the level. */
47
+ liveOnly?: boolean;
48
+ /** What the message is about (an order, a product): how watches raise it, closing drops it, caps count it. */
49
+ topic?(event: BusinessEvent): string | undefined;
50
+ /** The topic's name, to find a watch when the event names no topic. */
51
+ title?(event: BusinessEvent): string | undefined;
52
+ }
53
+ /** A type the app handles itself in `onEvent` (no message of its own). */
54
+ export interface AppEventType {
55
+ kind: "event";
56
+ /** Why it is malformed, naming the field; null when fine. */
57
+ validate?(event: BusinessEvent): EventProblem | null;
58
+ }
59
+ export type EventType = NotificationType | AppEventType;
60
+ export type EventCatalog = Record<string, EventType>;
61
+ export interface EventProblem {
62
+ field: string;
63
+ error: string;
64
+ }
65
+ /** A send, held or about to go. */
66
+ export interface PendingSend {
67
+ eventId: string;
68
+ /** The event type it came from (a nudge: `nudge.<kind>`), for logs. */
69
+ type?: string;
70
+ address: string;
71
+ delivery: DeliveryLevel | "nudge";
72
+ /** Raised by a watch: "watch" is kept, "urgent" also goes at once. */
73
+ raised?: "watch" | "urgent";
74
+ /** Never dropped as stale, whatever closes. */
75
+ sticky?: true;
76
+ /** Dropped when its topic closes, whatever the level. */
77
+ liveOnly?: true;
78
+ /** A nudge's kind, as the app names it. */
79
+ nudge?: string;
80
+ topic?: string;
81
+ collapseKey?: string;
82
+ notBefore: number;
83
+ expiresAt?: number;
84
+ /** The template, never the agent's words. */
85
+ exact: boolean;
86
+ message: EventMessage;
87
+ }
88
+ /** What a person asked to be told about. Apps add their own fields. */
89
+ export interface Watch {
90
+ id: string;
91
+ address: string;
92
+ /** Only watches with a topic raise messages. */
93
+ topic?: string;
94
+ /** The topic's name, as the agent saw it. */
95
+ title?: string;
96
+ /** "As soon as": raised messages go at once, even in quiet hours. */
97
+ urgent?: true;
98
+ createdAt: number;
99
+ /** When its topic closed. */
100
+ endedAt?: number;
101
+ }
102
+ /** A message the business sent itself (`sentBy`), kept as context. */
103
+ export interface EventCopy {
104
+ eventId: string;
105
+ type: string;
106
+ /** When it was sent (the envelope's occurredAt), ms. */
107
+ at: number;
108
+ text: string;
109
+ values: string[];
110
+ link: {
111
+ label: string;
112
+ url: string;
113
+ };
114
+ }
115
+ export interface SentNudge {
116
+ address: string;
117
+ topic?: string;
118
+ kind?: string;
119
+ at: number;
120
+ }
121
+ /** What the events side keeps. Pass your own (with extra fields of your app's) to share it. */
122
+ export declare function createEventState<W extends Watch = Watch>(): {
123
+ seen: Set<string>;
124
+ cancelled: Set<string>;
125
+ outbox: PendingSend[];
126
+ watches: W[];
127
+ /** Closed topics. */
128
+ closed: Set<string>;
129
+ /** `${address} ${topic}`: no more nudges about it. */
130
+ muted: Set<string>;
131
+ nudges: SentNudge[];
132
+ /** `${address} ${collapseKey|eventId}` -> when an optional last went. */
133
+ optionalLast: Map<string, number>;
134
+ /** The last nudged topic per address, for "not interested". */
135
+ lastNudged: Map<string, string>;
136
+ /** Address -> copies the business sent itself, newest last. */
137
+ copies: Map<string, EventCopy[]>;
138
+ /** `${address}\n${text}` -> when it reached them: the same message never goes again within a day. */
139
+ told: Map<string, number>;
140
+ /** Conversation id -> address. */
141
+ addresses: Map<string, string>;
142
+ };
143
+ export type EventState<W extends Watch = Watch> = ReturnType<typeof createEventState<W>>;
144
+ /** What sends: the agent (its nudge, and reach for copies). */
145
+ export interface EventSender {
146
+ nudge(input: NudgeInput): Promise<NudgeResult>;
147
+ reach?(to: {
148
+ channel: string;
149
+ address: string;
150
+ }): Promise<{
151
+ conversationId: string;
152
+ }>;
153
+ }
154
+ export interface FlowEventsOptions<W extends Watch = Watch> {
155
+ /** The whsec_ secret the business signs with; without it every POST is refused (503). */
156
+ secret?: string;
157
+ /** Every type accepted (plus notification.cancelled); any other is a 400. */
158
+ catalog: EventCatalog;
159
+ now?: () => number;
160
+ /** The channel messages go on. Default whatsapp. */
161
+ channel?: string;
162
+ /** The zone quiet hours and copies' times are read in. Default UTC. */
163
+ timezone?: string;
164
+ /** How the zone reads in log reasons and notes ("Pacific time"). Default the zone's name. */
165
+ timezoneName?: string;
166
+ /** Default 22:00 to 08:00; null: none. */
167
+ quietHours?: EventQuietHours | null;
168
+ /** Nudge caps (raised nudges skip them). Default 2 a day, 1 per topic a day, 5 a week. */
169
+ caps?: {
170
+ perDay: number;
171
+ perKeyPerDay: number;
172
+ perWeek: number;
173
+ };
174
+ /** How long a watch lasts. Default 30. */
175
+ watchDays?: number;
176
+ state?: EventState<W>;
177
+ /** Who may post copies (`sentBy`); none: sentBy is refused. */
178
+ sentBy?: string | string[];
179
+ /** The business's name in log reasons ("Acme"). Default the first sentBy, else "the business". */
180
+ businessName?: string;
181
+ /** Off production, who a business-started send may go to (eventTestList). */
182
+ testList?: {
183
+ allows(address: string): boolean;
184
+ };
185
+ /** A send the test list stopped. */
186
+ onUnlisted?(send: {
187
+ event: string;
188
+ type: string;
189
+ address: string;
190
+ }): void;
191
+ /** Each decision as fields (ids, types, outcomes; never text). Default one JSON line, numbers masked. */
192
+ log?(fields: Record<string, unknown>): void;
193
+ /** The default log line's msg. Default "flow event". */
194
+ logName?: string;
195
+ /** What a topic is called in log reasons ("order"). Default "topic". */
196
+ topicNoun?: string;
197
+ /** The log reason for a muted nudge. Default "muted (not interested)". */
198
+ mutedReason?: string;
199
+ /** Where a notification's person is. Default data.user.phone. */
200
+ addressOf?(event: BusinessEvent): string | undefined;
201
+ /** Whether they agreed to messages. Default data.consent.whatsappUpdates === true. */
202
+ consented?(event: BusinessEvent): boolean;
203
+ wording?: {
204
+ /** The note the agent gets to phrase a message (nudge: one the business started from a watch). */
205
+ prompt?(message: EventMessage, options: {
206
+ nudge: boolean;
207
+ }): string;
208
+ /** The stop line a nudge sent as text ends with. */
209
+ stopLine?: string;
210
+ /** The text fallback puts the link in the text instead of a link button. */
211
+ linkInText?: boolean;
212
+ };
213
+ copies?: {
214
+ kept?: number;
215
+ shown?: number;
216
+ days?: number;
217
+ /** The agent's note on recent copies; "" for none. */
218
+ note?(copies: EventCopy[], options: {
219
+ timezone: string;
220
+ timezoneName: string;
221
+ }): string;
222
+ };
223
+ rules?: {
224
+ /** Before the raise: "handled" when the app sent (or dropped) it itself; else it goes on. */
225
+ notification?(event: BusinessEvent, send: PendingSend, events: FlowEvents<W>): "send" | "handled" | Promise<"send" | "handled">;
226
+ /** A copy arrived: the app's own bookkeeping, and held sends it also counts as told. */
227
+ copy?(event: BusinessEvent, message: EventMessage, events: FlowEvents<W>): ((held: PendingSend) => boolean) | void;
228
+ };
229
+ /** Types of kind "event", in order with the rest. */
230
+ onEvent?(event: BusinessEvent, events: FlowEvents<W>): Promise<void> | void;
231
+ /** After held sends came due, in order with the rest. */
232
+ onTick?(now: number, events: FlowEvents<W>): Promise<void> | void;
233
+ }
234
+ /**
235
+ * The events side of an agent: mount `routes` and `onTick` on createAgent, `phrasing.check` in the
236
+ * channel's send, `context` in the agent's context, then `attach(agent)`.
237
+ */
238
+ export declare function flowEvents<W extends Watch = Watch>(options: FlowEventsOptions<W>): {
239
+ state: {
240
+ seen: Set<string>;
241
+ cancelled: Set<string>;
242
+ outbox: PendingSend[];
243
+ watches: W[];
244
+ /** Closed topics. */
245
+ closed: Set<string>;
246
+ /** `${address} ${topic}`: no more nudges about it. */
247
+ muted: Set<string>;
248
+ nudges: SentNudge[];
249
+ /** `${address} ${collapseKey|eventId}` -> when an optional last went. */
250
+ optionalLast: Map<string, number>;
251
+ /** The last nudged topic per address, for "not interested". */
252
+ lastNudged: Map<string, string>;
253
+ /** Address -> copies the business sent itself, newest last. */
254
+ copies: Map<string, EventCopy[]>;
255
+ /** `${address}\n${text}` -> when it reached them: the same message never goes again within a day. */
256
+ told: Map<string, number>;
257
+ /** Conversation id -> address. */
258
+ addresses: Map<string, string>;
259
+ };
260
+ phrasing: Phrasing;
261
+ handler: (req: Request) => Promise<Response>;
262
+ /** For createAgent's `routes`. */
263
+ routes: Record<string, (request: Request) => Promise<Response>>;
264
+ /** For createAgent's `onTick`. */
265
+ onTick: () => Promise<undefined>;
266
+ attach(s: EventSender): void;
267
+ invalid: (body: unknown) => EventProblem | null;
268
+ accept: (e: BusinessEvent) => boolean;
269
+ tick: () => Promise<unknown>;
270
+ /** Resolves once everything accepted so far is processed. */
271
+ idle: () => Promise<undefined>;
272
+ report: (fields: Record<string, unknown>) => void;
273
+ schedule: (p: PendingSend, exempt?: boolean) => Promise<void>;
274
+ drop: (match: (p: PendingSend) => boolean) => number;
275
+ raise: (event: string, type: string, delivery: DeliveryLevel, watch: Watch | undefined) => {
276
+ delivery: DeliveryLevel;
277
+ raised?: "watch" | "urgent";
278
+ };
279
+ /** This person's live watch on a topic (by topic, else by its name, with `titleOf` the app's names for topics). */
280
+ watchFor: (address: string, topic: string | undefined, title?: unknown) => W | undefined;
281
+ /** How a topic's name is found, for watches matched by name. */
282
+ setTitles(fn: (topic: string) => string | undefined): void;
283
+ /** Whether a watch is within its days. */
284
+ alive: (w: Watch, t?: number) => boolean;
285
+ told: (address: string, text: string) => boolean;
286
+ muted: (address: string, topic: string | undefined) => boolean;
287
+ isCopy: (e: BusinessEvent) => boolean;
288
+ /**
289
+ * Saves a watch; `same` finds one this person already has (it is refreshed: another 30 days, its
290
+ * title kept, urgent kept). "saved" or "already watching".
291
+ */
292
+ watch(w: Omit<W, "id" | "createdAt"> & {
293
+ createdAt?: number;
294
+ }, same?: (existing: W) => boolean): {
295
+ outcome: "saved" | "already watching";
296
+ watch: W;
297
+ };
298
+ /**
299
+ * A topic closed (sold, cancelled, ended): held sends about it are dropped, except a critical or
300
+ * raised one, a sticky one, and never a live-only one; its watches end. How many were dropped.
301
+ */
302
+ close(topic: string): number;
303
+ /** No more nudges to this person about this topic. */
304
+ mute(address: string, topic: string): void;
305
+ /** "Not interested" on a nudge: that topic is muted, its watch and held nudges go. The topic, or null. */
306
+ notInterested(address: string): string | null;
307
+ /** STOP: their watches end and held nudges go (the agent itself stops every send). */
308
+ stopped(address: string): void;
309
+ /** Which address a conversation is (from tool calls, copies). */
310
+ remember(conversationId: string, address: string): void;
311
+ /** The note on recent copies for this person; "" when none. */
312
+ copiesNote(address: string): string;
313
+ /** The turn's context: recent copies for this conversation's person ("" when none). `address` when the caller knows it. */
314
+ context(conversationId: string, address?: string | null): string;
315
+ };
316
+ export type FlowEvents<W extends Watch = Watch> = ReturnType<typeof flowEvents<W>>;