@flow-engineer/flow 0.2.0 → 0.2.2

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.
Files changed (82) hide show
  1. package/LICENSE +202 -21
  2. package/README.md +190 -17
  3. package/coding-agents/auth-skill.md +137 -0
  4. package/coding-agents/context-skill.md +94 -0
  5. package/coding-agents/skill.md +245 -11
  6. package/coding-agents/test-skill.md +13 -11
  7. package/coding-agents/tools-skill.md +78 -1
  8. package/dist/cli/src/auth.d.ts +11 -0
  9. package/dist/cli/src/auth.js +147 -0
  10. package/dist/cli/src/commands.d.ts +119 -0
  11. package/dist/cli/src/commands.js +212 -0
  12. package/dist/cli/src/index.d.ts +2 -0
  13. package/dist/cli/src/index.js +5 -0
  14. package/dist/cli/src/run.d.ts +28 -0
  15. package/dist/cli/src/run.js +523 -0
  16. package/dist/cli/src/terminal.d.ts +3 -0
  17. package/dist/cli/src/terminal.js +135 -0
  18. package/dist/flow-messaging/src/client.d.ts +7 -1
  19. package/dist/flow-messaging/src/client.js +13 -1
  20. package/dist/flow-messaging/src/index.d.ts +38 -3
  21. package/dist/flow-messaging/src/index.js +242 -40
  22. package/dist/mcp/src/client.d.ts +54 -0
  23. package/dist/mcp/src/client.js +175 -0
  24. package/dist/mcp/src/describe.d.ts +41 -0
  25. package/dist/mcp/src/describe.js +110 -0
  26. package/dist/mcp/src/index.d.ts +4 -0
  27. package/dist/mcp/src/index.js +7 -0
  28. package/dist/mcp/src/server.d.ts +70 -0
  29. package/dist/mcp/src/server.js +252 -0
  30. package/dist/mcp/src/terminal.d.ts +24 -0
  31. package/dist/mcp/src/terminal.js +54 -0
  32. package/dist/simulators/src/index.d.ts +1 -1
  33. package/dist/simulators/src/page.html +267 -49
  34. package/dist/simulators/src/server.d.ts +27 -0
  35. package/dist/simulators/src/server.js +264 -40
  36. package/dist/src/agent.d.ts +69 -3
  37. package/dist/src/agent.js +162 -8
  38. package/dist/src/channels.d.ts +2 -0
  39. package/dist/src/conversation.d.ts +16 -1
  40. package/dist/src/conversation.js +140 -12
  41. package/dist/src/events.d.ts +1 -1
  42. package/dist/src/index.d.ts +2 -2
  43. package/dist/src/index.js +1 -1
  44. package/dist/src/model.d.ts +27 -0
  45. package/dist/src/model.js +85 -13
  46. package/dist/src/names.d.ts +5 -0
  47. package/dist/src/names.js +5 -0
  48. package/dist/src/skills.d.ts +4 -0
  49. package/dist/src/skills.js +23 -0
  50. package/dist/src/store.d.ts +2 -0
  51. package/dist/src/store.js +18 -0
  52. package/dist/src/tools.d.ts +69 -0
  53. package/dist/src/tools.js +17 -1
  54. package/dist/src/traces.js +32 -4
  55. package/dist/src/types.d.ts +2 -0
  56. package/dist/testing/src/account.d.ts +4 -0
  57. package/dist/testing/src/account.js +891 -0
  58. package/dist/testing/src/chat.d.ts +3 -0
  59. package/dist/testing/src/chat.js +125 -0
  60. package/dist/testing/src/check.d.ts +4 -0
  61. package/dist/testing/src/check.js +90 -0
  62. package/dist/testing/src/chinwag.js +1 -1
  63. package/dist/testing/src/cli.js +105 -18
  64. package/dist/testing/src/client.d.ts +2 -0
  65. package/dist/testing/src/client.js +4 -3
  66. package/dist/testing/src/flow-login.d.ts +176 -0
  67. package/dist/testing/src/flow-login.js +274 -0
  68. package/dist/testing/src/new.d.ts +15 -2
  69. package/dist/testing/src/new.js +80 -21
  70. package/dist/testing/src/plugin.d.ts +4 -0
  71. package/dist/testing/src/plugin.js +116 -0
  72. package/docs/agents.md +160 -0
  73. package/examples/cafe/src/agent.ts +6 -2
  74. package/examples/cafe/src/channels.ts +20 -0
  75. package/examples/cafe/src/cli.ts +24 -0
  76. package/examples/cafe/src/main.ts +17 -3
  77. package/examples/cafe/src/mcp.ts +22 -0
  78. package/examples/cafe/src/tools.ts +13 -0
  79. package/flow-messaging/README.md +66 -27
  80. package/package.json +30 -2
  81. package/spec/agent-endpoint.openapi.yaml +2 -2
  82. package/spec/nudges.openapi.yaml +2 -2
@@ -0,0 +1,116 @@
1
+ // Plugin packages generated from an agent's skills. The two native formats deliver skills once:
2
+ // both point at the tools-only MCP endpoint. Teams can edit manifests and add files alongside them.
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { parseArgs } from "node:util";
7
+ import { CHAT_ONLY } from "../../mcp/src/describe.js";
8
+ import { loadSkills, readAgentFolder } from "../../src/skills.js";
9
+ export const PLUGIN_LINE = " flow plugin --server <url> --out <dir> Write Claude Code and Agent Plugins packages";
10
+ const STAMP = ".chinwag-skills.json";
11
+ function skillFiles(dir) {
12
+ const files = {};
13
+ const root = join(dir, "agent");
14
+ function visit(path) {
15
+ for (const entry of readdirSync(join(root, path), { withFileTypes: true })) {
16
+ const child = `${path}/${entry.name}`;
17
+ if (entry.isSymbolicLink())
18
+ throw new Error(`Plugin skills cannot copy a symlink: ${child}`);
19
+ if (entry.isDirectory())
20
+ visit(child);
21
+ else if (entry.isFile())
22
+ files[child] = readFileSync(join(root, child));
23
+ }
24
+ }
25
+ if (existsSync(join(root, "skills")))
26
+ visit("skills");
27
+ return files;
28
+ }
29
+ /** Hashes source bytes, rather than mtimes: a checkout cannot make an old plugin look current. */
30
+ function hashes(files) {
31
+ return Object.fromEntries(Object.entries(files).sort(([a], [b]) => a.localeCompare(b)).map(([p, body]) => [p, createHash("sha256").update(body).digest("hex")]));
32
+ }
33
+ export function pluginProblems(dir, out) {
34
+ const expected = hashes(skillFiles(dir));
35
+ try {
36
+ const recorded = JSON.parse(readFileSync(join(out, STAMP), "utf8"));
37
+ if (JSON.stringify(recorded) !== JSON.stringify(expected))
38
+ return ["plugin skills are stale; run flow plugin again"];
39
+ for (const format of ["claude", "agent-plugins"]) {
40
+ if (JSON.stringify(hashes(Object.fromEntries(Object.keys(expected).map((p) => [p, readFileSync(join(out, format, p))])))) !== JSON.stringify(expected)) {
41
+ return [`${format} plugin skills differ from agent/skills; run flow plugin again`];
42
+ }
43
+ }
44
+ return [];
45
+ }
46
+ catch {
47
+ return ["plugin skills are missing or unreadable; run flow plugin again"];
48
+ }
49
+ }
50
+ export function writePlugins(dir, server, out, name) {
51
+ const pkg = existsSync(join(dir, "package.json")) ? JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) : {};
52
+ const pluginName = name ?? String(pkg.name ?? "agent").replace(/^@[^/]+\//, "");
53
+ if (!/^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(pluginName) || pluginName.length > 64)
54
+ throw new Error("Give --name a plugin name: lowercase letters, numbers, dots and hyphens, up to 64 characters.");
55
+ const url = new URL(server);
56
+ if (url.username || url.password || url.search || url.hash)
57
+ throw new Error("The server URL must not contain credentials, a query or a fragment.");
58
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))
59
+ throw new Error("Use https for remote servers, or http on localhost.");
60
+ url.pathname = `${url.pathname.replace(/\/+$/, "")}${url.pathname.replace(/\/+$/, "").endsWith("/plugin") ? "" : "/plugin"}`;
61
+ const files = skillFiles(dir);
62
+ for (const skill of loadSkills(readAgentFolder(join(dir, "agent")))) {
63
+ const named = CHAT_ONLY.exec(skill.body);
64
+ if (named)
65
+ throw new Error(`skill ${skill.name}: names ${named[1]}, which the plugin's MCP server does not have`);
66
+ }
67
+ const manifest = { name: pluginName, ...(pkg.version ? { version: pkg.version } : {}), ...(pkg.description ? { description: pkg.description } : {}) };
68
+ const json = (path, value) => write(path, `${JSON.stringify(value, null, 2)}\n`);
69
+ function write(path, body) {
70
+ mkdirSync(dirname(path), { recursive: true });
71
+ writeFileSync(path, body);
72
+ }
73
+ // Remove only files this generator previously owned, so deleted source skills do not linger.
74
+ let previous = {};
75
+ if (existsSync(join(out, STAMP)))
76
+ previous = JSON.parse(readFileSync(join(out, STAMP), "utf8"));
77
+ for (const path of Object.keys(previous)) {
78
+ if (!path.startsWith("skills/") || path.split("/").includes(".."))
79
+ throw new Error("Invalid plugin skills stamp");
80
+ if (!(path in files))
81
+ for (const format of ["claude", "agent-plugins"])
82
+ rmSync(join(out, format, path), { force: true });
83
+ }
84
+ for (const format of ["claude", "agent-plugins"]) {
85
+ const root = join(out, format);
86
+ for (const [path, body] of Object.entries(files))
87
+ write(join(root, path), body);
88
+ if (format === "claude") {
89
+ json(join(root, ".claude-plugin/plugin.json"), manifest);
90
+ json(join(root, ".mcp.json"), { mcpServers: { [pluginName]: { type: "http", url: url.href } } });
91
+ }
92
+ else {
93
+ json(join(root, "plugin.json"), { $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", ...manifest });
94
+ json(join(root, "mcp.json"), { $schema: "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", mcpServers: { [pluginName]: { type: "streamable-http", url: url.href } } });
95
+ }
96
+ }
97
+ json(join(out, STAMP), hashes(files));
98
+ }
99
+ export function pluginCli(argv, dir = process.cwd()) {
100
+ try {
101
+ const { values } = parseArgs({ args: argv, options: { server: { type: "string" }, out: { type: "string" }, name: { type: "string" }, help: { type: "boolean", short: "h" } } });
102
+ if (values.help) {
103
+ console.log(PLUGIN_LINE.trim());
104
+ return 0;
105
+ }
106
+ if (!values.server || !values.out)
107
+ throw new Error(PLUGIN_LINE.trim());
108
+ writePlugins(dir, values.server, resolve(dir, values.out), values.name);
109
+ console.log(`Wrote ${values.out}/claude and ${values.out}/agent-plugins. Check: flow check --plugin ${values.out}`);
110
+ return 0;
111
+ }
112
+ catch (error) {
113
+ console.error(error.message);
114
+ return 1;
115
+ }
116
+ }
package/docs/agents.md ADDED
@@ -0,0 +1,160 @@
1
+ # Flow Framework for coding agents
2
+
3
+ This guide is at https://flow.engineer/flow-framework/agents.md: that is the link to give.
4
+
5
+ You are building or changing a chat agent for a person (the owner) so they can text it from their
6
+ phone while it runs on their laptop. No tunnel, no public URL, and no key the owner copies by hand.
7
+ Every command here runs without a terminal and never reads stdin, except `chat`, which reads the
8
+ messages you pipe to it.
9
+
10
+ Always name the package: `npx @flow-engineer/flow ...`. `npx flow` on its own fetches another npm
11
+ package.
12
+
13
+ ## The flow
14
+
15
+ ```sh
16
+ npx @flow-engineer/flow init --display-name "FlatX agent" # the starter in this (empty) folder; ask the owner what to call it first
17
+ npm install
18
+ npx @flow-engineer/flow link --json # signs in and sets up if needed (the keys, Flow's trial model key among them), then prints the phone link
19
+ npm start # keep it running: the playground link it prints, and the phone link reaches it
20
+ npx @flow-engineer/flow status --json # any time: what the folder has and lacks
21
+ printf 'hi\nA latte please\n' | npx @flow-engineer/flow chat # your own check: talk to it locally, one message per line
22
+ ```
23
+
24
+ Before `init` (or `flow new <folder>`), ask the owner what people should call their agent, such as
25
+ "FlatX agent", and pass it as `--display-name`. It is kept as `displayName` in `package.json`, and
26
+ the message their phone sends to reach the agent is `Hi FlatX agent (ref <code>)`; change it there
27
+ any time. Without the flag, `init` asks in a terminal and otherwise uses the folder's name in words.
28
+
29
+ ## How the owner tests it
30
+
31
+ When the owner asks how to test the agent, recommend these, in this order:
32
+
33
+ 1. **The playground in the browser**: `npm start`, then open the link it prints. The test people
34
+ (`testPeople`; Maria, Sam and Priya in the starter) and the channels are side by side, and
35
+ **Debug** shows each tool call and how fast each message was answered.
36
+ 2. **Their phone**: the link from `link` (`say_to_person` in `link --json`), while `npm start` runs.
37
+ It is the channel's own link (`url`): `https://t.me/<bot>?start=<code>` (press Start),
38
+ `https://wa.me/<number>?text=...` or, for iMessage, `sms:<number>?&body=...` (an iPhone or a Mac),
39
+ each with `Hi <displayName> (ref <code>)` filled in (`join_message`). `page` is the same on
40
+ flow.engineer, for a computer without that app.
41
+
42
+ `flow chat` and `npm test -- --synthetic` (the test cloud) are for you, to check your own work
43
+ before you hand it back; run them yourself, and do not offer them to the owner as ways to test.
44
+
45
+ - The model key: a provider key is optional. `setup` (which `link` runs; alone it needs `login`
46
+ first) writes `FLOW_KEY`, the agent's one Flow key, into `.env` (printed only as its prefix and
47
+ last 4, `FLOW_KEY=flow_test_…abcd added`; never read or print its value): with no provider key
48
+ the agent calls models through Flow with it, on the account's $2 of Flow trial credit (printed as
49
+ `Model: Flow trial credit ($1.87 left)`). The owner may add their own: one of `OPENROUTER_API_KEY`,
50
+ `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` or `GEMINI_API_KEY` in `.env` wins over `FLOW_KEY` for the model
51
+ (to switch back, empty that line), and a key they set is never written over. When the credit is
52
+ used up, a model call fails with code `trial_credit_used` (`trial_credit_unavailable` when the account gets none) and the next step: add your own key. With no key, `chat` and `npm start` stop with one sentence and
53
+ `Next: run npx @flow-engineer/flow setup to get Flow's free trial credit, or add your own key.`
54
+ (`chat --json`: `{"error":{"code":"no_model_key","message":...,"next":"npx @flow-engineer/flow setup"}}`),
55
+ and `init`, `setup`, `link` and `status` say so too, with the way to get one first in `next`.
56
+ Each folder's `setup` makes its own `FLOW_KEY` (the credit is the account's);
57
+ `setup --rotate key` replaces a leaked one (the old one works 24 h more; `--now` ends it at once).
58
+ - `status` reports `FLOW_KEY` and the model key (never their values; `flow_key.set` in `--json`; with
59
+ `FLOW_KEY` the model's key, the credit left from Flow, `model_key.flow_credit`; a key Flow does not
60
+ know gets `setup --rotate key`), the sign-in, each channel's sender in `.env`
61
+ and, signed in, which channels are open now with their trial counts. It writes nothing.
62
+ - `chat` builds the agent from `src/agent.ts` and writes as its first test person. Each reply is
63
+ printed (`agent: ...`) once the turn is over; `--json` gives one JSON line per thing it sends.
64
+ - `link` does the rest when the folder is not set up (no `FLOW_FRAMEWORK_JOIN_CODE` or
65
+ `FLOW_MESSAGING_KEY` in `.env`, or no sender for the WhatsApp or iMessage link asked for): it
66
+ runs `login` (opens the owner's browser itself and prints the link and code at once), then
67
+ `setup` (writes the keys and each open channel's sender into `.env`), then prints the link.
68
+ `--channel telegram|whatsapp|imessage` picks the channel (Telegram by default). Run it in the
69
+ agent's folder: `setup` and `link` refuse a folder with no `package.json` (`not_an_agent_folder`).
70
+ - A channel that is not open yet is said once ("iMessage isn't open yet; here are your Telegram
71
+ and WhatsApp links"), with the open channels' links, and exits 0: `link --json` then has
72
+ `not_open` (the channel asked for), `links` (each open channel's) and `url`/`channel` for the
73
+ first of them. Only when no channel is open does it exit 3 (`channel_not_open`).
74
+ - On WhatsApp and iMessage the agent can only reply after the owner writes first (WhatsApp: within
75
+ 24 hours of their last message); it cannot message them first. `link --json` says so
76
+ (`reply_only: true`, and in `say_to_person`): tell the owner.
77
+ - The starter's `npm start` is on Telegram, plus WhatsApp and iMessage when `.env` has their senders
78
+ (`FLOW_MESSAGING_WHATSAPP_SENDER`, `FLOW_MESSAGING_IMESSAGE_SENDER`), and reads `.env` once. When
79
+ `setup` or `link` adds a channel (or the model key) it says so: "If npm start is running, restart
80
+ it to add iMessage."
81
+ - Give the owner `say_to_person` from `link --json`, and the sign-in link and code from the
82
+ `pending` line while you wait.
83
+ - `npm start` connects to Flow Messaging by an outbound event stream. Messages sent while it was
84
+ stopped arrive when it starts again (for as long as Flow keeps events, 30 days by default).
85
+
86
+ ## Exit codes (login, setup, link)
87
+
88
+ `status` exits 0 whatever it finds (1 for a wrong flag): read its fields.
89
+
90
+ | Code | Meaning | What to do |
91
+ |---|---|---|
92
+ | 0 | Done | Go on |
93
+ | 1 | Usage, not signed in, not an agent's folder, or a login URL that is not https | Fix the command, or run `next` |
94
+ | 2 | flow.engineer could not be reached, or did not answer in time (`timeout`) | Try again |
95
+ | 3 | Refused: denied, expired, a product failed, no channel open yet (one closed while others are open is exit 0 with their links), a server with no CLI sign-in (`server_unsupported`), or a free-trial limit (`sandbox_allowance_used`, the iMessage people cap; HTTP 403) | Run `next`; for a trial limit `next` is a sentence: give it to the owner as it is |
96
+ | 4 | Sign-in still waiting for the owner's approval, or flow.engineer asked to wait (`slow_down`, HTTP 429) | Approval: ask the owner to approve, then run `next` (the same code is kept). `slow_down`: wait `retry_after` seconds (printed as `Wait <n> s`), then run `next` |
97
+
98
+ `login` and `link` wait at most `--timeout` seconds (120 by default) before exit 4, and no single
99
+ request waits longer than 30 s. `login --wait` (or `link` again) goes on with the same code: no new
100
+ code and no new browser tab. flow.engineer decides when a code has expired (an approved one can be
101
+ collected a while after its `expires_in`); a waiting code that has expired is replaced by one new
102
+ sign-in, and so is a code from another login URL.
103
+
104
+ ## The `next` lines
105
+
106
+ Every error is `{"error":{"code","message","next"}}` on stdout with `--json`, and
107
+ `<message> Next: <next>` on stderr. A waiting sign-in is `{"status":"pending",...,"next":...}`.
108
+ `next` is the exact command or URL to run; when it came from flow.engineer it is the server's.
109
+ A run gives one `next`: `setup --json`'s is a list in order (a model key first when there is
110
+ none, then `npm start` for the playground, then `link` for the phone); inside `link` setup's line
111
+ has none, and an error's `next` replaces it.
112
+ Run it, or give it to the owner when it needs them (a browser approval, a page to open). A
113
+ `slow_down` error also has `retry_after` (seconds, or `null` when the server gave none). A
114
+ trial limit's `next` is a sentence, not a command: it is printed on its own line as it came; pass
115
+ it on to the owner unchanged.
116
+
117
+ flow.engineer may add `warnings` (up to 20 lines) to any setup answer. Each is printed on its own
118
+ line on stderr; with `--json` setup's line has `warnings` (an empty list when there are none), and
119
+ an error line has it when there were any. Pass them on to the owner.
120
+
121
+ `setup` also shows the free trial: messages used per channel, and on iMessage the people it may
122
+ reach (`iMessage: 1 of 2 people`; `trial.imessage.contacts` `{limit, used}` in `--json`).
123
+
124
+ `chat` exits 1 for anything that stops it before the first message: `no_model_key`, `no_agent`
125
+ (no `src/agent.ts`), `not_built`, `no_test_people`, `no_channel`; with `--json` as
126
+ `{"error":{"code","message","next"}}` on stdout.
127
+
128
+ ## Keys
129
+
130
+ - Never ask the owner to paste a key into the chat, and never print, read aloud or commit `.env`.
131
+ `setup` writes the keys itself and prints only their last 4 characters.
132
+ - The model key: Flow's trial key from `setup`, or the owner's own, typed into `.env` themselves.
133
+ - `setup --rotate cloud,traces,messaging,model` (or `all`) makes those keys again and replaces
134
+ their lines in `.env`.
135
+ - `setup` keeps every value already in `.env` (`--replace` takes the new ones), but a name there
136
+ with no value (`cp .env.example .env`) counts as missing and is filled on its own line. Keys
137
+ flow.engineer made are written even when the rest of setup fails, since they are shown only once.
138
+ `.env` is set to mode 0600.
139
+ - The sign-in token goes only to the server that issued it, over https (http only to localhost):
140
+ with `FLOW_FRAMEWORK_LOGIN_URL` naming another server, commands stop with `signed_in_elsewhere`.
141
+ - CI never signs in: `npx @flow-engineer/flow setup --ci` says how to give it the test cloud key.
142
+
143
+ ## Shared test numbers and privacy
144
+
145
+ The free trial runs on Flow's shared Telegram bot, WhatsApp number and iMessage line. Sending an
146
+ agent's join message (`Hi <name> (ref <code>)`, what the link fills in) binds that phone to that
147
+ agent's app on the shared sender, so the person's messages go only to that app; sending another
148
+ app's join message moves them there. Trial chats are
149
+ test data, kept for the account's retention (30 days by default). Do not use the trial for real
150
+ customers' data: connect your own number for that.
151
+
152
+ ## Stability
153
+
154
+ Flow Framework is 0.x. The commands, `--json` fields and exit codes in this guide stay compatible
155
+ within 0.2.x; a breaking change comes with a minor version bump and a note in the release. Pin the
156
+ version in `package.json` for production.
157
+
158
+ ## Host it
159
+
160
+ Coming soon: `npx @flow-engineer/flow deploy`, to keep the agent answering while the laptop sleeps.
@@ -2,13 +2,15 @@
2
2
  // model, events and (simulated) tools.
3
3
 
4
4
  import { fileURLToPath } from "node:url";
5
- import { createAgent, customUI, openAICompatible, sms, whatsapp, type AgentOptions, type EventSink, type Model, type Tool } from "../../../src/index.ts";
5
+ import { createAgent, customUI, openAICompatible, sms, whatsapp, type AgentOptions, type Channel, type EventSink, type Model, type Tool } from "../../../src/index.ts";
6
6
  import { tools as cafeTools } from "./tools.ts";
7
7
 
8
8
  export interface BuildOptions {
9
9
  model?: Model;
10
10
  events?: EventSink[];
11
11
  tools?: Tool[];
12
+ /** Channels that are connected, such as Telegram through Flow Messaging (main.ts adds them when .env has its key); one named whatsapp replaces the unconnected one. */
13
+ channels?: Channel[];
12
14
  /** Where Pip runs, for a host with no file system (Cloudflare): its files as text, its database, no schedule timer. */
13
15
  host?: Pick<AgentOptions, "files" | "database" | "scheduleCheckMs">;
14
16
  }
@@ -23,12 +25,14 @@ const notConnected = async () => {
23
25
  const provider = { sendMessage: notConnected, react: notConnected, typing: notConnected, markRead: notConnected, status: notConnected };
24
26
 
25
27
  export function buildAgent(options: BuildOptions = {}) {
28
+ const connected = options.channels ?? [];
29
+ const unconnected = [whatsapp(provider), sms(provider), customUI("web", provider)].filter((c) => !connected.some((k) => k.name === c.name));
26
30
  return createAgent({
27
31
  ...(options.host ?? { dir: fileURLToPath(new URL("../agent", import.meta.url)) }),
28
32
  model: options.model ?? openAICompatible({ model: process.env.CAFE_MODEL }),
29
33
  tools: options.tools ?? cafeTools,
30
34
  events: options.events ?? [],
31
- channels: [whatsapp(provider), sms(provider), customUI("web", provider)],
35
+ channels: [...unconnected, ...connected],
32
36
  testPeople: [
33
37
  { name: "Maria", phone: "+15555550101" },
34
38
  { name: "Sam", phone: "+15555550102" },
@@ -0,0 +1,20 @@
1
+ // Which Flow Messaging channels Pip is on: Telegram always, WhatsApp and iMessage when .env names
2
+ // their sender (FLOW_MESSAGING_WHATSAPP_SENDER, FLOW_MESSAGING_IMESSAGE_SENDER).
3
+
4
+ import type { flowMessaging } from "../../../flow-messaging/src/index.ts";
5
+
6
+ type FlowMessaging = Pick<ReturnType<typeof flowMessaging>, "telegram" | "whatsapp" | "imessage">;
7
+
8
+ export function flowChannels(fm: FlowMessaging, env: Record<string, string | undefined> = process.env) {
9
+ return [
10
+ fm.telegram(),
11
+ ...(env.FLOW_MESSAGING_WHATSAPP_SENDER ? [fm.whatsapp()] : []),
12
+ ...(env.FLOW_MESSAGING_IMESSAGE_SENDER ? [fm.imessage()] : []),
13
+ ];
14
+ }
15
+
16
+ /** The channels as the log line names them: "Telegram, WhatsApp, iMessage". */
17
+ export function channelNames(channels: Array<{ name: string }>): string {
18
+ const label: Record<string, string> = { telegram: "Telegram", whatsapp: "WhatsApp", imessage: "iMessage" };
19
+ return channels.map((c) => label[c.name] ?? c.name).join(", ");
20
+ }
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ // The café's command line, over its MCP server (npm run mcp in another terminal):
3
+ // npm run cli -- order -h
4
+ // npm run cli -- order place --name Sam --items '[{"item":"Latte","size":"large","milk":"oat"}]'
5
+ // npm run cli -- order status BL-1040 --json
6
+ // (npm reads flags before `--` as its own: keep the `--`.) Installed, it is the `cafe` command.
7
+ // Every tool is already a command under its own name; this file adds the words and arguments.
8
+
9
+ import { fileURLToPath } from "node:url";
10
+ import { defineCli, runCli } from "../../../cli/src/index.ts";
11
+
12
+ export const cli = defineCli({
13
+ name: "cafe",
14
+ server: process.env.CAFE_SERVER ?? "http://127.0.0.1:8790/mcp",
15
+ description: "Bean & Leaf's menu and pickup orders",
16
+ groups: { order: "Place and track pickup orders" },
17
+ commands: {
18
+ menu: { tool: "get_menu", columns: ["item", "milks", "allergens"] },
19
+ "order place": { tool: "place_order", examples: [`cafe order place --name Sam --items '[{"item":"Latte","size":"large","milk":"oat"}]'`] },
20
+ "order status": { tool: "order_status", args: ["order_id"], view: ["found", "status", "ready_by"], examples: ["cafe order status BL-1040"] },
21
+ },
22
+ });
23
+
24
+ if (process.argv[1] === fileURLToPath(import.meta.url)) process.exit(await runCli(cli, process.argv.slice(2)));
@@ -1,16 +1,30 @@
1
- // Pip in the playground: `npm start`, then open the printed link and pick a test person.
1
+ // Pip in the playground: `npm start`, then open the printed link and pick a test person. With
2
+ // FLOW_MESSAGING_KEY in .env (`npx @flow-engineer/flow link` puts it there), Pip is on Telegram
3
+ // too (and WhatsApp and iMessage when .env names their senders), through Flow Messaging's event
4
+ // stream: an outbound connection, so no public URL or tunnel.
2
5
 
6
+ import { flowMessaging } from "../../../flow-messaging/src/index.ts";
3
7
  import { simulators } from "../../../simulators/src/index.ts";
4
8
  import { buildAgent } from "./agent.ts";
9
+ import { channelNames, flowChannels } from "./channels.ts";
5
10
 
6
11
  const play = simulators({ title: "Pip · Bean & Leaf" });
12
+ const fm = process.env.FLOW_MESSAGING_KEY ? flowMessaging() : undefined;
13
+ const channels = fm ? flowChannels(fm) : [];
7
14
  let agent: ReturnType<typeof buildAgent>;
8
15
  try {
9
- agent = buildAgent({ events: [play.events] });
16
+ agent = buildAgent({ events: [play.events], channels });
10
17
  } catch (error) {
11
18
  console.error((error as Error).message); // such as no model key in .env
12
19
  process.exit(1);
13
20
  }
14
- const { url } = await play.listen(agent, Number(process.env.PORT ?? 8788));
21
+ const port = Number(process.env.PORT ?? 8788);
22
+ const { url } = await play.listen(agent, port);
15
23
  console.log(`Pip's playground: ${url}/`);
16
24
  console.log(`Every channel side by side: ${url}/person`);
25
+ if (fm) {
26
+ // Events sent while Pip was not running come when it starts again (.flow/ keeps the last one handled).
27
+ const live = fm.start(agent, { cursorFile: ".flow/flow-messaging-cursor", port: port + 1 });
28
+ console.log(`On ${channelNames(channels)} through Flow Messaging (${live.mode}). The phone link: npx @flow-engineer/flow link`);
29
+ live.stopped.then((why) => console.error(`Flow Messaging stopped: ${why.message} Then: npx @flow-engineer/flow setup`));
30
+ }
@@ -0,0 +1,22 @@
1
+ // Pip's tools and skills as an MCP server, for Claude, ChatGPT or any MCP host: `npm run mcp`.
2
+ // The same tools the chat agent uses; no model key needed, since the host brings its own model.
3
+
4
+ import { createServer } from "node:http";
5
+ import { fileURLToPath } from "node:url";
6
+ import { mcpServer } from "../../../mcp/src/index.ts";
7
+ import { tools } from "./tools.ts";
8
+
9
+ export const mcp = mcpServer({
10
+ name: "bean-and-leaf",
11
+ version: "1.0.0",
12
+ tools,
13
+ dir: fileURLToPath(new URL("../agent", import.meta.url)),
14
+ // The café has no accounts yet, so every tool answers without sign-in. With accounts, add
15
+ // auth: { issuer, resource, scopes, verify } (the chinwag-auth skill).
16
+ });
17
+
18
+ const port = Number(process.env.MCP_PORT ?? 8790);
19
+ createServer((req, res) => void mcp.node(req, res)).listen(port, "127.0.0.1", () => {
20
+ console.log(`Pip's MCP server: http://127.0.0.1:${port}/mcp`);
21
+ console.log(`Try it in Claude Code: claude mcp add --transport http bean-and-leaf http://127.0.0.1:${port}/mcp`);
22
+ });
@@ -19,8 +19,15 @@ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
19
19
  export const tools = [
20
20
  tool({
21
21
  name: "get_menu",
22
+ title: "Menu",
22
23
  description: "The full menu: items, sizes with prices in USD, milk options and allergens.",
23
24
  input: z.object({}),
25
+ output: z.object({
26
+ menu: z.array(z.object({ item: z.string(), sizes: z.record(z.string(), z.number()), milks: z.array(z.string()), allergens: z.array(z.string()) })),
27
+ note: z.string(),
28
+ }),
29
+ signIn: "none",
30
+ hints: { readOnly: true, openWorld: false },
24
31
  run: async () => {
25
32
  await wait(150);
26
33
  return { menu: MENU, note: "This is the whole menu." };
@@ -28,6 +35,7 @@ export const tools = [
28
35
  }),
29
36
  tool({
30
37
  name: "place_order",
38
+ title: "Place an order",
31
39
  description: "Place a confirmed pickup order. Returns the order number and when it will be ready.",
32
40
  input: z.object({
33
41
  name: z.string().describe("The name for the order"),
@@ -40,6 +48,9 @@ export const tools = [
40
48
  }),
41
49
  ),
42
50
  }),
51
+ output: z.object({ order_id: z.string(), ready_by: z.string(), pay_at_pickup: z.boolean() }),
52
+ status: { running: "Placing your order…", done: "Order placed" },
53
+ hints: { destructive: false, openWorld: false },
43
54
  run: async ({ items }) => {
44
55
  await wait(5_000);
45
56
  const id = `BL-${nextOrder++}`;
@@ -52,6 +63,8 @@ export const tools = [
52
63
  name: "order_status",
53
64
  description: "Where an order is: received, being made, or ready.",
54
65
  input: z.object({ order_id: z.string() }),
66
+ output: z.object({ found: z.boolean(), status: z.enum(["being made", "ready"]).optional(), ready_by: z.string().optional() }),
67
+ hints: { readOnly: true, openWorld: false },
55
68
  run: async ({ order_id }) => {
56
69
  await wait(200);
57
70
  const order = orders.get(order_id);
@@ -1,9 +1,13 @@
1
1
  # Flow Messaging channels
2
2
 
3
- Put a Flow Framework (`npm i @flow-engineer/flow`) agent on real WhatsApp, Telegram and US SMS
3
+ Put a Flow Framework (`npm i @flow-engineer/flow`) agent on real WhatsApp, Telegram, iMessage and US SMS
4
4
  through [Flow Messaging](https://api.flow.engineer) (API version 2026-11-01). `@flow-engineer/flow/flow-messaging` gives you
5
- channels that send through Flow's senders, and a webhook that checks Flow's signed events and hands
6
- them to the agent. It needs nothing beyond the framework (it uses `fetch` and `node:crypto`).
5
+ channels that send through Flow's senders, and two ways to receive Flow's events: the live event
6
+ stream, an outbound WebSocket that needs no public URL, and a webhook that checks Flow's
7
+ signatures. It needs nothing beyond the framework (it uses `fetch`, Node's `WebSocket` and `node:crypto`).
8
+
9
+ On a laptop, `npx @flow-engineer/flow link` signs in, writes `FLOW_MESSAGING_KEY` and the Telegram
10
+ sender into `.env` and prints the link for your phone, so you can skip step 1 below.
7
11
 
8
12
  ```sh
9
13
  npm install @flow-engineer/flow
@@ -26,7 +30,8 @@ Each sender has an ID (`snd_...`), a link to open on your phone, and a join code
26
30
 
27
31
  The sandbox has Telegram today. WhatsApp and iMessage come as they open. A sandbox key lasts 7 days
28
32
  and covers one contact. Live keys (`fk_live_...`) and your own WhatsApp, Telegram or
29
- SMS senders are set up from the dashboard at https://api.flow.engineer.
33
+ SMS senders are set up from the dashboard at https://api.flow.engineer. iMessage lines, Flow's
34
+ shared one included, are arranged with the Flow team.
30
35
 
31
36
  Open the Telegram sandbox sender's link on your phone and send its join code. You are now the
32
37
  sandbox contact your agent can talk to.
@@ -35,31 +40,58 @@ sandbox contact your agent can talk to.
35
40
 
36
41
  ```ts
37
42
  // main.ts
38
- import { createServer } from "node:http";
39
43
  import { createAgent } from "@flow-engineer/flow";
40
44
  import { flowMessaging } from "@flow-engineer/flow/flow-messaging";
41
45
 
42
- const fm = flowMessaging(); // reads FLOW_MESSAGING_KEY and FLOW_MESSAGING_WEBHOOK_SECRET
46
+ const fm = flowMessaging(); // reads FLOW_MESSAGING_KEY (and FLOW_MESSAGING_WEBHOOK_SECRET for a webhook)
43
47
 
44
48
  const agent = createAgent({
45
49
  dir: "./agent", // your agent's folder (AGENTS.md, ...)
46
50
  model: yourModel,
47
51
  // only the channels you have senders for
48
- channels: [fm.telegram(), fm.whatsapp(), fm.sms()],
52
+ channels: [fm.telegram(), fm.whatsapp(), fm.imessage(), fm.sms()],
49
53
  });
50
54
  await agent.resume();
51
55
 
52
- createServer(fm.webhook(agent)).listen(Number(process.env.PORT ?? 8080));
56
+ const live = fm.start(agent, { cursorFile: ".flow/flow-messaging-cursor" });
57
+ live.stopped.then((why) => console.error(`Flow Messaging refused the stream: ${why.message}`));
53
58
  ```
54
59
 
55
- Already have an HTTP server? Hand the raw body and headers to `fm.receive` instead:
60
+ That's it on a laptop: with no webhook secret, `fm.start` opens the event stream (`fm.stream(agent)`
61
+ does only that).
62
+
63
+ ### The event stream
64
+
65
+ `GET /v1/stream` is a WebSocket the agent opens to Flow, with the key offered as a subprotocol
66
+ (Node's `WebSocket` cannot set headers). It asks only for the events the adapter handles and hands
67
+ each, in log order, to the same handler as the webhook.
68
+
69
+ - **Acknowledged once handled.** The stream has no ack frame for events; it resumes from `after`.
70
+ The adapter moves its cursor past an event only once the agent has taken it, so an event that
71
+ fails, or arrives as the socket drops, comes again.
72
+ - **Reconnects.** A dropped socket, Flow's `reconnect` frame (it restarts, or a connection reaches
73
+ 24 hours) and a failed event reconnect with `after` set to the last handled event: at once the
74
+ first time, then backing off to 30 s, or after Flow's `retry_after` (too many streams for the
75
+ key, Flow busy). An event that fails 5 times in a row is passed over, so it cannot hold up every
76
+ later one; it stays in Flow's log (`GET /v1/events`).
77
+ - **Offline messages.** With `cursorFile`, the last handled event's ID is kept on disk, so after a
78
+ restart Flow replays everything that came while the agent was stopped, for as long as your plan
79
+ keeps events (30 days by default). Without it, a dropped stream still resumes, but a restart
80
+ starts from now. A cursor older than Flow's log starts again from now, and says so.
81
+ - **Stops for good** on a refused key or request (close codes 4401, 4403, 4400): `stopped`
82
+ resolves with the code, type and Flow's message. `close()` ends the stream.
83
+ - **Replies go over HTTP**, with their Idempotency-Keys, so they never wait for the stream.
84
+ - The stream cannot send `Flow-Version`, so its events come in the version your app was pinned to.
85
+
86
+ ### Or a webhook
87
+
88
+ Already have an HTTP server? Hand the raw body and headers to `fm.receive`, or serve `fm.webhook(agent)`
89
+ (with a webhook secret set, `fm.start` serves it on `port`, default `PORT` else 8080):
56
90
 
57
91
  ```ts
58
92
  const { status, body } = await fm.receive(agent, { body: rawBody, headers: req.headers });
59
93
  ```
60
94
 
61
- ### 3. Point Flow's webhook at it
62
-
63
95
  Flow posts events to a public HTTPS URL. Create a webhook endpoint for it:
64
96
 
65
97
  ```sh
@@ -72,32 +104,31 @@ curl https://api.flow.engineer/v1/webhook_endpoints \
72
104
  The answer holds the signing secret (`secret`, `whsec_...`), shown once. Save it as
73
105
  `FLOW_MESSAGING_WEBHOOK_SECRET`.
74
106
 
75
- On a laptop there is no public URL. Run a tunnel to the port (for example
76
- `cloudflared tunnel --url http://localhost:8080`) and register that URL.
107
+ On a laptop there is no public URL: use the event stream instead.
77
108
 
78
109
  ## Environment
79
110
 
80
111
  | Variable | What it is |
81
112
  |---|---|
82
113
  | `FLOW_MESSAGING_KEY` | Your app's API key, `fk_test_...` or `fk_live_...`. Keep it on your server. |
83
- | `FLOW_MESSAGING_WEBHOOK_SECRET` | The webhook endpoint's signing secret, `whsec_...`. While you rotate it, give both, comma-separated. |
84
- | `FLOW_MESSAGING_TELEGRAM_SENDER`, `FLOW_MESSAGING_WHATSAPP_SENDER`, `FLOW_MESSAGING_SMS_SENDER` | Each channel's sender, `snd_...`. Needed to start conversations (nudges, templates) and to tell senders apart. Replies to people who wrote first work without it. |
114
+ | `FLOW_MESSAGING_WEBHOOK_SECRET` | The webhook endpoint's signing secret, `whsec_...`. While you rotate it, give both, comma-separated. Without it, `fm.start` uses the event stream. |
115
+ | `FLOW_MESSAGING_TELEGRAM_SENDER`, `FLOW_MESSAGING_WHATSAPP_SENDER`, `FLOW_MESSAGING_IMESSAGE_SENDER`, `FLOW_MESSAGING_SMS_SENDER` | Each channel's sender, `snd_...`. Needed to start conversations (nudges, templates) and to tell senders apart. Replies to people who wrote first work without it. |
85
116
  | `FLOW_MESSAGING_BASE_URL` | Optional. Default `https://api.flow.engineer`. |
86
117
 
87
118
  Each of these can also be passed in code: `flowMessaging({ apiKey, webhookSecret, baseUrl })`, and
88
- `fm.telegram({ sender, name, instructions })` (the same for `whatsapp` and `sms`).
119
+ `fm.telegram({ sender, name, instructions })` (the same for `whatsapp`, `imessage` and `sms`).
89
120
 
90
121
  ## What it maps
91
122
 
92
123
  **Out (agent to person):**
93
124
 
94
- - Text: Markdown on Telegram, plain text on WhatsApp and SMS.
125
+ - Text: Markdown on Telegram, plain text on WhatsApp, iMessage and SMS.
95
126
  - Reply buttons and lists, sent as Flow `buttons`. WhatsApp shows up to 3 reply buttons, or a list
96
- for 4 to 10. Telegram shows an inline keyboard.
97
- - A link: one URL button (WhatsApp's call-to-action button).
127
+ for 4 to 10. Telegram shows an inline keyboard. iMessage has none, so the agent is not offered them.
128
+ - A link: one URL button (WhatsApp's call-to-action button); on iMessage, the URL on its own line.
98
129
  - A photo: a media message with the text as its caption. Flow's button messages have no image
99
130
  header, so a photo with buttons or a link goes just before them.
100
- - Quotes (`reply_to`), reactions, typing, and read receipts (WhatsApp).
131
+ - Quotes (`reply_to`), reactions (tapbacks on iMessage), typing, and read receipts (WhatsApp, iMessage).
101
132
  - WhatsApp templates, by name or `tpl_` ID, with their header (text, or media by URL or file ID),
102
133
  body values (`{{1}}`, `{{2}}`, ...) and button values (URL suffixes, quick-reply payloads).
103
134
 
@@ -122,24 +153,32 @@ Each of these can also be passed in code: `flowMessaging({ apiKey, webhookSecret
122
153
  template.
123
154
  - A permanent refusal (opted out, no consent, ...) reaches the agent once, with "don't resend it".
124
155
  - Signatures are checked: HMAC-SHA256, five minutes' tolerance, and any of the secrets you gave.
125
- - An event Flow delivers twice reaches the agent once. A failure answers 500, so Flow delivers the
126
- event again.
156
+ - An event Flow delivers twice (or by both the stream and a webhook) reaches the agent once. On the
157
+ webhook a failure answers 500, so Flow delivers the event again; on the stream it comes again on
158
+ the next connection.
127
159
  - `contact.deleted` makes it forget the contact.
128
160
  - Keys and message text are never logged.
129
161
 
130
- What it remembers (conversations, contacts, event IDs) is kept in memory. After a restart, the
162
+ What it remembers (conversations, contacts, event IDs) is kept in memory, except the stream's
163
+ `cursorFile`. After a restart, the
131
164
  first send to someone starts from the channel's sender, and Flow puts it into the open conversation
132
165
  if there is one.
133
166
 
134
- The framework has no Telegram channel kind yet, so Telegram is a custom channel. iMessage is not in this
135
- package yet.
167
+ The framework has no Telegram or iMessage channel kind yet, so both are custom channels. On iMessage
168
+ a person is their phone number, or their email address when that is their handle; they are not joined
169
+ with the same number on WhatsApp or SMS.
170
+
171
+ **iMessage replies only.** On Flow's shared iMessage sender (as on WhatsApp after 24 hours) the agent
172
+ can only answer someone who wrote first. A nudge or scheduled message to anyone else fails, and
173
+ reaches the agent as "Not delivered" (`outside_window`). Ask people to write first with the line's
174
+ opt-in link. Text takes up to 9999 characters; there are no buttons, lists or templates.
136
175
 
137
176
  ## Also exported
138
177
 
139
178
  - `verifySignature(body, header, secrets)`: checks a `Flow-Signature` header yourself.
140
179
  - `FlowMessagingError`: the API's typed error (`status`, `type`, `channelCode`, `retryAfter`).
141
180
  - `API_VERSION`: the API version every request asks for, `2026-11-01`.
142
- - `TELEGRAM`: the Telegram channel's limits, as a framework `ChannelInfo`.
181
+ - `TELEGRAM`, `IMESSAGE`: the Telegram and iMessage channels' limits, as framework `ChannelInfo`s.
143
182
 
144
183
  ## Links
145
184
 
@@ -149,4 +188,4 @@ package yet.
149
188
 
150
189
  ## License
151
190
 
152
- MIT, as the rest of this repository.
191
+ Apache-2.0, as the rest of this repository.