@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.
- package/LICENSE +202 -21
- package/README.md +190 -17
- package/coding-agents/auth-skill.md +137 -0
- package/coding-agents/context-skill.md +94 -0
- package/coding-agents/skill.md +245 -11
- package/coding-agents/test-skill.md +13 -11
- package/coding-agents/tools-skill.md +78 -1
- package/dist/cli/src/auth.d.ts +11 -0
- package/dist/cli/src/auth.js +147 -0
- package/dist/cli/src/commands.d.ts +119 -0
- package/dist/cli/src/commands.js +212 -0
- package/dist/cli/src/index.d.ts +2 -0
- package/dist/cli/src/index.js +5 -0
- package/dist/cli/src/run.d.ts +28 -0
- package/dist/cli/src/run.js +523 -0
- package/dist/cli/src/terminal.d.ts +3 -0
- package/dist/cli/src/terminal.js +135 -0
- package/dist/flow-messaging/src/client.d.ts +7 -1
- package/dist/flow-messaging/src/client.js +13 -1
- package/dist/flow-messaging/src/index.d.ts +38 -3
- package/dist/flow-messaging/src/index.js +242 -40
- package/dist/mcp/src/client.d.ts +54 -0
- package/dist/mcp/src/client.js +175 -0
- package/dist/mcp/src/describe.d.ts +41 -0
- package/dist/mcp/src/describe.js +110 -0
- package/dist/mcp/src/index.d.ts +4 -0
- package/dist/mcp/src/index.js +7 -0
- package/dist/mcp/src/server.d.ts +70 -0
- package/dist/mcp/src/server.js +252 -0
- package/dist/mcp/src/terminal.d.ts +24 -0
- package/dist/mcp/src/terminal.js +54 -0
- package/dist/simulators/src/index.d.ts +1 -1
- package/dist/simulators/src/page.html +267 -49
- package/dist/simulators/src/server.d.ts +27 -0
- package/dist/simulators/src/server.js +264 -40
- package/dist/src/agent.d.ts +69 -3
- package/dist/src/agent.js +162 -8
- package/dist/src/channels.d.ts +2 -0
- package/dist/src/conversation.d.ts +16 -1
- package/dist/src/conversation.js +140 -12
- package/dist/src/events.d.ts +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +1 -1
- package/dist/src/model.d.ts +27 -0
- package/dist/src/model.js +85 -13
- package/dist/src/names.d.ts +5 -0
- package/dist/src/names.js +5 -0
- package/dist/src/skills.d.ts +4 -0
- package/dist/src/skills.js +23 -0
- package/dist/src/store.d.ts +2 -0
- package/dist/src/store.js +18 -0
- package/dist/src/tools.d.ts +69 -0
- package/dist/src/tools.js +17 -1
- package/dist/src/traces.js +32 -4
- package/dist/src/types.d.ts +2 -0
- package/dist/testing/src/account.d.ts +4 -0
- package/dist/testing/src/account.js +891 -0
- package/dist/testing/src/chat.d.ts +3 -0
- package/dist/testing/src/chat.js +125 -0
- package/dist/testing/src/check.d.ts +4 -0
- package/dist/testing/src/check.js +90 -0
- package/dist/testing/src/chinwag.js +1 -1
- package/dist/testing/src/cli.js +105 -18
- package/dist/testing/src/client.d.ts +2 -0
- package/dist/testing/src/client.js +4 -3
- package/dist/testing/src/flow-login.d.ts +176 -0
- package/dist/testing/src/flow-login.js +274 -0
- package/dist/testing/src/new.d.ts +15 -2
- package/dist/testing/src/new.js +80 -21
- package/dist/testing/src/plugin.d.ts +4 -0
- package/dist/testing/src/plugin.js +116 -0
- package/docs/agents.md +160 -0
- package/examples/cafe/src/agent.ts +6 -2
- package/examples/cafe/src/channels.ts +20 -0
- package/examples/cafe/src/cli.ts +24 -0
- package/examples/cafe/src/main.ts +17 -3
- package/examples/cafe/src/mcp.ts +22 -0
- package/examples/cafe/src/tools.ts +13 -0
- package/flow-messaging/README.md +66 -27
- package/package.json +30 -2
- package/spec/agent-endpoint.openapi.yaml +2 -2
- 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: [
|
|
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
|
|
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);
|
package/flow-messaging/README.md
CHANGED
|
@@ -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
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
135
|
-
|
|
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
|
|
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
|
-
|
|
191
|
+
Apache-2.0, as the rest of this repository.
|