@flow-engineer/flow 0.2.3 → 0.3.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 +3 -3
- package/coding-agents/skill.md +24 -9
- package/coding-agents/test-skill.md +50 -1
- package/dist/flow-messaging/src/index.js +8 -5
- package/dist/simulators/src/server.d.ts +2 -0
- package/dist/simulators/src/server.js +7 -1
- package/dist/src/agent.d.ts +32 -0
- package/dist/src/agent.js +38 -8
- package/dist/src/channels.d.ts +2 -0
- package/dist/src/channels.js +1 -0
- package/dist/src/conversation.d.ts +2 -0
- package/dist/src/conversation.js +8 -2
- package/dist/src/http.js +5 -2
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/local.d.ts +22 -0
- package/dist/src/local.js +85 -0
- package/dist/src/types.d.ts +6 -0
- package/dist/testing/src/account.d.ts +1 -1
- package/dist/testing/src/account.js +107 -29
- package/dist/testing/src/business.d.ts +42 -0
- package/dist/testing/src/business.js +145 -0
- package/dist/testing/src/cli.js +10 -8
- package/dist/testing/src/client.d.ts +4 -2
- package/dist/testing/src/client.js +20 -0
- package/dist/testing/src/index.d.ts +2 -2
- package/dist/testing/src/index.js +1 -1
- package/dist/testing/src/new.d.ts +9 -0
- package/dist/testing/src/new.js +44 -5
- package/dist/testing/src/protocol.d.ts +99 -1
- package/dist/testing/src/protocol.js +9 -0
- package/dist/testing/src/suite.d.ts +142 -3
- package/dist/testing/src/suite.js +94 -10
- package/docs/agents.md +40 -10
- package/examples/cafe/src/agent.ts +3 -3
- package/examples/cafe/src/channels.ts +1 -1
- package/examples/cafe/src/main.ts +23 -8
- package/examples/cafe/src/mcp.ts +24 -6
- package/examples/cafe/src/tools.ts +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,17 +27,17 @@ Node 22.18 or later. Your agent is TypeScript that Node runs as it is, with no b
|
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
29
|
npx @flow-engineer/flow new my-agent --display-name "My agent" && cd my-agent && npm install
|
|
30
|
-
npx @flow-engineer/flow link # signs in; writes Flow's free trial model key and a phone link (or put your own key in .env)
|
|
30
|
+
npx @flow-engineer/flow link --channel telegram # or whatsapp, imessage: signs in; writes Flow's free trial model key and a phone link (or put your own key in .env)
|
|
31
31
|
npm start
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
`flow new <name>` makes `./<name>/` from the café example (lowercase letters, digits and dashes); `--display-name` is what people call it, kept as `displayName` in its `package.json`, and a phone reaches it by sending `Hi <that name> (ref <code>)`. Open the link `npm start` prints and text
|
|
34
|
+
`flow new <name>` makes `./<name>/` from the café example (lowercase letters, digits and dashes); `--display-name` is what people call it, kept as `displayName` in its `package.json`, and a phone reaches it by sending `Hi <that name> (ref <code>)`. Open the link `npm start` prints (it uses `PORT`, else 8788 or the next free port) and text your agent, the café's ordering assistant under the name you gave it (Pip in the example): your starting point; `flow link --display-name <name>` renames it later. Pick a platform at the top (SMS, WhatsApp, iMessage, Telegram, web), and turn on **Debug** to see every tool call and how fast each message was answered. A model call that fails (a wrong or expired key, say) shows in the chat, in Debug and on the terminal. The café's `src/agent.ts` reads its model id from `CAFE_MODEL` (default: the model for the key you set, see [Models](#models)). The new folder has the coding-agent skills in `.claude/skills/` and an `AGENTS.md` pointing at them, so you can tell Claude Code, Codex or any coding agent what to build.
|
|
35
35
|
|
|
36
36
|
### On your phone, from your laptop
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
39
|
npx @flow-engineer/flow init # the same starter in the current folder; then npm install
|
|
40
|
-
npx @flow-engineer/flow link
|
|
40
|
+
npx @flow-engineer/flow link --channel telegram # signs in (opens the browser), writes its keys and Flow's trial model key into .env, prints a link
|
|
41
41
|
npm start # try it in the playground link it prints, then open the phone link and text the agent
|
|
42
42
|
npx @flow-engineer/flow status # what the folder has and lacks
|
|
43
43
|
```
|
package/coding-agents/skill.md
CHANGED
|
@@ -20,7 +20,7 @@ and `flow-framework-auth` (`auth-skill.md`) when tools need to know who the pers
|
|
|
20
20
|
`npx @flow-engineer/flow new <name>` (lowercase letters, digits and dashes) makes `./<name>/` from the café
|
|
21
21
|
example: a complete agent with tools and tests that depends on the `@flow-engineer/flow` package and imports
|
|
22
22
|
`@flow-engineer/flow` and `@flow-engineer/flow/simulators`, with these skills in `.claude/skills/`. Run `npm install` there.
|
|
23
|
-
Build in it, changing
|
|
23
|
+
Build in it, changing the café agent's prompt, skills, tools and tests into yours. A provider key is optional:
|
|
24
24
|
`npx @flow-engineer/flow link` (or `setup`) writes `FLOW_KEY`, the agent's one Flow key, into `.env` (never
|
|
25
25
|
read or print it), and the agent calls models through Flow with it on $2 of Flow trial credit. A key of the owner's own wins over it, made
|
|
26
26
|
at the provider: `OPENROUTER_API_KEY` at https://openrouter.ai/keys,
|
|
@@ -63,21 +63,25 @@ skill, not in AGENTS.md.
|
|
|
63
63
|
## buildAgent
|
|
64
64
|
|
|
65
65
|
Build the agent in one function that the playground and the tests both call, so they never
|
|
66
|
-
drift apart. Tests pass their own model, events and simulated tools
|
|
66
|
+
drift apart. Tests pass their own model, events and simulated tools; a test the business starts
|
|
67
|
+
also passes `channels` and `testing` (the run's clock), which go on to `createAgent` as they are
|
|
68
|
+
(the `flow-framework-tests` skill):
|
|
67
69
|
|
|
68
70
|
```ts
|
|
69
71
|
import { fileURLToPath } from "node:url";
|
|
70
|
-
import { createAgent, openAICompatible, type EventSink, type Model, type Tool } from "@flow-engineer/flow";
|
|
72
|
+
import { createAgent, openAICompatible, type AgentOptions, type Channel, type EventSink, type Model, type Tool } from "@flow-engineer/flow";
|
|
71
73
|
import { tools as myTools } from "./tools.ts";
|
|
72
74
|
|
|
73
75
|
export const tools = myTools;
|
|
74
76
|
|
|
75
|
-
export function buildAgent(options: { model?: Model; events?: EventSink[]; tools?: Tool[] } = {}) {
|
|
77
|
+
export function buildAgent(options: { model?: Model; events?: EventSink[]; tools?: Tool[]; channels?: Channel[]; testing?: Partial<AgentOptions> } = {}) {
|
|
76
78
|
return createAgent({
|
|
77
79
|
dir: fileURLToPath(new URL("../agent", import.meta.url)),
|
|
78
80
|
model: options.model ?? openAICompatible({ model: "anthropic/claude-haiku-5.5" }),
|
|
79
81
|
tools: options.tools ?? myTools,
|
|
80
82
|
events: options.events ?? [],
|
|
83
|
+
...(options.channels ? { channels: options.channels } : {}),
|
|
84
|
+
...options.testing,
|
|
81
85
|
});
|
|
82
86
|
}
|
|
83
87
|
```
|
|
@@ -93,7 +97,12 @@ and are `message.in`/`message.out` events marked `answered: true`);
|
|
|
93
97
|
`deadlineMs` (default 60,000) is the longest a reply waits for a tool before it moves to the
|
|
94
98
|
background (set a tool's own `deadlineMs` for a known-slow one); `partialAfterMs` (default 5,000)
|
|
95
99
|
is how long a finished tool's result waits for the others of its response; `shareUpdates` turns live status lines on web and app UIs off, or replaces their
|
|
96
|
-
guidance with a string
|
|
100
|
+
guidance with a string; `routes` (`{ "/v1/events": (request) => Response }`) are HTTP routes the
|
|
101
|
+
agent serves beside its chat, such as a business's event push, answered by `agent.fetch(request)`
|
|
102
|
+
(and by the test cloud with a test's events); `onTick(now)` is work of your own on the agent's clock
|
|
103
|
+
(held sends, reminders timed from your data), run with each check for due scheduled messages.
|
|
104
|
+
`agent.reach({ channel, address })` makes the conversation of someone the business may message
|
|
105
|
+
before they have written; until they write, only a template reaches them on WhatsApp.
|
|
97
106
|
|
|
98
107
|
Models are reached through any OpenAI-compatible API. `openAICompatible()` with no key reads the
|
|
99
108
|
first of `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY` and
|
|
@@ -205,6 +214,9 @@ to send later. Prefer `prompt`; use `text` only when the wording must be exact.
|
|
|
205
214
|
see it.", no values) so a nudge is held until they reply instead of refused (`outside_window`); the
|
|
206
215
|
agent may send a fitting template itself (`send_template`), and `template.missing` events say which
|
|
207
216
|
messages want one. Held nudges (quiet hours, a closed window) settle as `held`, then `sent` or `expired`.
|
|
217
|
+
For a call to action use `link` (`text` with `link: { label, url }`, or the agent's send with
|
|
218
|
+
`link`), never a URL in the text: what the business starts goes with link previews off, since on
|
|
219
|
+
WhatsApp a preview card's title becomes the phone's notification instead of the message.
|
|
208
220
|
|
|
209
221
|
When the owner's backend is a separate service, mount `nudgeHandler(agent, { token })` (from
|
|
210
222
|
`@flow-engineer/flow`; a token of at least 32 characters, from a secret; `base` is `/nudges` by default) in the
|
|
@@ -313,12 +325,13 @@ and every simulated message is logged with the person, channel, time and address
|
|
|
313
325
|
To chat with the agent from the owner's phone, with no tunnel and no key made by hand, run these
|
|
314
326
|
from the agent's folder (`npx @flow-engineer/flow init --display-name "<name>"` makes a starter in
|
|
315
327
|
an empty one: first ask the owner what people should call their agent, such as "FlatX agent"; the
|
|
316
|
-
phone's first message is `Hi FlatX agent (ref <code>)`, and `package.json`'s `displayName` keeps it
|
|
328
|
+
phone's first message is `Hi FlatX agent (ref <code>)`, and `package.json`'s `displayName` keeps it;
|
|
329
|
+
`link --display-name "<name>"` renames it later, in `package.json` and `agent/AGENTS.md`).
|
|
317
330
|
Only `chat` reads stdin; `init` asks for the name only in a terminal, and nothing else asks for input.
|
|
318
331
|
|
|
319
332
|
```sh
|
|
320
|
-
npx @flow-engineer/flow link --json
|
|
321
|
-
npm start #
|
|
333
|
+
npx @flow-engineer/flow link --channel telegram --json # or whatsapp, imessage: signs in and runs setup when needed (Flow's trial model key too), then the channel's own link (t.me, wa.me, sms:); give its say_to_person to the owner, and run its next
|
|
334
|
+
npm start # after link: its playground link, and the owner texts it while it runs (PORT, else 8788 or the next free port)
|
|
322
335
|
npx @flow-engineer/flow status --json # what the folder has and lacks, at any time
|
|
323
336
|
printf 'hi\nA latte please\n' | npx @flow-engineer/flow chat # your own check: one message per line, replies printed
|
|
324
337
|
npx @flow-engineer/flow test --synthetic # your own check: the tests, on the key setup wrote
|
|
@@ -336,7 +349,9 @@ kept: give the owner the link and code, and run `link` again (it polls the same
|
|
|
336
349
|
`setup`, which writes the test cloud, traces and Flow Messaging keys into `.env`, prints names and
|
|
337
350
|
last 4 characters only, keeps values already there (`--replace` takes the new ones), and is safe to
|
|
338
351
|
run again. `--channel telegram|whatsapp|imessage` picks the channel; a channel flow.engineer cannot
|
|
339
|
-
open yet exits 3 with its `next`.
|
|
352
|
+
open yet exits 3 with its `next`. When only observe's part of setup fails (traces and the test cloud),
|
|
353
|
+
`link` still prints the link and exits 0, with one warning saying what is missing and the server's
|
|
354
|
+
fix (`warnings` in `--json`): pass it on to the owner. `login`, `setup` and `link` also run on their own.
|
|
340
355
|
|
|
341
356
|
In `src/main.ts`, put `fm.telegram()` from `@flow-engineer/flow/flow-messaging` in the agent's
|
|
342
357
|
channels and call `fm.start(agent, { cursorFile: ".flow/flow-messaging-cursor" })`: with no webhook
|
|
@@ -74,7 +74,7 @@ expect:
|
|
|
74
74
|
- `timezone` (default UTC) and `now`: the test's clock. `now` is when each run starts,
|
|
75
75
|
`YYYY-MM-DDTHH:MM[:SS]`, local to `timezone` unless it ends in `Z` or an offset (`-04:00`); left
|
|
76
76
|
out, it is the real time. Placeholders are filled from it, and the simulated tools and the judge
|
|
77
|
-
are told it; your agent's clock is not changed: the agent still reads the real time. So set `now` only when the agent takes
|
|
77
|
+
are told it; outside a business run (below) your agent's clock is not changed: the agent still reads the real time. So set `now` only when the agent takes
|
|
78
78
|
its dates from its tools or its context; otherwise leave it out and placeholders follow the real
|
|
79
79
|
date.
|
|
80
80
|
|
|
@@ -98,6 +98,55 @@ time of day, then `| iso` (the default, with its offset), `| date` (2026-10-16),
|
|
|
98
98
|
`person`, `opening`, `expect` and the tool files' `responses`; the cloud fills them on each run. Any other
|
|
99
99
|
`{{...}}` is plain text.
|
|
100
100
|
|
|
101
|
+
### Scenarios the business starts
|
|
102
|
+
|
|
103
|
+
A scenario can start with the business instead of the person: an event its backend sends, a nudge,
|
|
104
|
+
or time passing. Such a run (a business run) gets an agent of its own, built by `buildAgent` with
|
|
105
|
+
the run's clock and the agent's own channels (pass `options.channels` and `options.testing` on to
|
|
106
|
+
`createAgent`, `testing` spread as it is: `now` and `scheduleCheckMs: 0`), so the agent runs on
|
|
107
|
+
the test's clock: quiet hours, held sends, `expiresAt` and caps over days follow it.
|
|
108
|
+
|
|
109
|
+
```yaml
|
|
110
|
+
timezone: Asia/Kolkata
|
|
111
|
+
now: 2026-10-20T23:30
|
|
112
|
+
person:
|
|
113
|
+
address: "+15550100001" # who the events and nudges are for (a made-up number)
|
|
114
|
+
persona: ...
|
|
115
|
+
goal: ...
|
|
116
|
+
opening:
|
|
117
|
+
- event: { type: buying.bid.outbid, delivery: critical, subject: "user:u1", data: { user: { phone: "+15550100001" } } }
|
|
118
|
+
- wait: 8h30m # time passes; what came due runs, a minute at a time
|
|
119
|
+
- say: "ok thanks" # the person can still write
|
|
120
|
+
- event: { id: evt_dup, type: ..., subject: ..., data: {...} }
|
|
121
|
+
signature: bad # or missing; answer: 401
|
|
122
|
+
answer: 401 # what the route must answer (default: any 2xx); { status: 400, field: data.user }
|
|
123
|
+
- nudge: { prompt: "Tell them the listing ends soon." } # or text, or template: { name, body: [...] }
|
|
124
|
+
at: 2026-10-21T15:00 # moves the clock first: a date and time, HH:MM (the next one), or {{now ...}}
|
|
125
|
+
max_turns: 1 # 1: no turns played after the opening
|
|
126
|
+
tools: { real: [keep_me_posted] } # their own code runs, for state the agent keeps itself
|
|
127
|
+
expect:
|
|
128
|
+
sent: # what went out, in order (other sends may come between)
|
|
129
|
+
- { at: "23:30", template: outbid }
|
|
130
|
+
- { at: "08:00", phrased: true, text: "₹53,000" }
|
|
131
|
+
not_sent:
|
|
132
|
+
- { template: outbid_reminder } # {} is anything at all
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
- An `event` is sent exactly as a business's backend sends a Flow event: one envelope
|
|
136
|
+
`{ id, type, occurredAt, subject, delivery?, data }` (`id` and `occurredAt` default to one per
|
|
137
|
+
step and the clock), as `POST /v1/events` (or the step's `path`) to the agent's `routes`, with
|
|
138
|
+
`Content-Type: application/json`, `Idempotency-Key: <id>` and
|
|
139
|
+
`Flow-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.{t}.{body}">`, signed with the
|
|
140
|
+
run's own test secret (`options.testing.eventsSecret`: check signatures with it in a business run).
|
|
141
|
+
The same `id` twice is a duplicate, as it would be live.
|
|
142
|
+
- A `nudge` is `agent.nudge` to the run's person; `wait` and `at` move the clock (never back).
|
|
143
|
+
- `expect.sent` items: `at` (HH:MM on any day, or a date and time; to the minute), `template`
|
|
144
|
+
(its name), `phrased: true` (the agent's own words, not a template), `text` (in the message, or in
|
|
145
|
+
the template's values). Checked by code, like `calls`.
|
|
146
|
+
- A business run where the agent sent nothing is not a failure by itself: `expect` decides.
|
|
147
|
+
- Timers of your own (held sends, reminders timed from your data) go in `createAgent`'s `onTick`,
|
|
148
|
+
which runs on the agent's clock, never in a `setInterval` of your own.
|
|
149
|
+
|
|
101
150
|
How fast the agent first replies to each message is measured on every run from timestamps, not
|
|
102
151
|
written in scenarios: the dashboard shows it for the whole run (average, p95, min and max).
|
|
103
152
|
|
|
@@ -97,16 +97,17 @@ export function flowMessaging(options = {}) {
|
|
|
97
97
|
return { phone: to.startsWith("+") ? to : `+${to}` };
|
|
98
98
|
}
|
|
99
99
|
/** One message: into the person's conversation, or a new one from the channel's sender. */
|
|
100
|
-
async function send(route, to, content, replyTo, about = replyTo, idempotencyKey) {
|
|
100
|
+
async function send(route, to, content, replyTo, about = replyTo, idempotencyKey, channelOptions) {
|
|
101
101
|
try {
|
|
102
102
|
const conversation = await conversationFor(route.name, to, about);
|
|
103
|
+
const options = channelOptions ? { channel_options: channelOptions } : {};
|
|
103
104
|
let sent;
|
|
104
105
|
if (conversation)
|
|
105
|
-
sent = await client.post(`/v1/conversations/${idSegment("conv", conversation)}/messages`, { content, ...(replyTo ? { reply_to: replyTo } : {}) }, undefined, idempotencyKey);
|
|
106
|
+
sent = await client.post(`/v1/conversations/${idSegment("conv", conversation)}/messages`, { content, ...(replyTo ? { reply_to: replyTo } : {}), ...options }, undefined, idempotencyKey);
|
|
106
107
|
else {
|
|
107
108
|
if (!route.sender)
|
|
108
109
|
throw new Error(`no conversation with them on ${route.name} yet, and no sender to start one (set FLOW_MESSAGING_${route.kind.toUpperCase()}_SENDER)`);
|
|
109
|
-
sent = await client.post("/v1/messages", { sender: route.sender, to: recipient(route.kind, to), content }, undefined, idempotencyKey);
|
|
110
|
+
sent = await client.post("/v1/messages", { sender: route.sender, to: recipient(route.kind, to), content, ...options }, undefined, idempotencyKey);
|
|
110
111
|
}
|
|
111
112
|
flowConversation.set(key(route.name, to), sent.conversation);
|
|
112
113
|
}
|
|
@@ -175,11 +176,13 @@ export function flowMessaging(options = {}) {
|
|
|
175
176
|
function provider(route) {
|
|
176
177
|
return {
|
|
177
178
|
sendMessage: async (to, message) => {
|
|
179
|
+
// A message the business started: on WhatsApp, no link preview (its card's title would be the phone's notification).
|
|
180
|
+
const options = (content) => (message.preview === false && route.kind === "whatsapp" && content.type === "text" ? { preview_url: false } : undefined);
|
|
178
181
|
const [first, ...rest] = contents(route.kind, message);
|
|
179
182
|
const key = message.idempotencyKey;
|
|
180
|
-
await send(route, to, first, message.replyTo, message.replyTo, key);
|
|
183
|
+
await send(route, to, first, message.replyTo, message.replyTo, key, options(first));
|
|
181
184
|
for (const [i, next] of rest.entries())
|
|
182
|
-
await send(route, to, next, undefined, undefined, key && `${key}:${i + 1}
|
|
185
|
+
await send(route, to, next, undefined, undefined, key && `${key}:${i + 1}`, options(next));
|
|
183
186
|
},
|
|
184
187
|
react: (to, messageId, emoji) => send(route, to, { type: "reaction", message_id: messageId, emoji }, undefined, messageId),
|
|
185
188
|
typing: (to, on) => courtesy(route, to, "typing", { state: on ? "on" : "off" }),
|
|
@@ -136,6 +136,8 @@ export interface Playground {
|
|
|
136
136
|
}
|
|
137
137
|
export interface TestCloudSimulators {
|
|
138
138
|
events: EventSink;
|
|
139
|
+
/** Where the agent's sends to one person's thread go, before they have written (a run the business starts). */
|
|
140
|
+
deliverer(platform: string, person: string): Deliver;
|
|
139
141
|
listen(agent: AgentConnection, port: number): Promise<Listening>;
|
|
140
142
|
handler(agent: AgentConnection, base?: string): Handler;
|
|
141
143
|
}
|
|
@@ -393,7 +393,9 @@ export function simulators(options = {}) {
|
|
|
393
393
|
}
|
|
394
394
|
function serve(handle, port) {
|
|
395
395
|
const server = createServer(handle);
|
|
396
|
-
return new Promise((resolve) => {
|
|
396
|
+
return new Promise((resolve, reject) => {
|
|
397
|
+
// A port taken since it was chosen rejects, as an error the caller can print, never an unhandled 'error' event.
|
|
398
|
+
server.once("error", reject);
|
|
397
399
|
server.listen(port, "127.0.0.1", () => resolve({
|
|
398
400
|
// The bound port, so port 0 (any free port) works too.
|
|
399
401
|
url: `http://127.0.0.1:${server.address().port}`,
|
|
@@ -537,6 +539,10 @@ export function simulators(options = {}) {
|
|
|
537
539
|
}
|
|
538
540
|
const testCloud = {
|
|
539
541
|
events: cloudEvents,
|
|
542
|
+
deliverer: (platform, person) => {
|
|
543
|
+
const p = platformOf(platform);
|
|
544
|
+
return deliverer(p, key(p.id, person));
|
|
545
|
+
},
|
|
540
546
|
listen: (agent, port) => serve(guarded((req, res) => handle(agent, req, res, "")), port),
|
|
541
547
|
handler: (agent, base = "") => guarded((req, res) => handle(agent, req, res, base)),
|
|
542
548
|
};
|
package/dist/src/agent.d.ts
CHANGED
|
@@ -140,6 +140,18 @@ export interface AgentOptions {
|
|
|
140
140
|
keepMessageIdsDays?: number;
|
|
141
141
|
/** The clock (tests move it). */
|
|
142
142
|
now?: () => number;
|
|
143
|
+
/**
|
|
144
|
+
* Work of your own that runs on the agent's clock: called (and awaited) each time due scheduled
|
|
145
|
+
* messages are looked for (every `scheduleCheckMs`, and each `runDue()`), with the time now. A
|
|
146
|
+
* test that moves the clock runs it at each step, so your own timers (held sends, reminders
|
|
147
|
+
* timed from your data) follow the test's clock. An error is logged, never thrown.
|
|
148
|
+
*/
|
|
149
|
+
onTick?: (now: number) => Promise<void> | void;
|
|
150
|
+
/**
|
|
151
|
+
* HTTP routes the agent serves beside its chat, by path ("/v1/events": a business's event push),
|
|
152
|
+
* answered by `agent.fetch(request)`. The test cloud calls them in-process with a test's events.
|
|
153
|
+
*/
|
|
154
|
+
routes?: Record<string, (request: Request) => Response | Promise<Response>>;
|
|
143
155
|
/**
|
|
144
156
|
* The people who may write from the test playground (`simulate`), and no one else. Each is a
|
|
145
157
|
* real person to the agent, by their phone number: the same conversation whether they write from
|
|
@@ -197,6 +209,11 @@ export interface NudgeInput {
|
|
|
197
209
|
};
|
|
198
210
|
/** Sent as written, with no model call. */
|
|
199
211
|
text?: string;
|
|
212
|
+
/** With `text`: its call to action, sent as a link button (on channels without one, the URL after the text). Never a URL in the text. */
|
|
213
|
+
link?: {
|
|
214
|
+
label: string;
|
|
215
|
+
url: string;
|
|
216
|
+
};
|
|
200
217
|
/** The agent gets a turn with this note and writes the message itself, or sends nothing. */
|
|
201
218
|
prompt?: string;
|
|
202
219
|
/** A template the owner already set up with the channel, sent as is (also outside WhatsApp's window). */
|
|
@@ -515,6 +532,21 @@ export declare class Agent {
|
|
|
515
532
|
}>;
|
|
516
533
|
/** Whether the agent has a profile (get_user_profile), so the test cloud knows to play it. */
|
|
517
534
|
hasProfile(): boolean;
|
|
535
|
+
/**
|
|
536
|
+
* A person the business may message before they have written (they gave it their number): their
|
|
537
|
+
* conversation, made the first time, with no message in it. Until they write, their WhatsApp
|
|
538
|
+
* window is closed, so only a template reaches them there.
|
|
539
|
+
*/
|
|
540
|
+
reach(to: {
|
|
541
|
+
channel: string;
|
|
542
|
+
address: string;
|
|
543
|
+
}): Promise<{
|
|
544
|
+
conversationId: string;
|
|
545
|
+
}>;
|
|
546
|
+
/** A channel the agent was given, by name, as it was given (the test cloud plays a copy of it). */
|
|
547
|
+
channel(name: string): Channel | undefined;
|
|
548
|
+
/** Answers a request to one of `routes` by its path; 404 for any other. */
|
|
549
|
+
fetch(request: Request): Promise<Response>;
|
|
518
550
|
nudge(input: NudgeInput): Promise<NudgeResult>;
|
|
519
551
|
/**
|
|
520
552
|
* Records a nudge and resolves at once, without waiting for its send or turn: `queued`, or what
|
package/dist/src/agent.js
CHANGED
|
@@ -63,6 +63,9 @@ export class Agent {
|
|
|
63
63
|
if (this.channelsByName.has(name))
|
|
64
64
|
throw new Error(`flow: "${name}" is reserved; give the channel another name`);
|
|
65
65
|
this.now = options.now ?? Date.now;
|
|
66
|
+
for (const path of Object.keys(options.routes ?? {}))
|
|
67
|
+
if (!path.startsWith("/"))
|
|
68
|
+
throw new Error(`flow: a route is a path starting with /, not "${path}"`);
|
|
66
69
|
if (options.fallbackTemplate) {
|
|
67
70
|
const declared = declaredTemplate(options.templates, options.fallbackTemplate);
|
|
68
71
|
if (!declared)
|
|
@@ -313,7 +316,7 @@ export class Agent {
|
|
|
313
316
|
// Released because they just wrote: their window is open, though their message may not have landed yet.
|
|
314
317
|
const inbox = await this.store.releaseNudge(writer ? { ...n, body: { ...n.body, theyWrote: true } } : n);
|
|
315
318
|
if (inbox !== null)
|
|
316
|
-
this.wake(n.conversationId, inbox);
|
|
319
|
+
await this.wake(n.conversationId, inbox);
|
|
317
320
|
}
|
|
318
321
|
}
|
|
319
322
|
/** Sends the traces of finished turns still waiting for the traces endpoint; call it before the process exits. */
|
|
@@ -701,6 +704,30 @@ export class Agent {
|
|
|
701
704
|
hasProfile() {
|
|
702
705
|
return this.options.profile !== undefined;
|
|
703
706
|
}
|
|
707
|
+
/**
|
|
708
|
+
* A person the business may message before they have written (they gave it their number): their
|
|
709
|
+
* conversation, made the first time, with no message in it. Until they write, their WhatsApp
|
|
710
|
+
* window is closed, so only a template reaches them there.
|
|
711
|
+
*/
|
|
712
|
+
async reach(to) {
|
|
713
|
+
await this.settings.ready;
|
|
714
|
+
const adapter = this.channelOf(to.channel);
|
|
715
|
+
const identity = await this.conversationFor(adapter, to.channel, to.address);
|
|
716
|
+
const conversation = this.open(identity.conversationId);
|
|
717
|
+
conversation.link({ name: to.channel, info: adapter.info, params: adapter.params, instructions: adapter.instructions, address: to.address, deliver: this.deliverer(adapter, to.address, identity.conversationId), lastMessageAt: identity.lastMessageAt });
|
|
718
|
+
return { conversationId: identity.conversationId };
|
|
719
|
+
}
|
|
720
|
+
/** A channel the agent was given, by name, as it was given (the test cloud plays a copy of it). */
|
|
721
|
+
channel(name) {
|
|
722
|
+
return this.channelsByName.get(name);
|
|
723
|
+
}
|
|
724
|
+
/** Answers a request to one of `routes` by its path; 404 for any other. */
|
|
725
|
+
async fetch(request) {
|
|
726
|
+
const route = this.options.routes?.[new URL(request.url).pathname];
|
|
727
|
+
if (!route)
|
|
728
|
+
return Response.json({ error: "no such route" }, { status: 404 });
|
|
729
|
+
return route(request);
|
|
730
|
+
}
|
|
704
731
|
async nudge(input) {
|
|
705
732
|
// outcome() waits while it is queued, so what it resolves is settled.
|
|
706
733
|
return (await this.outcome(await this.recordNudge(input)));
|
|
@@ -715,6 +742,8 @@ export class Agent {
|
|
|
715
742
|
const kinds = [input.text, input.prompt, input.template].filter((v) => v !== undefined).length;
|
|
716
743
|
if (kinds !== 1)
|
|
717
744
|
throw new Error("flow: a nudge has exactly one of text, prompt or template");
|
|
745
|
+
if (input.link && (input.text === undefined || input.at !== undefined))
|
|
746
|
+
throw new Error("flow: a nudge's link goes with text, sent now (not with `at`)");
|
|
718
747
|
// A declared template whose values don't fit it never goes: Meta would refuse it, the playground would show nonsense.
|
|
719
748
|
const declared = input.template && declaredTemplate(this.options.templates, input.template);
|
|
720
749
|
if (declared)
|
|
@@ -755,7 +784,7 @@ export class Agent {
|
|
|
755
784
|
const added = await add({ ...record, body: { text: input.text, prompt: input.prompt, at: input.at }, status: "scheduled" }, schedule);
|
|
756
785
|
return typeof added === "number" ? { id, status: "scheduled" } : added;
|
|
757
786
|
}
|
|
758
|
-
const body = { address: input.to.address, ...(input.template ? { template: input.template } : input.text !== undefined ? { text: input.text } : { prompt: input.prompt }) };
|
|
787
|
+
const body = { address: input.to.address, ...(input.template ? { template: input.template } : input.text !== undefined ? { text: input.text, ...(input.link ? { link: input.link } : {}) } : { prompt: input.prompt }) };
|
|
759
788
|
const now = this.now();
|
|
760
789
|
const quietEnds = input.respectQuietHours === false ? null : await this.quietEnd(conversationId, now);
|
|
761
790
|
if (quietEnds !== null) {
|
|
@@ -780,7 +809,7 @@ export class Agent {
|
|
|
780
809
|
const added = await add({ ...record, body, status: "queued" });
|
|
781
810
|
if (typeof added !== "number")
|
|
782
811
|
return added;
|
|
783
|
-
this.wake(conversationId, added);
|
|
812
|
+
void this.wake(conversationId, added);
|
|
784
813
|
return { id, status: "queued" };
|
|
785
814
|
}
|
|
786
815
|
/** A nudge's state now, or null for an id the store does not hold. */
|
|
@@ -826,9 +855,11 @@ export class Agent {
|
|
|
826
855
|
return `no way to reach them on ${s.channel}`;
|
|
827
856
|
return windowClosed({ info: this.infoOf(s.channel, s.conversationId), lastMessageAt: identity.lastMessageAt }, now) ? "outside_window" : null;
|
|
828
857
|
});
|
|
829
|
-
for (
|
|
830
|
-
|
|
858
|
+
// Awaited, so a caller that then waits for the conversation (a test moving the clock) sees the turn.
|
|
859
|
+
await Promise.all(queued.map((q) => this.wake(q.conversationId, q.inbox)));
|
|
831
860
|
await this.releaseHeld(now);
|
|
861
|
+
if (this.options.onTick)
|
|
862
|
+
await Promise.resolve().then(() => this.options.onTick(now)).catch((error) => console.warn("flow: onTick:", error));
|
|
832
863
|
await this.pruneMessageIds(now).catch((error) => console.warn("flow: pruning message ids:", error));
|
|
833
864
|
return queued.length;
|
|
834
865
|
}
|
|
@@ -872,14 +903,13 @@ export class Agent {
|
|
|
872
903
|
wake(conversationId, inbox) {
|
|
873
904
|
const resident = this.conversations.get(conversationId);
|
|
874
905
|
if (!resident) {
|
|
875
|
-
|
|
906
|
+
return this.store.conversation(conversationId).then((row) => {
|
|
876
907
|
if (row)
|
|
877
908
|
this.rows.set(conversationId, row);
|
|
878
909
|
this.open(conversationId);
|
|
879
910
|
});
|
|
880
|
-
return;
|
|
881
911
|
}
|
|
882
|
-
|
|
912
|
+
return this.store.openInbox(conversationId).then((rows) => {
|
|
883
913
|
const row = rows.find((r) => r.id === inbox);
|
|
884
914
|
if (row)
|
|
885
915
|
resident.enqueue(row);
|
package/dist/src/channels.d.ts
CHANGED
|
@@ -76,6 +76,8 @@ export interface Message {
|
|
|
76
76
|
image?: {
|
|
77
77
|
url: string;
|
|
78
78
|
};
|
|
79
|
+
/** false: send it with link previews off (a message the business started). */
|
|
80
|
+
preview?: false;
|
|
79
81
|
/** Given for a send the business starts (a nudge, a scheduled text): the same in every process, so a provider that takes one sends it once. */
|
|
80
82
|
idempotencyKey?: string;
|
|
81
83
|
}
|
package/dist/src/channels.js
CHANGED
|
@@ -159,6 +159,7 @@ function message(outbound) {
|
|
|
159
159
|
...(outbound.list ? { list: outbound.list } : {}),
|
|
160
160
|
...(outbound.link ? { link: outbound.link } : {}),
|
|
161
161
|
...(outbound.image ? { image: outbound.image } : {}),
|
|
162
|
+
...(outbound.preview === false ? { preview: false } : {}),
|
|
162
163
|
...(outbound.idempotencyKey ? { idempotencyKey: outbound.idempotencyKey } : {}),
|
|
163
164
|
};
|
|
164
165
|
}
|
|
@@ -117,6 +117,8 @@ export declare class Conversation {
|
|
|
117
117
|
private readonly tasks;
|
|
118
118
|
private taskCount;
|
|
119
119
|
private idleSince;
|
|
120
|
+
/** The turn running now was not started by the person (a nudge, a scheduled message, a background result). */
|
|
121
|
+
private businessTurn;
|
|
120
122
|
private settled;
|
|
121
123
|
/** Channels where nothing has reached the person since their latest message there. */
|
|
122
124
|
private readonly unanswered;
|
package/dist/src/conversation.js
CHANGED
|
@@ -101,6 +101,8 @@ export class Conversation {
|
|
|
101
101
|
tasks = new Map();
|
|
102
102
|
taskCount = 0;
|
|
103
103
|
idleSince = Date.now();
|
|
104
|
+
/** The turn running now was not started by the person (a nudge, a scheduled message, a background result). */
|
|
105
|
+
businessTurn = false;
|
|
104
106
|
settled = [];
|
|
105
107
|
/** Channels where nothing has reached the person since their latest message there. */
|
|
106
108
|
unanswered = new Set();
|
|
@@ -463,7 +465,7 @@ export class Conversation {
|
|
|
463
465
|
this.notes.push({ text, inbox: row.id, owed: { nudge: p.nudge, mode: "prompt", channel: p.channel, address: p.address } });
|
|
464
466
|
}
|
|
465
467
|
else
|
|
466
|
-
this.direct.push({ inbox: row.id, channel: p.channel, address: p.address, text: p.text, template: p.template, nudge: p.nudge, ...(p.theyWrote ? { theyWrote: true } : {}) });
|
|
468
|
+
this.direct.push({ inbox: row.id, channel: p.channel, address: p.address, text: p.text, ...(p.link ? { link: p.link } : {}), template: p.template, nudge: p.nudge, ...(p.theyWrote ? { theyWrote: true } : {}) });
|
|
467
469
|
}
|
|
468
470
|
if (!this.running && this.hasWork())
|
|
469
471
|
this.schedule(0);
|
|
@@ -803,6 +805,7 @@ export class Conversation {
|
|
|
803
805
|
this.absorb();
|
|
804
806
|
// A turn the person started (as against a background result, a scheduled message or a nudge).
|
|
805
807
|
const theirs = this.unanswered.size > 0;
|
|
808
|
+
this.businessTurn = !theirs;
|
|
806
809
|
if (!this.channels.size) {
|
|
807
810
|
// No way to reach them right now (a channel with no adapter, after a restart).
|
|
808
811
|
for (const o of this.owed.splice(0))
|
|
@@ -1049,6 +1052,8 @@ export class Conversation {
|
|
|
1049
1052
|
...(input.link ? { link: input.link } : {}),
|
|
1050
1053
|
...(input.image ? { image: input.image } : {}),
|
|
1051
1054
|
...(hasFields ? { fields } : {}),
|
|
1055
|
+
// A turn the person did not start: no link preview leads the notification (types.ts, `preview`).
|
|
1056
|
+
...(this.businessTurn ? { preview: false } : {}),
|
|
1052
1057
|
});
|
|
1053
1058
|
const failure = await this.send(channel, outbound);
|
|
1054
1059
|
if (failure) {
|
|
@@ -1175,9 +1180,10 @@ export class Conversation {
|
|
|
1175
1180
|
}
|
|
1176
1181
|
if (covered)
|
|
1177
1182
|
return settle("dropped", `no-contact rule ${covered.id}: ${covered.rule}`);
|
|
1183
|
+
// The business started it: its link goes as a button, and no preview leads the notification.
|
|
1178
1184
|
// The same key in every process, so the channel's provider sends it once.
|
|
1179
1185
|
const idempotencyKey = d.nudge ? `nudge:${d.nudge}` : d.run ? `run:${d.run}` : `inbox:${this.id}:${d.inbox}`;
|
|
1180
|
-
const outbound = d.template ? { type: "template", ...d.template, idempotencyKey } : { type: "message", text: d.text ?? "", idempotencyKey };
|
|
1186
|
+
const outbound = d.template ? { type: "template", ...d.template, idempotencyKey } : fitMessage(link.info, { type: "message", text: d.text ?? "", ...(d.link ? { link: d.link } : {}), preview: false, idempotencyKey });
|
|
1181
1187
|
let failure = null;
|
|
1182
1188
|
if (this.closed)
|
|
1183
1189
|
return settle("failed", "this person was forgotten");
|
package/dist/src/http.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// bearer token each.
|
|
4
4
|
//
|
|
5
5
|
// nudgeHandler:
|
|
6
|
-
// POST {base} { to: { channel, address }, text | prompt | template, at?, idempotency_key? }
|
|
6
|
+
// POST {base} { to: { channel, address }, text (+ link?) | prompt | template, at?, idempotency_key? }
|
|
7
7
|
// → 202 { id, status, reason? } as soon as it is recorded, before any send or turn
|
|
8
8
|
// GET {base}/:id → 200 { id, status, reason? }, its state now; 404 for an id the agent never recorded
|
|
9
9
|
//
|
|
@@ -68,6 +68,8 @@ const Body = z
|
|
|
68
68
|
.object({
|
|
69
69
|
to: z.object({ channel: z.string().min(1), address: z.string().min(1) }).strict(),
|
|
70
70
|
text: z.string().min(1).optional(),
|
|
71
|
+
/** With text: its call to action, as a link button. */
|
|
72
|
+
link: z.object({ label: z.string().min(1), url: z.url({ protocol: /^https$/ }) }).strict().optional(),
|
|
71
73
|
prompt: z.string().min(1).optional(),
|
|
72
74
|
template: Template.optional(),
|
|
73
75
|
/** Milliseconds since the epoch, as `agent.nudge()` takes it. */
|
|
@@ -80,7 +82,8 @@ const Body = z
|
|
|
80
82
|
})
|
|
81
83
|
.strict()
|
|
82
84
|
.refine((b) => [b.text, b.prompt, b.template].filter((v) => v !== undefined).length === 1, { message: "give exactly one of text, prompt or template" })
|
|
83
|
-
.refine((b) => !(b.template && b.at !== undefined), { message: "a template nudge cannot be scheduled yet: leave out at" })
|
|
85
|
+
.refine((b) => !(b.template && b.at !== undefined), { message: "a template nudge cannot be scheduled yet: leave out at" })
|
|
86
|
+
.refine((b) => !(b.link && (b.text === undefined || b.at !== undefined)), { message: "link goes with text, sent now (no at)" });
|
|
84
87
|
/**
|
|
85
88
|
* Nudges over HTTP, as a request handler for your own server: hand it the requests under `base`.
|
|
86
89
|
* Requests outside `base` get 404 without a token check. A nudge refused or dropped as it is
|
package/dist/src/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export { nudgeHandler, peopleHandler, type NudgeHandlerOptions, type PeopleHandl
|
|
|
5
5
|
export { sqlite, type Database } from "./db.ts";
|
|
6
6
|
export { postgres, type PostgresPool } from "./postgres.ts";
|
|
7
7
|
export { jsonlSink, type EventSink, type FlowEvent, type ChinwagEvent } from "./events.ts";
|
|
8
|
+
export { choosePort, markRunning } from "./local.ts";
|
|
8
9
|
export { flowModel, flowModelURL, openAICompatible, type OpenAICompatibleOptions } from "./model.ts";
|
|
9
10
|
export { Store, type StoreApi } from "./store.ts";
|
|
10
11
|
export type { AgentFiles } from "./skills.ts";
|
package/dist/src/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export { nudgeHandler, peopleHandler } from "./http.js";
|
|
|
5
5
|
export { sqlite } from "./db.js";
|
|
6
6
|
export { postgres } from "./postgres.js";
|
|
7
7
|
export { jsonlSink } from "./events.js";
|
|
8
|
+
export { choosePort, markRunning } from "./local.js";
|
|
8
9
|
export { flowModel, flowModelURL, openAICompatible } from "./model.js";
|
|
9
10
|
export { Store } from "./store.js";
|
|
10
11
|
export { tool, toSpec } from "./tools.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** True when nothing listens on 127.0.0.1:`port` (the address the playground and the MCP server bind). */
|
|
2
|
+
export declare function portFree(port: number): Promise<boolean>;
|
|
3
|
+
/**
|
|
4
|
+
* The port to listen on. `env[name]` set: that port, or an error naming a free one when it is taken
|
|
5
|
+
* (the caller prints its message and exits, so no stack). Not set: `fallback`, else the next free
|
|
6
|
+
* port above it, with `note` saying so. `command` is how the agent is started, for the error's
|
|
7
|
+
* "Run: PORT=8798 npm start".
|
|
8
|
+
*/
|
|
9
|
+
export declare function choosePort(name: string, fallback: number, options: {
|
|
10
|
+
command: string;
|
|
11
|
+
env?: Record<string, string | undefined>;
|
|
12
|
+
}): Promise<{
|
|
13
|
+
port: number;
|
|
14
|
+
note?: string;
|
|
15
|
+
}>;
|
|
16
|
+
/** Writes .flow/running.json in `dir` (this process and its URL) and removes it when the process exits. */
|
|
17
|
+
export declare function markRunning(url: string, dir?: string): void;
|
|
18
|
+
/** The agent running from `dir` (its marker names a live process), or undefined. */
|
|
19
|
+
export declare function runningAgent(dir?: string): {
|
|
20
|
+
pid: number;
|
|
21
|
+
url: string;
|
|
22
|
+
} | undefined;
|