@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
@@ -32,6 +32,17 @@ export class FlowMessagingError extends Error {
32
32
  return this.status === 0 || this.status === 429 || this.status >= 500 || (this.type === "idempotency_conflict" && this.channelCode === "in_progress");
33
33
  }
34
34
  }
35
+ /**
36
+ * One of Flow's object IDs as a URL path segment: `<prefix>_` and letters, digits, `_` or `-` (Flow
37
+ * makes `<prefix>_<ULID>`). IDs come from inbound events and callers, and every request carries the
38
+ * key, so anything else (a `/`, `..`, `?`) is refused rather than sent to another path.
39
+ */
40
+ export function idSegment(prefix, id) {
41
+ if (typeof id !== "string" || !new RegExp(`^${prefix}_[A-Za-z0-9_-]+$`).test(id)) {
42
+ throw new Error(`not a Flow Messaging ${prefix}_ id: ${JSON.stringify(String(id)).slice(0, 80)}`);
43
+ }
44
+ return encodeURIComponent(id);
45
+ }
35
46
  export class Client {
36
47
  apiKey;
37
48
  baseUrl;
@@ -47,9 +58,10 @@ export class Client {
47
58
  }
48
59
  /** A file's bytes: GET /v1/files/{id} answers them or redirects to a signed URL, which fetch follows without the key. */
49
60
  async download(fileId) {
61
+ const path = `/v1/files/${idSegment("file", fileId)}`;
50
62
  if (!this.apiKey)
51
63
  throw new Error("set FLOW_MESSAGING_KEY (or pass apiKey) to reach Flow Messaging");
52
- const response = await fetch(`${this.baseUrl}/v1/files/${fileId}`, { headers: { authorization: `Bearer ${this.apiKey}`, "flow-version": API_VERSION }, signal: AbortSignal.timeout(60_000) });
64
+ const response = await fetch(this.baseUrl + path, { headers: { authorization: `Bearer ${this.apiKey}`, "flow-version": API_VERSION }, signal: AbortSignal.timeout(60_000) });
53
65
  if (!response.ok)
54
66
  throw await errorOf(response);
55
67
  return new Uint8Array(await response.arrayBuffer());
@@ -1,4 +1,4 @@
1
- import type { IncomingMessage, ServerResponse } from "node:http";
1
+ import { type IncomingMessage, type ServerResponse } from "node:http";
2
2
  import { type Agent, type Channel, type ChannelInfo, type WhatsAppFeature } from "../../src/index.ts";
3
3
  export { API_VERSION, FlowMessagingError, verifySignature } from "./client.ts";
4
4
  export interface FlowMessagingOptions {
@@ -12,19 +12,48 @@ export interface FlowMessagingOptions {
12
12
  export interface FlowChannelOptions {
13
13
  /** The Flow sender (`snd_...`) this channel starts conversations from. Default: FLOW_MESSAGING_<CHANNEL>_SENDER. */
14
14
  sender?: string;
15
- /** The agent's name for the channel. Default: whatsapp, telegram or sms. */
15
+ /** The agent's name for the channel. Default: whatsapp, telegram, imessage or sms. */
16
16
  name?: string;
17
17
  instructions?: string;
18
18
  }
19
- /** What the webhook needs of an agent. */
19
+ /** What the webhook and the stream need of an agent. */
20
20
  export type WebhookAgent = Pick<Agent, "message" | "reacted" | "undelivered">;
21
+ export interface StreamOptions {
22
+ /**
23
+ * A file that keeps the ID of the last event the agent handled, so a restart resumes after it and
24
+ * Flow replays what arrived while the agent was away (for as long as the plan keeps events, 30 days
25
+ * by default). Without it the cursor is in memory: a dropped stream still resumes, a restart starts from now.
26
+ */
27
+ cursorFile?: string;
28
+ }
29
+ /** Why the stream stopped for good: Flow refused the key or the request, so reconnecting cannot help. */
30
+ export interface StreamStop {
31
+ /** The WebSocket close code, such as 4401 (authentication) or 4403 (permission). */
32
+ code: number;
33
+ type: string;
34
+ message: string;
35
+ }
36
+ export interface StreamHandle {
37
+ /** Closes the stream and waits for the event being handled. */
38
+ close(): Promise<void>;
39
+ /** Resolves when Flow refused the stream for good (a bad or revoked key, say); never after close(). */
40
+ stopped: Promise<StreamStop>;
41
+ }
21
42
  /** Telegram as Flow sends it: an inline keyboard of up to 10 buttons (or one link), photos, Markdown, no read receipts for bots. */
22
43
  export declare const TELEGRAM: ChannelInfo;
44
+ /**
45
+ * iMessage as Flow sends it: plain text up to 9999 characters, photos, inline replies, tapbacks
46
+ * (other emoji go as emoji reactions), typing and read receipts (the whole conversation). No buttons,
47
+ * lists or templates. No reply window is set: the line only answers people who wrote to it, and a
48
+ * send it will not deliver comes back as Not delivered (outside_window).
49
+ */
50
+ export declare const IMESSAGE: ChannelInfo;
23
51
  export declare function flowMessaging(options?: FlowMessagingOptions): {
24
52
  whatsapp: (o?: FlowChannelOptions & {
25
53
  disable?: WhatsAppFeature[];
26
54
  }) => Channel;
27
55
  telegram: (o?: FlowChannelOptions) => Channel;
56
+ imessage: (o?: FlowChannelOptions) => Channel;
28
57
  sms: (o?: FlowChannelOptions) => Channel;
29
58
  webhook: (agent: WebhookAgent) => (req: IncomingMessage, res: ServerResponse) => Promise<void>;
30
59
  receive: (agent: WebhookAgent, request: {
@@ -34,4 +63,10 @@ export declare function flowMessaging(options?: FlowMessagingOptions): {
34
63
  status: number;
35
64
  body: string;
36
65
  }>;
66
+ stream: (agent: WebhookAgent, o?: StreamOptions) => StreamHandle;
67
+ start: (agent: WebhookAgent, o?: StreamOptions & {
68
+ port?: number;
69
+ }) => StreamHandle & {
70
+ mode: "webhook" | "stream";
71
+ };
37
72
  };
@@ -1,18 +1,24 @@
1
1
  // Flow Framework channels on Flow Messaging (api.flow.engineer), Flow's unified messaging API: WhatsApp,
2
- // Telegram and US SMS from senders Flow hosts, and a webhook that turns Flow's signed events into
3
- // messages for the agent.
2
+ // Telegram, iMessage and US SMS from senders Flow hosts. Events reach the agent one of two ways: the live
3
+ // stream (an outbound WebSocket, so a laptop needs no public URL or tunnel) or a webhook that checks
4
+ // Flow's signature. Both hand each event to the same handler.
4
5
  //
5
- // const fm = flowMessaging(); // FLOW_MESSAGING_KEY, FLOW_MESSAGING_WEBHOOK_SECRET
6
- // const agent = createAgent({ ..., channels: [fm.whatsapp(), fm.telegram(), fm.sms()] });
7
- // http.createServer(fm.webhook(agent)).listen(8080);
6
+ // const fm = flowMessaging(); // FLOW_MESSAGING_KEY (and FLOW_MESSAGING_WEBHOOK_SECRET for a webhook)
7
+ // const agent = createAgent({ ..., channels: [fm.whatsapp(), fm.telegram(), fm.imessage(), fm.sms()] });
8
+ // fm.start(agent); // the stream, or the webhook on PORT when a webhook secret is set
8
9
  //
9
10
  // Addresses are what Flow Framework joins people by: the phone number on WhatsApp and SMS, the numeric
10
- // user id on Telegram. A reply goes into the Flow conversation the person last wrote in; with none
11
- // known yet (a nudge, or after a restart) it starts one from the channel's sender, which Flow puts
12
- // into the open conversation if there is one. What this process learns (conversations, contacts,
11
+ // user id on Telegram, the phone number on iMessage, else the email address that is the person's
12
+ // handle there. A reply goes into the Flow conversation the person last wrote in; with none known
13
+ // yet (a nudge, or after a restart) it starts one from the channel's sender, which Flow puts into the
14
+ // open conversation if there is one. On Flow's shared iMessage sender, as on WhatsApp after 24
15
+ // hours, the agent can only answer someone who wrote first: a start to anyone else fails. What this process learns (conversations, contacts,
13
16
  // event ids) is kept in memory only.
17
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { createServer } from "node:http";
19
+ import { dirname } from "node:path";
14
20
  import { sms as smsChannel, whatsapp as whatsappChannel } from "../../src/index.js";
15
- import { Client, FlowMessagingError, verifySignature } from "./client.js";
21
+ import { Client, FlowMessagingError, idSegment, verifySignature } from "./client.js";
16
22
  export { API_VERSION, FlowMessagingError, verifySignature } from "./client.js";
17
23
  /** Telegram as Flow sends it: an inline keyboard of up to 10 buttons (or one link), photos, Markdown, no read receipts for bots. */
18
24
  export const TELEGRAM = {
@@ -26,11 +32,34 @@ export const TELEGRAM = {
26
32
  images: { captionMax: 1024 },
27
33
  readReceipts: false,
28
34
  };
35
+ /**
36
+ * iMessage as Flow sends it: plain text up to 9999 characters, photos, inline replies, tapbacks
37
+ * (other emoji go as emoji reactions), typing and read receipts (the whole conversation). No buttons,
38
+ * lists or templates. No reply window is set: the line only answers people who wrote to it, and a
39
+ * send it will not deliver comes back as Not delivered (outside_window).
40
+ */
41
+ export const IMESSAGE = {
42
+ reactions: true,
43
+ status: false,
44
+ formatting: "plain",
45
+ maxLength: 9999,
46
+ replies: true,
47
+ images: { captionMax: 1024 },
48
+ readReceipts: true,
49
+ };
29
50
  /** Flow sends a list as buttons, whose labels take at most 20 characters, and has no row descriptions. */
30
51
  const LIST_TITLE_MAX = 20;
31
52
  const MAX_BODY_BYTES = 1_000_000;
32
53
  /** Event ids remembered to drop Flow's redeliveries (deliveries are at least once). */
33
54
  const SEEN_MAX = 10_000;
55
+ /** The events the handler acts on; the stream asks for only these. */
56
+ const HANDLED = ["message.received", "message.failed", "reaction.added", "contact.deleted"];
57
+ /** The longest wait between two tries to reconnect the stream (ms). */
58
+ const STREAM_BACKOFF_MAX = 30_000;
59
+ /** Tries of one event before the stream gives up on it and goes on to the next. */
60
+ const STREAM_EVENT_TRIES = 5;
61
+ /** Close codes after which reconnecting unchanged fails again: authentication, permission, a bad request. */
62
+ const STREAM_FINAL = new Set([4401, 4403, 4400]);
34
63
  export function flowMessaging(options = {}) {
35
64
  const env = (...names) => names.map((n) => process.env[n]).find(Boolean);
36
65
  const apiKey = options.apiKey ?? env("FLOW_MESSAGING_KEY", "FLOW_MESSAGING_API_KEY") ?? "";
@@ -54,7 +83,7 @@ export function flowMessaging(options = {}) {
54
83
  const known = flowConversation.get(key(channel, to));
55
84
  if (known || !messageId)
56
85
  return known;
57
- const found = (await client.get(`/v1/messages/${messageId}`)).conversation;
86
+ const found = (await client.get(`/v1/messages/${idSegment("msg", messageId)}`)).conversation;
58
87
  flowConversation.set(key(channel, to), found);
59
88
  return found;
60
89
  }
@@ -63,6 +92,8 @@ export function flowMessaging(options = {}) {
63
92
  return { contact: to };
64
93
  if (kind === "telegram")
65
94
  return { telegram_user_id: to };
95
+ if (kind === "imessage" && !/^\+?[0-9]{7,15}$/.test(to))
96
+ return { handle: to }; // an email address
66
97
  return { phone: to.startsWith("+") ? to : `+${to}` };
67
98
  }
68
99
  /** One message: into the person's conversation, or a new one from the channel's sender. */
@@ -71,7 +102,7 @@ export function flowMessaging(options = {}) {
71
102
  const conversation = await conversationFor(route.name, to, about);
72
103
  let sent;
73
104
  if (conversation)
74
- sent = await client.post(`/v1/conversations/${conversation}/messages`, { content, ...(replyTo ? { reply_to: replyTo } : {}) });
105
+ sent = await client.post(`/v1/conversations/${idSegment("conv", conversation)}/messages`, { content, ...(replyTo ? { reply_to: replyTo } : {}) });
75
106
  else {
76
107
  if (!route.sender)
77
108
  throw new Error(`no conversation with them on ${route.name} yet, and no sender to start one (set FLOW_MESSAGING_${route.kind.toUpperCase()}_SENDER)`);
@@ -83,14 +114,20 @@ export function flowMessaging(options = {}) {
83
114
  throw error instanceof FlowMessagingError ? new Error(reason(error)) : error;
84
115
  }
85
116
  }
86
- /** Typing and read receipts are a courtesy: Flow's spec says to ignore their failures. */
117
+ /**
118
+ * Typing and read receipts are a courtesy: Flow's spec says to ignore their failures. Outside the
119
+ * window (iMessage and WhatsApp until the person writes) they are expected to be refused, so that
120
+ * is not logged; any other failure is, once.
121
+ */
87
122
  async function courtesy(route, to, action, body, messageId) {
88
123
  try {
89
124
  const conversation = await conversationFor(route.name, to, messageId);
90
125
  if (conversation)
91
- await client.post(`/v1/conversations/${conversation}/${action}`, body, 1);
126
+ await client.post(`/v1/conversations/${idSegment("conv", conversation)}/${action}`, body, 1);
92
127
  }
93
128
  catch (error) {
129
+ if (error instanceof FlowMessagingError && error.type === "outside_window")
130
+ return;
94
131
  console.warn(`@flow-engineer/flow/flow-messaging: ${action} on ${route.name} failed (${error instanceof FlowMessagingError ? error.type : "error"})`);
95
132
  }
96
133
  }
@@ -122,7 +159,7 @@ export function flowMessaging(options = {}) {
122
159
  const all = [];
123
160
  let after = "";
124
161
  for (;;) {
125
- const page = await client.get(`/v1/templates?sender=${sender}&status=approved&limit=100${after ? `&after=${after}` : ""}`);
162
+ const page = await client.get(`/v1/templates?${new URLSearchParams({ sender: idSegment("snd", sender), status: "approved", limit: "100", ...(after ? { after } : {}) })}`);
126
163
  all.push(...page.data);
127
164
  if (!page.has_more || !page.data.length)
128
165
  break;
@@ -168,12 +205,23 @@ export function flowMessaging(options = {}) {
168
205
  }
169
206
  /** A Telegram bot connected to Flow. Flow Framework has no Telegram kind yet, so it is a custom channel. */
170
207
  function telegram(o = {}) {
171
- const route = register("telegram", o);
208
+ return custom(register("telegram", o), TELEGRAM, o);
209
+ }
210
+ /**
211
+ * An iMessage line connected to Flow (Flow's shared one by default). Flow Framework has no iMessage
212
+ * kind yet, so it is a custom channel. Its addresses are not marked as phone numbers, since a handle
213
+ * may be an email address: the person on iMessage is their own, not joined with their WhatsApp or SMS.
214
+ */
215
+ function imessage(o = {}) {
216
+ return custom(register("imessage", o), IMESSAGE, o);
217
+ }
218
+ /** A Flow channel Flow Framework has no kind for: text, quotes, buttons and links as `info` allows, photos, reactions, typing, read receipts. */
219
+ function custom(route, info, o) {
172
220
  const p = provider(route);
173
221
  return {
174
222
  name: route.name,
175
223
  kind: "custom",
176
- info: TELEGRAM,
224
+ info,
177
225
  instructions: o.instructions,
178
226
  send: (to, outbound) => {
179
227
  switch (outbound.type) {
@@ -181,17 +229,19 @@ export function flowMessaging(options = {}) {
181
229
  return p.sendMessage(to, {
182
230
  text: outbound.text,
183
231
  ...(outbound.replyTo ? { replyTo: outbound.replyTo } : {}),
184
- ...(outbound.buttons ? { buttons: outbound.buttons } : {}),
185
- ...(outbound.link ? { link: outbound.link } : {}),
186
- ...(outbound.image ? { image: outbound.image } : {}),
232
+ ...(outbound.buttons && info.buttons ? { buttons: outbound.buttons } : {}),
233
+ ...(outbound.link && info.linkButton ? { link: outbound.link } : {}),
234
+ ...(outbound.image && info.images ? { image: outbound.image } : {}),
187
235
  });
188
236
  case "reaction":
189
237
  return p.react(to, outbound.messageId, outbound.emoji);
190
238
  case "typing":
191
239
  return p.typing(to, outbound.on);
240
+ case "read":
241
+ return info.readReceipts ? p.markRead(to, outbound.messageIds) : undefined; // Telegram bots cannot send them
192
242
  case "template":
193
- throw new Error("Telegram has no templates");
194
- // read: Telegram bots cannot send read receipts; status: Telegram has no status line.
243
+ throw new Error(`${route.kind === "telegram" ? "Telegram" : "iMessage"} has no templates`);
244
+ // status: neither has a status line.
195
245
  }
196
246
  },
197
247
  };
@@ -207,14 +257,15 @@ export function flowMessaging(options = {}) {
207
257
  const known = contacts.get(contactId);
208
258
  if (known)
209
259
  return known;
210
- const contact = await client.get(`/v1/contacts/${contactId}`);
211
- const address = (route.kind === "telegram" ? contact.address?.telegram_user_id : contact.address?.phone) ?? contactId;
260
+ const contact = await client.get(`/v1/contacts/${idSegment("ct", contactId)}`);
261
+ const a = contact.address;
262
+ const address = (route.kind === "telegram" ? a?.telegram_user_id : route.kind === "imessage" ? (a?.phone ?? a?.handle) : a?.phone) ?? contactId;
212
263
  contacts.set(contactId, address);
213
264
  return address;
214
265
  }
215
266
  async function quoted(id) {
216
267
  try {
217
- const m = await client.get(`/v1/messages/${id}`);
268
+ const m = await client.get(`/v1/messages/${idSegment("msg", id)}`);
218
269
  const text = textOf(m.content);
219
270
  return text ? { text, fromAgent: m.direction === "out" } : undefined;
220
271
  }
@@ -306,6 +357,21 @@ export function flowMessaging(options = {}) {
306
357
  agent.undelivered(conversationId, text, reason({ type, channelCode: m.error?.channel_code, message: m.error?.message ?? "The channel did not take it.", retryable: type === "api_error" || type === "rate_limited" }));
307
358
  }
308
359
  }
360
+ /** Hands one event to the agent once: a redelivery (or the same event by webhook and stream) is dropped. */
361
+ async function once(agent, event) {
362
+ if (seen.has(event.id))
363
+ return;
364
+ let handled = handling.get(event.id);
365
+ if (!handled) {
366
+ handled = handle(agent, event).finally(() => handling.delete(event.id));
367
+ handling.set(event.id, handled);
368
+ }
369
+ await handled;
370
+ seen.add(event.id);
371
+ if (seen.size > SEEN_MAX)
372
+ seen.delete(seen.values().next().value);
373
+ }
374
+ const failed = (event, error) => console.warn(`@flow-engineer/flow/flow-messaging: event ${event.id} (${event.type}) failed: ${error instanceof FlowMessagingError ? error.type : error instanceof Error ? error.name : "error"}`);
309
375
  /**
310
376
  * One delivery: checks Flow-Signature, drops a redelivered event, hands the event to the agent.
311
377
  * 2xx tells Flow it arrived; a failure answers 500, so Flow delivers it again.
@@ -327,27 +393,14 @@ export function flowMessaging(options = {}) {
327
393
  }
328
394
  if (typeof event?.id !== "string" || typeof event.type !== "string")
329
395
  return { status: 400, body: '{"error":"not an event"}' };
330
- if (seen.has(event.id))
331
- return { status: 200, body: "{}" };
332
- let handled = handling.get(event.id);
333
- if (!handled) {
334
- handled = handle(agent, event);
335
- handling.set(event.id, handled);
336
- }
337
396
  try {
338
- await handled;
339
- seen.add(event.id);
340
- if (seen.size > SEEN_MAX)
341
- seen.delete(seen.values().next().value);
397
+ await once(agent, event);
342
398
  return { status: 200, body: "{}" };
343
399
  }
344
400
  catch (error) {
345
- console.warn(`@flow-engineer/flow/flow-messaging: event ${event.id} (${event.type}) failed: ${error instanceof FlowMessagingError ? error.type : error instanceof Error ? error.name : "error"}`);
401
+ failed(event, error);
346
402
  return { status: 500, body: '{"error":"failed"}' };
347
403
  }
348
- finally {
349
- handling.delete(event.id);
350
- }
351
404
  }
352
405
  /** A Node http handler for the webhook endpoint: give it the requests to the URL Flow posts to. */
353
406
  function webhook(agent) {
@@ -369,7 +422,156 @@ export function flowMessaging(options = {}) {
369
422
  answer(result.status, result.body);
370
423
  };
371
424
  }
372
- return { whatsapp, telegram, sms, webhook, receive };
425
+ // ---- the stream ----
426
+ /**
427
+ * The live event stream (GET /v1/stream): an outbound WebSocket, so the agent needs no public URL.
428
+ * Events are handled one at a time, in log order, by the webhook's handler. There is no ack frame
429
+ * for events: the stream resumes from `after`, so an event counts as acknowledged once the agent
430
+ * has handled it and the cursor (in memory, or `cursorFile`) has moved past it. A failed event, a
431
+ * dropped socket or Flow's `reconnect` frame reconnects from that cursor, and Flow replays what
432
+ * came after it: at once the first time, then with a backoff up to 30 s, or after Flow's
433
+ * retry_after. An event that fails 5 times in a row is passed over, so it cannot hold up every
434
+ * later one. A refused key or request stops the stream (`stopped`). Replies go over HTTP
435
+ * (with Idempotency-Keys), so they do not depend on the stream being up.
436
+ */
437
+ function stream(agent, o = {}) {
438
+ if (!apiKey)
439
+ throw new Error("@flow-engineer/flow/flow-messaging: set FLOW_MESSAGING_KEY (or pass apiKey) to open the stream");
440
+ let cursor = o.cursorFile ? readCursor(o.cursorFile) : undefined;
441
+ let socket;
442
+ let timer;
443
+ let closed = false;
444
+ let tries = 0;
445
+ let failing;
446
+ let generation = 0;
447
+ let queue = Promise.resolve();
448
+ let stop;
449
+ const stopped = new Promise((resolve) => (stop = resolve));
450
+ const moveCursor = (id) => {
451
+ cursor = id;
452
+ if (o.cursorFile)
453
+ writeCursor(o.cursorFile, id);
454
+ };
455
+ function connect() {
456
+ const mine = ++generation;
457
+ const url = new URL(`${client.baseUrl.replace(/^http/, "ws")}/v1/stream`);
458
+ if (cursor)
459
+ url.searchParams.set("after", cursor);
460
+ for (const type of HANDLED)
461
+ url.searchParams.append("type", type);
462
+ // Node's WebSocket cannot set headers: the key goes as a subprotocol beside "flow", which Flow selects.
463
+ const ws = new WebSocket(url, ["flow", `flow.key.${apiKey}`]);
464
+ socket = ws;
465
+ let refusal;
466
+ ws.onopen = () => {
467
+ if (!failing)
468
+ tries = 0; // a failing event keeps its backoff across reconnects
469
+ };
470
+ ws.onmessage = (message) => {
471
+ let frame;
472
+ try {
473
+ frame = JSON.parse(String(message.data));
474
+ }
475
+ catch {
476
+ return;
477
+ }
478
+ if (frame.type === "event" && frame.event && typeof frame.event.id === "string") {
479
+ const event = frame.event;
480
+ queue = queue.then(() => take(event, mine, ws));
481
+ }
482
+ else if (frame.type === "error" && !frame.ref)
483
+ refusal = frame.error ?? {};
484
+ };
485
+ ws.onerror = () => { }; // a close always follows
486
+ ws.onclose = ({ code, reason }) => {
487
+ if (socket === ws)
488
+ socket = undefined;
489
+ if (closed || mine !== generation)
490
+ return;
491
+ if (STREAM_FINAL.has(code)) {
492
+ if (code === 4400 && refusal?.param === "after" && cursor) {
493
+ // The cursor's event has left Flow's log (past retention): start again from now.
494
+ console.warn("@flow-engineer/flow/flow-messaging: the last handled event is no longer in Flow's log; the stream starts again from now");
495
+ moveCursor(undefined);
496
+ return void later(0);
497
+ }
498
+ closed = true;
499
+ const type = refusal?.type ?? (code === 4401 ? "authentication" : code === 4403 ? "permission" : "invalid_request");
500
+ const why = refusal?.message ?? reason ?? "Flow Messaging refused the stream.";
501
+ console.warn(`@flow-engineer/flow/flow-messaging: the stream stopped: ${type} (${code})`);
502
+ return void stop({ code, type, message: why });
503
+ }
504
+ // 1012 follows a reconnect frame: at once. 4429 and 45xx say when (retry_after). Else back off.
505
+ if (code === 1012)
506
+ return void later(0);
507
+ if (refusal?.retry_after !== undefined)
508
+ return void later(refusal.retry_after * 1000);
509
+ later(Math.min(STREAM_BACKOFF_MAX, tries === 0 ? 0 : 1000 * 2 ** (tries - 1)));
510
+ tries++;
511
+ };
512
+ }
513
+ function later(ms) {
514
+ if (closed)
515
+ return;
516
+ timer = setTimeout(connect, ms);
517
+ }
518
+ /** Handles one event of connection `mine`; one that fails makes the stream reconnect from the cursor. */
519
+ async function take(event, mine, ws) {
520
+ if (closed || mine !== generation)
521
+ return; // a newer connection replays it from the cursor
522
+ try {
523
+ await once(agent, event);
524
+ }
525
+ catch (error) {
526
+ failed(event, error);
527
+ const times = failing?.id === event.id ? failing.times + 1 : 1;
528
+ if (times < STREAM_EVENT_TRIES) {
529
+ failing = { id: event.id, times };
530
+ if (mine === generation)
531
+ ws.close(1000, "event failed; resuming");
532
+ return;
533
+ }
534
+ console.warn(`@flow-engineer/flow/flow-messaging: event ${event.id} failed ${times} times; going on without it (it stays in Flow's log, GET /v1/events)`);
535
+ }
536
+ failing = undefined;
537
+ tries = 0;
538
+ moveCursor(event.id);
539
+ }
540
+ connect();
541
+ return {
542
+ stopped,
543
+ async close() {
544
+ closed = true;
545
+ clearTimeout(timer);
546
+ socket?.close(1000, "closing");
547
+ await queue;
548
+ },
549
+ };
550
+ }
551
+ /**
552
+ * Starts receiving events: the webhook on `port` (default PORT, else 8080) when a webhook secret is
553
+ * set, which means Flow posts to a public URL; otherwise the stream, which needs no URL.
554
+ */
555
+ function start(agent, o = {}) {
556
+ if (!secrets.length)
557
+ return { mode: "stream", ...stream(agent, o) };
558
+ const server = createServer(webhook(agent)).listen(o.port ?? Number(process.env.PORT ?? 8080));
559
+ return { mode: "webhook", stopped: new Promise(() => { }), close: () => new Promise((resolve) => server.close(() => resolve())) };
560
+ }
561
+ return { whatsapp, telegram, imessage, sms, webhook, receive, stream, start };
562
+ }
563
+ function readCursor(file) {
564
+ try {
565
+ const id = readFileSync(file, "utf8").trim();
566
+ return /^evt_[A-Za-z0-9_]+$/.test(id) ? id : undefined;
567
+ }
568
+ catch {
569
+ return undefined;
570
+ }
571
+ }
572
+ function writeCursor(file, id) {
573
+ mkdirSync(dirname(file), { recursive: true });
574
+ writeFileSync(file, id ? `${id}\n` : "");
373
575
  }
374
576
  /** The text of a message's content, as the agent knew it. */
375
577
  function textOf(content) {
@@ -0,0 +1,54 @@
1
+ import { type Tool } from "../../src/tools.ts";
2
+ export interface McpToolsOptions {
3
+ /** The server's name in mcp.lock.json. */
4
+ name: string;
5
+ /** Its Streamable HTTP endpoint. */
6
+ url: string;
7
+ /** Sent with every request, such as `authorization`. Keep keys in the environment, never in the lock. */
8
+ headers?: Record<string, string>;
9
+ /** Only these of its tools (default: every tool in the lock). */
10
+ only?: string[];
11
+ /** Put before each tool's name, to keep two servers' tools apart (default: none). */
12
+ prefix?: string;
13
+ /** Take the server's tools as they are now, with no lock (default false). */
14
+ live?: boolean;
15
+ /** Default: mcp.lock.json in the current folder (the agent's, for flow commands). */
16
+ lockFile?: string;
17
+ }
18
+ /** A tool as the server described it, as tools/list sends it. */
19
+ export interface McpDescriptor {
20
+ name: string;
21
+ title?: string;
22
+ description?: string;
23
+ inputSchema: Record<string, unknown>;
24
+ outputSchema?: Record<string, unknown>;
25
+ annotations?: {
26
+ title?: string;
27
+ readOnlyHint?: boolean;
28
+ destructiveHint?: boolean;
29
+ idempotentHint?: boolean;
30
+ openWorldHint?: boolean;
31
+ };
32
+ }
33
+ export interface McpLock {
34
+ servers: Record<string, {
35
+ url: string;
36
+ tools: Record<string, {
37
+ fingerprint: string;
38
+ descriptor: McpDescriptor;
39
+ }>;
40
+ }>;
41
+ }
42
+ export declare const RECHECK_MS: number;
43
+ /** Set by `flow mcp update`: mcpTools writes the server's current tools into the lock. */
44
+ export declare const UPDATE_ENV = "CHINWAG_MCP_UPDATE";
45
+ export declare function mcpTools(options: McpToolsOptions): Promise<Tool[]>;
46
+ /** The fingerprint of what a model and a host see of a tool: any change to it is a change. */
47
+ export declare function fingerprint(d: McpDescriptor): string;
48
+ export declare function readLock(lockFile: string): McpLock;
49
+ /**
50
+ * A model's placeholder for an argument it did not mean (null, "" or []) is left out when the schema
51
+ * does not require it, also inside objects: servers' validation refused them (Flow, 6 Oct 2026).
52
+ * false and 0 are real values and stay.
53
+ */
54
+ export declare function withoutPlaceholders(value: Record<string, unknown>, schema: Record<string, unknown>): Record<string, unknown>;