@flow-engineer/flow 0.2.0 → 0.2.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.
@@ -0,0 +1,249 @@
1
+ // The flow.engineer side of `flow login`, `logout`, `whoami` and `setup`: every call to it, and
2
+ // where the Flow CLI token is kept. Only this file knows the server's routes and field names
3
+ // (flow.engineer's CLI sign-in contract).
4
+ // POST /cli/device {client_name, hostname} -> {device_code, user_code, verification_uri, verification_uri_complete, interval, expires_in}
5
+ // POST /cli/token {device_code} -> {access_token, expires_at, account}, or 400 {error} as RFC 8628 has it:
6
+ // authorization_pending, slow_down, expired_token, access_denied, invalid_grant
7
+ // POST /cli/revoke (bearer) {} ends this token
8
+ // GET /cli/whoami (bearer) -> {account, email, token}
9
+ // POST /cli/setup (bearer) {name, hostname, channels, have}
10
+ // -> {agent_id, cloud_key?, traces_key?, messaging_key?, join_code, account, app_id, dashboard_url, try_url,
11
+ // <channel>_sender?, <channel>_link? (telegram, whatsapp, imessage: each only while that shared sender
12
+ // is open), try_urls?, trial?, minted, next}; a 502 carries every field that worked too;
13
+ // 429 slow_down with Retry-After; 403 for a trial limit (sandbox_allowance_used, ...), its next prose
14
+ // Any answer may carry warnings: up to 20 strings to show the owner as they are.
15
+ // Every error is flat: {"error": "<code>", "error_description": "<sentence>", "next": "<command, URL or sentence>"}.
16
+ // The token is opaque (fcli_...), good only for /cli/*, and sent only to the server that issued it,
17
+ // over https (http only to this machine). Every request gives up after REQUEST_TIMEOUT_MS at most.
18
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { configDir, configFile, RUN, setting } from "../../src/names.js";
21
+ export const LOGIN_URL = "https://flow.engineer";
22
+ /** The longest one request to flow.engineer may take; login's polls are cut shorter by what is left of --timeout. */
23
+ export const REQUEST_TIMEOUT_MS = 30_000;
24
+ /** A login URL a token may go to: https, or http to this machine only (a local flow.engineer). */
25
+ export function safeLoginUrl(url) {
26
+ const parsed = URL.parse(url);
27
+ if (!parsed)
28
+ return false;
29
+ if (parsed.protocol === "https:")
30
+ return true;
31
+ return parsed.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname);
32
+ }
33
+ /** flow.engineer, or FLOW_FRAMEWORK_LOGIN_URL, without trailing slashes (trimmed from the end, in linear time). */
34
+ export function loginUrl(env = process.env) {
35
+ const url = setting("LOGIN_URL", env) || LOGIN_URL;
36
+ let end = url.length;
37
+ while (end > 0 && url[end - 1] === "/")
38
+ end--;
39
+ return url.slice(0, end);
40
+ }
41
+ /** ~/.config/flow-framework/credentials.json (XDG_CONFIG_HOME when set), written at mode 0600. */
42
+ export function credentialsPath(env = process.env) {
43
+ return join(configDir(env), "credentials.json");
44
+ }
45
+ /** The credentials file to read or delete: credentialsPath, else ~/.config/chinwag/credentials.json while only it exists. */
46
+ const savedCredentialsPath = (env) => configFile("credentials.json", env);
47
+ /**
48
+ * The token to use: FLOW_FRAMEWORK_TOKEN, else the credentials file's (with `url`, the server that
49
+ * issued it, the only one it is sent to); undefined when signed out.
50
+ */
51
+ export function currentToken(env = process.env) {
52
+ const fromEnv = setting("TOKEN", env)?.trim();
53
+ if (fromEnv)
54
+ return { token: fromEnv, from: "env" };
55
+ const saved = readCredentials(env);
56
+ return saved ? { token: saved.token, from: "file", url: saved.url || LOGIN_URL } : undefined;
57
+ }
58
+ export function readCredentials(env = process.env) {
59
+ const path = savedCredentialsPath(env);
60
+ if (!existsSync(path))
61
+ return undefined;
62
+ try {
63
+ const saved = JSON.parse(readFileSync(path, "utf8"));
64
+ return typeof saved.token === "string" && saved.token ? saved : undefined;
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ }
70
+ export function saveCredentials(credentials, env = process.env) {
71
+ const path = credentialsPath(env);
72
+ mkdirSync(join(path, ".."), { recursive: true, mode: 0o700 });
73
+ writeFileSync(path, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
74
+ chmodSync(path, 0o600); // a file that was already there keeps its mode on write
75
+ return path;
76
+ }
77
+ export function deleteCredentials(env = process.env) {
78
+ const path = savedCredentialsPath(env);
79
+ if (!existsSync(path))
80
+ return false;
81
+ rmSync(path);
82
+ return true;
83
+ }
84
+ /** ~/.config/flow-framework/pending.json, written at mode 0600 and deleted once the code is used or expired. */
85
+ export function pendingPath(env = process.env) {
86
+ return join(configDir(env), "pending.json");
87
+ }
88
+ export function readPending(env = process.env) {
89
+ try {
90
+ const saved = JSON.parse(readFileSync(pendingPath(env), "utf8"));
91
+ return typeof saved.device_code === "string" && typeof saved.expires_at === "number" ? saved : undefined;
92
+ }
93
+ catch {
94
+ return undefined;
95
+ }
96
+ }
97
+ export function savePending(pending, env = process.env) {
98
+ const path = pendingPath(env);
99
+ mkdirSync(join(path, ".."), { recursive: true, mode: 0o700 });
100
+ writeFileSync(path, `${JSON.stringify(pending, null, 2)}\n`, { mode: 0o600 });
101
+ chmodSync(path, 0o600);
102
+ }
103
+ export function deletePending(env = process.env) {
104
+ rmSync(pendingPath(env), { force: true });
105
+ }
106
+ /** The answer's warnings: its strings only, at most 20. */
107
+ export function warningsOf(body) {
108
+ return Array.isArray(body.warnings) ? body.warnings.filter((w) => typeof w === "string" && w !== "").slice(0, 20) : [];
109
+ }
110
+ /** Retry-After as seconds from now (delta-seconds or an HTTP date); undefined when absent or unreadable. */
111
+ export function retryAfterSeconds(header, now = Date.now()) {
112
+ if (!header)
113
+ return undefined;
114
+ const value = header.trim();
115
+ if (/^\d+$/.test(value))
116
+ return Number(value);
117
+ const at = Date.parse(value);
118
+ return Number.isNaN(at) ? undefined : Math.max(0, Math.ceil((at - now) / 1000));
119
+ }
120
+ /**
121
+ * The server answered with an error status; `code` is its `error` field when it gave one, `next` its
122
+ * next step, and `fields` the whole answer (a failed setup carries the keys it minted anyway).
123
+ */
124
+ export class LoginRefused extends Error {
125
+ status;
126
+ code;
127
+ next;
128
+ fields;
129
+ /** Seconds the server asked to wait (Retry-After), for a 429. */
130
+ retryAfter;
131
+ constructor(status, code, message, next, fields = {}, retryAfter) {
132
+ super(message);
133
+ this.status = status;
134
+ this.code = code;
135
+ this.next = next;
136
+ this.fields = fields;
137
+ this.retryAfter = retryAfter;
138
+ }
139
+ }
140
+ /** The server answered nothing in time: `ms` is how long this request was given. */
141
+ export class TimedOut extends Error {
142
+ ms;
143
+ constructor(url, ms) {
144
+ super(`${url} did not answer within ${Math.ceil(ms / 1000)} s.`);
145
+ this.ms = ms;
146
+ }
147
+ }
148
+ async function call(url, path, options = {}) {
149
+ if (!safeLoginUrl(url)) {
150
+ throw new LoginRefused(0, "insecure_login_url", `${url} is not https: the sign-in and its token go only over https (http only to localhost).`, `set FLOW_FRAMEWORK_LOGIN_URL to an https address, or unset it to use ${LOGIN_URL}`);
151
+ }
152
+ const timeoutMs = Math.max(0, Math.min(options.timeoutMs ?? REQUEST_TIMEOUT_MS, REQUEST_TIMEOUT_MS));
153
+ const headers = { accept: "application/json" };
154
+ if (options.token)
155
+ headers.authorization = `Bearer ${options.token}`;
156
+ if (options.body !== undefined)
157
+ headers["content-type"] = "application/json";
158
+ let response;
159
+ let text;
160
+ try {
161
+ response = await fetch(`${url}${path}`, {
162
+ method: options.method ?? "POST",
163
+ headers,
164
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
165
+ redirect: options.token ? "error" : "follow", // a redirect would carry the token to another address
166
+ signal: AbortSignal.timeout(timeoutMs),
167
+ });
168
+ text = await response.text();
169
+ }
170
+ catch (error) {
171
+ if (error.name === "TimeoutError")
172
+ throw new TimedOut(url, timeoutMs);
173
+ throw error;
174
+ }
175
+ let body = {};
176
+ try {
177
+ body = text ? JSON.parse(text) : {};
178
+ }
179
+ catch {
180
+ if (response.ok)
181
+ throw new LoginRefused(response.status, "bad_answer", `${url}${path} answered ${response.status} with no JSON.`);
182
+ }
183
+ return { status: response.status, body, retryAfter: retryAfterSeconds(response.headers.get("retry-after")) };
184
+ }
185
+ function refused(path, answer, url) {
186
+ // A bare 404: this server has no CLI sign-in (an older one, or not flow.engineer at all).
187
+ if (answer.status === 404 && typeof answer.body.error !== "string") {
188
+ return new LoginRefused(404, "server_unsupported", `${url} has no ${path}, so it cannot sign in the Flow CLI: it is an older server, or not flow.engineer.`, url === LOGIN_URL ? `${RUN}@latest login` : `unset FLOW_FRAMEWORK_LOGIN_URL to use ${LOGIN_URL}, or point it at a server that has /cli/`, answer.body);
189
+ }
190
+ const code = typeof answer.body.error === "string" ? answer.body.error : answer.status === 401 ? "not_signed_in" : `http_${answer.status}`;
191
+ const detail = typeof answer.body.error_description === "string" ? answer.body.error_description : typeof answer.body.message === "string" ? answer.body.message : "";
192
+ const next = typeof answer.body.next === "string" && answer.body.next ? answer.body.next : undefined;
193
+ const sentence = detail.endsWith(".") ? detail.slice(0, -1) : detail;
194
+ return new LoginRefused(answer.status, code, `${path} answered ${answer.status} ${code}${sentence ? `: ${sentence}` : ""}.`, next, answer.body, answer.retryAfter);
195
+ }
196
+ /** `clientName` and `hostname` are shown on the approval page. */
197
+ export async function startDevice(url, clientName, hostname) {
198
+ const answer = await call(url, "/cli/device", { body: { client_name: clientName, hostname } });
199
+ if (answer.status !== 200 || typeof answer.body.device_code !== "string")
200
+ throw refused("/cli/device", answer, url);
201
+ return answer.body;
202
+ }
203
+ const WAITING = new Set(["authorization_pending", "slow_down", "expired_token", "access_denied"]);
204
+ /** `timeoutMs` cuts this one poll short (login gives it what is left of --timeout). */
205
+ export async function pollToken(url, deviceCode, timeoutMs) {
206
+ const answer = await call(url, "/cli/token", { body: { device_code: deviceCode }, timeoutMs });
207
+ const token = answer.body.access_token;
208
+ if (answer.status === 200 && typeof token === "string") {
209
+ return { status: "signed_in", token, expires_at: answer.body.expires_at, account: answer.body.account };
210
+ }
211
+ const error = answer.body.error;
212
+ if (typeof error === "string" && WAITING.has(error))
213
+ return { status: error };
214
+ throw refused("/cli/token", answer, url);
215
+ }
216
+ export async function revoke(url, token) {
217
+ const answer = await call(url, "/cli/revoke", { token, body: {} });
218
+ // 401: the token had already ended, which is what logout wants.
219
+ if (answer.status >= 300 && answer.status !== 401)
220
+ throw refused("/cli/revoke", answer, url);
221
+ }
222
+ export async function whoami(url, token) {
223
+ const answer = await call(url, "/cli/whoami", { token, method: "GET" });
224
+ if (answer.status !== 200)
225
+ throw refused("/cli/whoami", answer, url);
226
+ return answer.body;
227
+ }
228
+ export async function setup(url, token, request) {
229
+ const answer = await call(url, "/cli/setup", { token, body: request });
230
+ // A 200 is returned whole even without a join code: the keys it minted are shown only now.
231
+ if (answer.status !== 200)
232
+ throw refused("/cli/setup", answer, url);
233
+ return answer.body;
234
+ }
235
+ /** The agent's .env names for what setup hands back; only the keys' and the join's names are sent as `have`. */
236
+ export const SETUP_ENV = {
237
+ cloud_key: "FLOW_FRAMEWORK_CLOUD_KEY",
238
+ traces_key: "FLOW_FRAMEWORK_TRACES_KEY",
239
+ messaging_key: "FLOW_MESSAGING_KEY",
240
+ telegram_sender: "FLOW_MESSAGING_TELEGRAM_SENDER",
241
+ whatsapp_sender: "FLOW_MESSAGING_WHATSAPP_SENDER",
242
+ imessage_sender: "FLOW_MESSAGING_IMESSAGE_SENDER",
243
+ join_code: "FLOW_FRAMEWORK_JOIN_CODE",
244
+ try_url: "FLOW_FRAMEWORK_TRY_URL",
245
+ };
246
+ /** The phone link for a join code, built as the server builds its try_url, for a .env from before setup kept it. */
247
+ export function tryLink(url, joinCode, channel) {
248
+ return `${url}/try/${encodeURIComponent(joinCode)}?c=${encodeURIComponent(channel)}`;
249
+ }
@@ -7,3 +7,7 @@ export declare function newStandalone(name: string, options?: {
7
7
  into?: string;
8
8
  root?: string;
9
9
  }): string;
10
+ /** Makes the agent in `dir` (the current folder by default), named after the folder; returns its name. */
11
+ export declare function initHere(dir?: string, options?: {
12
+ root?: string;
13
+ }): string;
@@ -1,9 +1,11 @@
1
1
  // `flow new <name>`: makes ./<name>/, an agent of its own started from the café example (Pip),
2
- // that depends on the @flow-engineer/flow package. Its imports become "@flow-engineer/flow" and "@flow-engineer/flow/simulators", and
3
- // the coding-agent skills go in .claude/skills/, with an AGENTS.md (and a CLAUDE.md importing it)
4
- // pointing at them. Inside a clone, `npm run new -- <name>` makes agents/<name>/ instead.
2
+ // that depends on the @flow-engineer/flow package. Its imports become "@flow-engineer/flow",
3
+ // "@flow-engineer/flow/simulators" and "@flow-engineer/flow/flow-messaging", and the coding-agent
4
+ // skills go in .claude/skills/, with an AGENTS.md (and a CLAUDE.md importing it) pointing at them.
5
+ // `flow init` makes the same agent in the current folder. Inside a clone, `npm run new -- <name>`
6
+ // makes agents/<name>/ instead.
5
7
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
6
- import { dirname, join } from "node:path";
8
+ import { basename, dirname, join } from "node:path";
7
9
  import { fileURLToPath } from "node:url";
8
10
  const NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
9
11
  /** Throws unless `name` can be an agent's folder and package name. */
@@ -50,7 +52,7 @@ GEMINI_API_KEY=
50
52
  # Optional. The model id for that provider; empty is that provider's default.
51
53
  # CAFE_MODEL=
52
54
 
53
- # The test cloud (npx flow test --synthetic) and traces. Sign in at https://flow.engineer/observe,
55
+ # The test cloud (npx @flow-engineer/flow test --synthetic) and traces. Sign in at https://flow.engineer/observe,
54
56
  # add the agent, and on its Developers page make a CI key (oci_..., shown once); the trace key is
55
57
  # from the same site.
56
58
  FLOW_FRAMEWORK_CLOUD=https://flow.engineer/observe
@@ -65,18 +67,36 @@ A chat agent on Flow Framework (https://github.com/flow-engineer/flow), started
65
67
  - Load the skills in \`.claude/skills/\` before changing it: \`flow-framework\` to build the agent, \`flow-framework-tools\`
66
68
  for its tools and their test files, \`flow-framework-tests\` for its test scenarios.
67
69
  - \`agent/AGENTS.md\` is the agent's own prompt, not instructions for coding agents.
68
- - \`npm start\` runs it in the playground. The owner puts one model key in \`.env\` (never read or
69
- print it): \`OPENROUTER_API_KEY\`, \`OPENAI_API_KEY\`, \`ANTHROPIC_API_KEY\` or \`GEMINI_API_KEY\`.
70
- \`npm run typecheck\` checks the types, \`npm test -- --synthetic\` runs the tests in the test
71
- cloud at https://flow.engineer/observe with the owner's \`FLOW_FRAMEWORK_CLOUD_KEY\` (\`npx flow --help\`).
70
+ - The owner puts one model key in \`.env\` (never read or print it): \`OPENROUTER_API_KEY\`,
71
+ \`OPENAI_API_KEY\`, \`ANTHROPIC_API_KEY\` or \`GEMINI_API_KEY\`. Then \`npx @flow-engineer/flow chat\`
72
+ talks to it in the terminal, \`npm start\` runs it in the playground, and \`npx @flow-engineer/flow link\`
73
+ signs in, puts its other keys in \`.env\` and prints the link that reaches it from a phone while
74
+ \`npm start\` runs. \`npm run typecheck\` checks the types, \`npm test -- --synthetic\` runs the
75
+ tests in the test cloud at https://flow.engineer/observe with the owner's \`FLOW_FRAMEWORK_CLOUD_KEY\`
76
+ (\`npx @flow-engineer/flow --help\`).
77
+ - The guide for coding agents, with exit codes and next steps: https://github.com/flow-engineer/flow/blob/main/docs/agents.md
72
78
  `;
73
79
  /** Makes `<into>/<name>` (the current folder by default) and returns it. */
74
80
  export function newStandalone(name, options = {}) {
75
81
  checkName(name);
76
- const root = options.root ?? packageRoot();
77
82
  const dir = join(options.into ?? process.cwd(), name);
78
83
  if (existsSync(dir))
79
84
  throw new Error(`${dir} already exists: pick another name or remove it.`);
85
+ makeAgent(dir, name, options.root ?? packageRoot());
86
+ return dir;
87
+ }
88
+ /** What `flow init` writes into a folder; it refuses a folder that holds any of them, so nothing is overwritten. */
89
+ const MADE = ["package.json", "tsconfig.json", "src", "agent", "tests", ".env.example", "AGENTS.md", "CLAUDE.md", ".npmrc"];
90
+ /** Makes the agent in `dir` (the current folder by default), named after the folder; returns its name. */
91
+ export function initHere(dir = process.cwd(), options = {}) {
92
+ const taken = MADE.filter((f) => existsSync(join(dir, f)));
93
+ if (taken.length)
94
+ throw new Error(`${dir} has ${taken.join(", ")} already: run flow init in an empty folder, or flow new <name> to make one.`);
95
+ const name = basename(dir).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "my-agent";
96
+ makeAgent(dir, name, options.root ?? packageRoot());
97
+ return name;
98
+ }
99
+ function makeAgent(dir, name, root) {
80
100
  const cafe = join(root, "examples", "cafe");
81
101
  for (const part of ["agent", "src", "tests"])
82
102
  cpSync(join(cafe, part), join(dir, part), { recursive: true });
@@ -84,7 +104,8 @@ export function newStandalone(name, options = {}) {
84
104
  const path = join(dir, "src", file);
85
105
  const source = readFileSync(path, "utf8")
86
106
  .replace(/(["'])(\.\.\/)+src\/index\.ts\1/g, '"@flow-engineer/flow"')
87
- .replace(/(["'])(\.\.\/)+simulators\/src\/index\.ts\1/g, '"@flow-engineer/flow/simulators"');
107
+ .replace(/(["'])(\.\.\/)+simulators\/src\/index\.ts\1/g, '"@flow-engineer/flow/simulators"')
108
+ .replace(/(["'])(\.\.\/)+flow-messaging\/src\/index\.ts\1/g, '"@flow-engineer/flow/flow-messaging"');
88
109
  if (/["']\.\.\/\.\.\//.test(source))
89
110
  throw new Error(`flow: ${file} imports a file outside the agent; only Flow Framework's public entries can be rewritten.`);
90
111
  writeFileSync(path, source);
@@ -103,7 +124,12 @@ export function newStandalone(name, options = {}) {
103
124
  };
104
125
  writeFileSync(join(dir, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`);
105
126
  writeFileSync(join(dir, "tsconfig.json"), `${JSON.stringify(TSCONFIG, null, 2)}\n`);
106
- writeFileSync(join(dir, ".gitignore"), "node_modules/\n.env\n.flow/\n");
127
+ // An existing .gitignore (flow init in a fresh repository) keeps its lines and gets the missing ones.
128
+ const ignore = join(dir, ".gitignore");
129
+ const ignored = existsSync(ignore) ? readFileSync(ignore, "utf8") : "";
130
+ const missing = ["node_modules/", ".env", ".flow/"].filter((line) => !ignored.split("\n").includes(line));
131
+ if (missing.length)
132
+ writeFileSync(ignore, `${ignored}${ignored && !ignored.endsWith("\n") ? "\n" : ""}${missing.join("\n")}\n`);
107
133
  // npm install then refuses a Node older than the engines field says, before .ts files fail to load.
108
134
  writeFileSync(join(dir, ".npmrc"), "engine-strict=true\n");
109
135
  writeFileSync(join(dir, ".env.example"), ENV_EXAMPLE);
@@ -118,5 +144,4 @@ export function newStandalone(name, options = {}) {
118
144
  }
119
145
  writeFileSync(join(dir, "AGENTS.md"), agentsMd(name));
120
146
  writeFileSync(join(dir, "CLAUDE.md"), "@AGENTS.md\n");
121
- return dir;
122
147
  }
package/docs/agents.md ADDED
@@ -0,0 +1,85 @@
1
+ # Flow Framework for coding agents
2
+
3
+ You are building or changing a chat agent for a person (the owner) so they can text it from their
4
+ phone while it runs on their laptop. No tunnel, no public URL, and no key the owner copies by hand
5
+ except their model key. Every command here runs without a terminal and never reads stdin, except
6
+ `chat`, which reads the messages you pipe to it.
7
+
8
+ Always name the package: `npx @flow-engineer/flow ...`. `npx flow` on its own fetches another npm
9
+ package.
10
+
11
+ ## The flow
12
+
13
+ ```sh
14
+ npx @flow-engineer/flow init # the starter agent in this (empty) folder; flow new <name> makes a folder
15
+ npm install
16
+ # the owner puts one model key in .env (OPENROUTER_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY or GEMINI_API_KEY)
17
+ printf 'hi\nA latte please\n' | npx @flow-engineer/flow chat # talk to it locally: no account, one message per line
18
+ npx @flow-engineer/flow link --json # signs in and sets up if needed, then prints the phone link
19
+ npm start # keep it running; the owner texts it from the phone
20
+ ```
21
+
22
+ - `chat` builds the agent from `src/agent.ts` and writes as its first test person. Each reply is
23
+ printed (`agent: ...`) once the turn is over; `--json` gives one JSON line per thing it sends.
24
+ - `link` does the rest when the folder is not set up (no `FLOW_FRAMEWORK_JOIN_CODE` or
25
+ `FLOW_MESSAGING_KEY` in `.env`, or no sender for the WhatsApp or iMessage link asked for): it
26
+ runs `login` (opens the owner's browser itself and prints the link and code at once), then
27
+ `setup` (writes the keys and each open channel's sender into `.env`), then prints the link.
28
+ `--channel telegram|whatsapp|imessage` picks the channel (Telegram by default). Run it in the
29
+ agent's folder: `setup` and `link` refuse a folder with no `package.json` (`not_an_agent_folder`).
30
+ - On WhatsApp and iMessage the agent can only reply after the owner writes first (WhatsApp: within
31
+ 24 hours of their last message); it cannot message them first. `link --json` says so
32
+ (`reply_only: true`, and in `say_to_person`): tell the owner.
33
+ - The starter's `npm start` is on Telegram, plus WhatsApp and iMessage when `.env` has their senders
34
+ (`FLOW_MESSAGING_WHATSAPP_SENDER`, `FLOW_MESSAGING_IMESSAGE_SENDER`). If it was running when
35
+ `link` added one, restart it.
36
+ - Give the owner `say_to_person` from `link --json`, and the sign-in link and code from the
37
+ `pending` line while you wait.
38
+ - `npm start` connects to Flow Messaging by an outbound event stream. Messages sent while it was
39
+ stopped arrive when it starts again (for as long as Flow keeps events, 30 days by default).
40
+
41
+ ## Exit codes (login, setup, link)
42
+
43
+ | Code | Meaning | What to do |
44
+ |---|---|---|
45
+ | 0 | Done | Go on |
46
+ | 1 | Usage, not signed in, not an agent's folder, or a login URL that is not https | Fix the command, or run `next` |
47
+ | 2 | flow.engineer could not be reached, or did not answer in time (`timeout`) | Try again |
48
+ | 3 | Refused: denied, expired, a product failed, a channel not open yet, 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 |
49
+ | 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` |
50
+
51
+ `login` and `link` wait at most `--timeout` seconds (120 by default) before exit 4, and no single
52
+ request waits longer than 30 s. `login --wait` (or `link` again) goes on with the same code: no new
53
+ code and no new browser tab. flow.engineer decides when a code has expired (an approved one can be
54
+ collected a while after its `expires_in`); a waiting code that has expired is replaced by one new
55
+ sign-in, and so is a code from another login URL.
56
+
57
+ ## The `next` lines
58
+
59
+ Every error is `{"error":{"code","message","next"}}` on stdout with `--json`, and
60
+ `<message> Next: <next>` on stderr. A waiting sign-in is `{"status":"pending",...,"next":...}`.
61
+ `next` is the exact command or URL to run; when it came from flow.engineer it is the server's.
62
+ Run it, or give it to the owner when it needs them (a browser approval, a page to open). A
63
+ `slow_down` error also has `retry_after` (seconds, or `null` when the server gave none). A
64
+ trial limit's `next` is a sentence, not a command: it is printed on its own line as it came; pass
65
+ it on to the owner unchanged.
66
+
67
+ flow.engineer may add `warnings` (up to 20 lines) to any setup answer. Each is printed on its own
68
+ line on stderr; with `--json` setup's line has `warnings` (an empty list when there are none), and
69
+ an error line has it when there were any. Pass them on to the owner.
70
+
71
+ `setup` also shows the free trial: messages used per channel, and on iMessage the people it may
72
+ reach (`iMessage: 1 of 2 people`; `trial.imessage.contacts` `{limit, used}` in `--json`).
73
+
74
+ ## Keys
75
+
76
+ - Never ask the owner to paste a key into the chat, and never print, read aloud or commit `.env`.
77
+ `setup` writes the keys itself and prints only their last 4 characters.
78
+ - The model key is the owner's: they type it into `.env` themselves.
79
+ - `setup` keeps every value already in `.env` (`--replace` takes the new ones), but a name there
80
+ with no value (`cp .env.example .env`) counts as missing and is filled on its own line. Keys
81
+ flow.engineer made are written even when the rest of setup fails, since they are shown only once.
82
+ `.env` is set to mode 0600.
83
+ - The sign-in token goes only to the server that issued it, over https (http only to localhost):
84
+ with `FLOW_FRAMEWORK_LOGIN_URL` naming another server, commands stop with `signed_in_elsewhere`.
85
+ - CI never signs in: `npx @flow-engineer/flow setup --ci` says how to give it the test cloud key.
@@ -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
+ }
@@ -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
+ }