@flow-engineer/flow 0.2.3 → 0.3.0

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.
@@ -1,10 +1,13 @@
1
- // `flow new <name>`: makes ./<name>/, an agent of its own started from the café example (Pip),
1
+ // `flow new <name>`: makes ./<name>/, an agent of its own started from the café example,
2
2
  // that depends on the @flow-engineer/flow package. Its imports become "@flow-engineer/flow",
3
3
  // "@flow-engineer/flow/simulators" and "@flow-engineer/flow/flow-messaging", and the coding-agent
4
4
  // skills go in .claude/skills/, with an AGENTS.md (and a CLAUDE.md importing it) pointing at them.
5
5
  // `flow init` makes the same agent in the current folder. Inside a clone, `npm run new -- <name>`
6
6
  // makes agents/<name>/ instead. `--display-name` is what people call the agent ("FlatX agent"), kept
7
7
  // as package.json's displayName: the message a phone sends to reach it is "Hi <it> (ref <code>)".
8
+ // The café's agent is Pip; the display name takes Pip's place in its prompt (agent/AGENTS.md), and
9
+ // its playground and MCP server read the name from package.json. `flow link --display-name` (and
10
+ // `setup`) renames an agent made already, the same way (setDisplayName).
8
11
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
9
12
  import { basename, dirname, join } from "node:path";
10
13
  import { fileURLToPath } from "node:url";
@@ -66,13 +69,15 @@ FLOW_FRAMEWORK_TRACES_KEY=
66
69
  `;
67
70
  const agentsMd = (name) => `# ${name}
68
71
 
69
- A chat agent on Flow Framework (https://github.com/flow-engineer/flow), started from its café example (Pip).
72
+ A chat agent on Flow Framework (https://github.com/flow-engineer/flow), started from its café example.
70
73
 
71
74
  - Load the skills in \`.claude/skills/\` before changing it: \`flow-framework\` to build the agent (and its MCP
72
75
  server and CLI), \`flow-framework-tools\` for its tools and their test files, \`flow-framework-tests\` for its test
73
76
  scenarios, \`flow-framework-context\` to gather what the tools are written from out of an existing
74
77
  codebase, and \`flow-framework-auth\` for sign-in.
75
- - \`agent/AGENTS.md\` is the agent's own prompt, not instructions for coding agents.
78
+ - \`agent/AGENTS.md\` is the agent's own prompt, not instructions for coding agents. What people call
79
+ the agent is package.json's \`displayName\`; \`npx @flow-engineer/flow link --display-name <name>\`
80
+ changes it there and in that prompt.
76
81
  - The model key goes in \`.env\` (never read or print it): \`npx @flow-engineer/flow link\` (or \`setup\`)
77
82
  writes \`FLOW_KEY\`, the agent's one Flow key, which calls models on Flow's free trial credit, so no provider key is needed; a key the owner types
78
83
  (\`OPENROUTER_API_KEY\`, \`OPENAI_API_KEY\`, \`ANTHROPIC_API_KEY\` or \`GEMINI_API_KEY\`) wins over it. \`npm start\` runs it in the playground,
@@ -97,6 +102,39 @@ export function displayNameOf(given, packageName) {
97
102
  throw new Error(`--display-name is at most ${MAX_DISPLAY_NAME} characters, such as "FlatX agent".`);
98
103
  return name || packageName.replace(/-/g, " ");
99
104
  }
105
+ /** The café's name for its agent, in its prompt; a new agent's display name takes its place. */
106
+ const TEMPLATE_NAME = "Pip";
107
+ /** Puts `to` in place of each of `from` (whole words, the longest first, in one pass) in the agent's prompt; true when it changed. */
108
+ function renameInPrompt(dir, from, to) {
109
+ const file = join(dir, "agent", "AGENTS.md");
110
+ const names = [...new Set(from.filter((n) => n && n !== to))].sort((a, b) => b.length - a.length);
111
+ if (!names.length || !existsSync(file))
112
+ return false;
113
+ const text = readFileSync(file, "utf8");
114
+ const word = new RegExp(`(?<![\\p{L}\\p{N}_])(?:${names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})(?![\\p{L}\\p{N}_])`, "gu");
115
+ const renamed = text.replace(word, () => to);
116
+ if (renamed === text)
117
+ return false;
118
+ writeFileSync(file, renamed);
119
+ return true;
120
+ }
121
+ /**
122
+ * Renames the agent in `dir`: package.json's displayName, and in its prompt the old display name
123
+ * (and the café's Pip) become the new one. Returns the name and whether the prompt changed (a prompt
124
+ * rewritten by hand may name neither).
125
+ */
126
+ export function setDisplayName(dir, given) {
127
+ const file = join(dir, "package.json");
128
+ const pkg = JSON.parse(readFileSync(file, "utf8"));
129
+ const displayName = displayNameOf(given, pkg.name ?? folderName(dir));
130
+ const before = typeof pkg.displayName === "string" ? pkg.displayName : "";
131
+ if (before !== displayName) {
132
+ // displayName stays right after name, where flow init puts it.
133
+ const { name, displayName: _, ...rest } = pkg;
134
+ writeFileSync(file, `${JSON.stringify({ ...(name !== undefined ? { name } : {}), displayName, ...rest }, null, 2)}\n`);
135
+ }
136
+ return { displayName, prompt: renameInPrompt(dir, [before, TEMPLATE_NAME], displayName) };
137
+ }
100
138
  /** Makes `<into>/<name>` (the current folder by default) and returns it. */
101
139
  export function newStandalone(name, options = {}) {
102
140
  checkName(name);
@@ -126,6 +164,7 @@ function makeAgent(dir, name, displayName, root) {
126
164
  const cafe = join(root, "examples", "cafe");
127
165
  for (const part of ["agent", "src", "tests"])
128
166
  cpSync(join(cafe, part), join(dir, part), { recursive: true });
167
+ renameInPrompt(dir, [TEMPLATE_NAME], displayName);
129
168
  for (const file of readdirSync(join(dir, "src"), { recursive: true, encoding: "utf8" }).filter((f) => f.endsWith(".ts"))) {
130
169
  const path = join(dir, "src", file);
131
170
  const source = readFileSync(path, "utf8")
@@ -134,8 +173,8 @@ function makeAgent(dir, name, displayName, root) {
134
173
  .replace(/(["'])(\.\.\/)+flow-messaging\/src\/index\.ts\1/g, '"@flow-engineer/flow/flow-messaging"')
135
174
  .replace(/(["'])(\.\.\/)+mcp\/src\/index\.ts\1/g, '"@flow-engineer/flow/mcp"')
136
175
  .replace(/(["'])(\.\.\/)+cli\/src\/index\.ts\1/g, '"@flow-engineer/flow/cli"');
137
- // The CLI is the agent's own command, named after it.
138
- const text = file === "cli.ts" ? source.replace(/name: "cafe"/, `name: "${name}"`).replace(/CAFE_SERVER/g, `${name.toUpperCase().replace(/-/g, "_")}_SERVER`) : source;
176
+ // The CLI is the agent's own command, named after it (and so is its server's setting, which mcp.ts names too).
177
+ const text = (file === "cli.ts" ? source.replace(/name: "cafe"/, `name: "${name}"`) : source).replace(/CAFE_SERVER/g, `${name.toUpperCase().replace(/-/g, "_")}_SERVER`);
139
178
  if (/["']\.\.\/\.\.\//.test(text))
140
179
  throw new Error(`flow: ${file} imports a file outside the agent; only Flow Framework's public entries can be rewritten.`);
141
180
  writeFileSync(path, text);
@@ -1,4 +1,4 @@
1
- import type { Attachment, ChannelInfo, ChannelSummary, Incoming, ToolSpec } from "../../src/index.ts";
1
+ import type { Attachment, ChannelInfo, ChannelSummary, Incoming, Outbound, Template, ToolSpec } from "../../src/index.ts";
2
2
  /** One graded expect item of a run. */
3
3
  export interface Check {
4
4
  kind: "outcome" | "judge";
@@ -35,6 +35,8 @@ export interface OpenSession {
35
35
  resume?: boolean;
36
36
  /** The client opens one new session for the unfinished runs when this cloud says session_gone. */
37
37
  reopen?: boolean;
38
+ /** The client plays business runs (open, event, nudge, advance, close). */
39
+ business?: boolean;
38
40
  /** Play only these runs, numbered as given: a re-open after session_gone. Sent only to a cloud whose Opened said reopen,
39
41
  * and only when some run finished: the cloud checks it against the suite's files it already stores. */
40
42
  only?: RunKey[];
@@ -67,6 +69,8 @@ export interface Opened {
67
69
  resume?: boolean;
68
70
  /** This cloud answers session_gone for a session it lost, and plays `only` on an open. Absent on an older cloud. */
69
71
  reopen?: boolean;
72
+ /** This cloud plays business runs; the client sent `business`. Absent on an older cloud. */
73
+ business?: boolean;
70
74
  }
71
75
  export interface UploadFile {
72
76
  hash: string;
@@ -98,7 +102,101 @@ export type Command = {
98
102
  | {
99
103
  type: "failed";
100
104
  error: string;
105
+ } | BusinessCommand;
106
+ /**
107
+ * A business run's commands. `step` numbers the run's business commands from 1 in the order the
108
+ * cloud sends them (`open` is 0; a step with `at:` is an `advance` and then its own command); its
109
+ * StepAnswer carries the same number. The client treats it as opaque.
110
+ */
111
+ export type BusinessCommand =
112
+ /** A run starts: build its agent with its clock at `now` (epoch ms). The person is `address` on `channelName`;
113
+ * `realTools` run the agent's own code in place of the cloud's simulation. */
114
+ {
115
+ type: "open";
116
+ conversationId: string;
117
+ channelName: string;
118
+ channel: ChannelInfo;
119
+ address: string;
120
+ now: number;
121
+ realTools?: string[];
122
+ }
123
+ /** The business's backend sends an event: POST `path` (default /v1/events) to the agent's own routes with this
124
+ * envelope as the body, signed with the run's test secret (`signature` bad: with another secret; missing: no header). */
125
+ | {
126
+ type: "event";
127
+ conversationId: string;
128
+ step: number;
129
+ envelope: FlowEventEnvelope;
130
+ path?: string;
131
+ signature?: "bad" | "missing";
132
+ }
133
+ /** The business nudges the run's person. */
134
+ | {
135
+ type: "nudge";
136
+ conversationId: string;
137
+ step: number;
138
+ nudge: BusinessNudge;
139
+ }
140
+ /** Time passes: the clock moves to `to` (epoch ms), a minute at a time, and what came due runs at each step. */
141
+ | {
142
+ type: "advance";
143
+ conversationId: string;
144
+ step: number;
145
+ to: number;
146
+ }
147
+ /** The run is over: its agent is dropped. */
148
+ | {
149
+ type: "close";
150
+ conversationId: string;
101
151
  };
152
+ /** A Flow event as a business's backend sends it: one envelope for every type. */
153
+ export interface FlowEventEnvelope {
154
+ /** Unique per event; the agent processes one id once. */
155
+ id: string;
156
+ type: string;
157
+ /** ISO 8601. */
158
+ occurredAt: string;
159
+ /** What it is about: "user:<id>", "listing:<slug>". */
160
+ subject: string;
161
+ /** How binding a send it asks for, e.g. critical, standard or optional; the agent decides what each means. */
162
+ delivery?: string;
163
+ data: Record<string, unknown>;
164
+ }
165
+ /** A nudge as a scenario writes it; `to` is the run's person. */
166
+ export interface BusinessNudge {
167
+ text?: string;
168
+ prompt?: string;
169
+ template?: Template;
170
+ respectQuietHours?: boolean;
171
+ /** Epoch ms. */
172
+ expiresAt?: number;
173
+ idempotencyKey?: string;
174
+ }
175
+ /** What one business step came to. */
176
+ export interface StepAnswer {
177
+ conversationId: string;
178
+ step: number;
179
+ /** The agent's HTTP answer to an event. */
180
+ answer?: {
181
+ status: number;
182
+ body: unknown;
183
+ };
184
+ /** A nudge's result. */
185
+ nudge?: {
186
+ status: string;
187
+ reason?: string;
188
+ };
189
+ /** The run's clock when the step settled, epoch ms. */
190
+ at: number;
191
+ /** The step could not be played on the agent's side (the agent threw, no such route ...). */
192
+ error?: string;
193
+ }
194
+ /** A business run's outbound: what the agent sent, and when on the run's clock. */
195
+ export interface BusinessOutbound {
196
+ conversationId: string;
197
+ outbound: Outbound;
198
+ at: number;
199
+ }
102
200
  /**
103
201
  * A person's message as it travels: a scenario's file goes by its path in the tests folder, and the
104
202
  * agent's side reads it there, so the file never leaves the developer's machine.
@@ -13,6 +13,15 @@
13
13
  // says idempotency, a repeat of a key is the stored answer and the client may send it again.
14
14
  // POST /api/sessions/:id/receipts Receipt → { ok }
15
15
  // POST /api/sessions/:id/drained {} → { ok } flush before the terminal command
16
+ // POST /api/sessions/:id/steps StepAnswer → { ok } a business run's step done (below)
17
+ //
18
+ // Business runs (both sides said `business`): a scenario the business starts, with an event its
19
+ // backend sends, a nudge, or time passing on the test's clock. Each such run gets an agent of its
20
+ // own on the agent's side, built with a clock the commands move (its own `now`, no schedule timer),
21
+ // and the agent's own channels, whose sends come to the cloud as an outbound with `at`, the run's
22
+ // clock. The cloud sends `open` first, then any of `message`, `reaction`, `event`, `nudge`,
23
+ // `advance`, and `close` when the run is over. `open`, `event`, `nudge` and `advance` are each
24
+ // answered by one StepAnswer once the agent has settled: everything they set off has been sent.
16
25
  //
17
26
  // When Opened says resume, every command on the stream carries `id: N` (1, 2, 3, ... per session).
18
27
  // A stream that drops, or sits silent past the idle watch, is opened again with Last-Event-ID set
@@ -21,6 +21,15 @@ declare const expectedCall: z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
21
21
  tool: z.ZodString;
22
22
  args: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
23
23
  }, z.core.$strict>]>;
24
+ /** A wait's length in ms, or null when it is not one. */
25
+ export declare function waitMs(text: string): number | null;
26
+ /** One thing the agent sent (`expect.sent`), or must not have (`expect.not_sent`; `{}` is anything at all). */
27
+ declare const sentItem: z.ZodObject<{
28
+ at: z.ZodOptional<z.ZodString>;
29
+ template: z.ZodOptional<z.ZodString>;
30
+ phrased: z.ZodOptional<z.ZodLiteral<true>>;
31
+ text: z.ZodOptional<z.ZodString>;
32
+ }, z.core.$strict>;
24
33
  declare const scenarioSchema: z.ZodObject<{
25
34
  id: z.ZodString;
26
35
  title: z.ZodOptional<z.ZodString>;
@@ -34,12 +43,13 @@ declare const scenarioSchema: z.ZodObject<{
34
43
  timezone: z.ZodOptional<z.ZodString>;
35
44
  now: z.ZodOptional<z.ZodPreprocess<z.ZodString, unknown>>;
36
45
  person: z.ZodObject<{
46
+ address: z.ZodOptional<z.ZodString>;
37
47
  persona: z.ZodString;
38
48
  goal: z.ZodString;
39
49
  knows: z.ZodOptional<z.ZodArray<z.ZodString>>;
40
50
  }, z.core.$strip>;
41
- opening: z.ZodArray<z.ZodObject<{
42
- say: z.ZodDefault<z.ZodString>;
51
+ opening: z.ZodArray<z.ZodPipe<z.ZodObject<{
52
+ say: z.ZodOptional<z.ZodString>;
43
53
  attach: z.ZodOptional<z.ZodObject<{
44
54
  kind: z.ZodEnum<{
45
55
  audio: "audio";
@@ -49,7 +59,120 @@ declare const scenarioSchema: z.ZodObject<{
49
59
  file: z.ZodString;
50
60
  }, z.core.$strip>>;
51
61
  after_ms: z.ZodOptional<z.ZodNumber>;
52
- }, z.core.$strip>>;
62
+ event: z.ZodOptional<z.ZodObject<{
63
+ id: z.ZodOptional<z.ZodString>;
64
+ type: z.ZodString;
65
+ occurredAt: z.ZodOptional<z.ZodString>;
66
+ subject: z.ZodString;
67
+ delivery: z.ZodOptional<z.ZodString>;
68
+ data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
69
+ }, z.core.$strict>>;
70
+ path: z.ZodOptional<z.ZodString>;
71
+ signature: z.ZodOptional<z.ZodEnum<{
72
+ bad: "bad";
73
+ missing: "missing";
74
+ }>>;
75
+ answer: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodObject<{
76
+ status: z.ZodNumber;
77
+ field: z.ZodOptional<z.ZodString>;
78
+ }, z.core.$strict>]>>;
79
+ nudge: z.ZodOptional<z.ZodObject<{
80
+ text: z.ZodOptional<z.ZodString>;
81
+ prompt: z.ZodOptional<z.ZodString>;
82
+ template: z.ZodOptional<z.ZodObject<{
83
+ name: z.ZodString;
84
+ language: z.ZodOptional<z.ZodString>;
85
+ body: z.ZodOptional<z.ZodArray<z.ZodString>>;
86
+ buttons: z.ZodOptional<z.ZodArray<z.ZodObject<{
87
+ index: z.ZodNumber;
88
+ value: z.ZodString;
89
+ }, z.core.$strict>>>;
90
+ }, z.core.$strict>>;
91
+ respectQuietHours: z.ZodOptional<z.ZodBoolean>;
92
+ expiresAt: z.ZodOptional<z.ZodString>;
93
+ }, z.core.$strict>>;
94
+ wait: z.ZodOptional<z.ZodString>;
95
+ at: z.ZodOptional<z.ZodString>;
96
+ }, z.core.$strip>, z.ZodTransform<{
97
+ say?: string | undefined;
98
+ attach?: {
99
+ kind: "audio" | "document" | "image";
100
+ file: string;
101
+ } | undefined;
102
+ after_ms?: number | undefined;
103
+ event?: {
104
+ id?: string | undefined;
105
+ type: string;
106
+ occurredAt?: string | undefined;
107
+ subject: string;
108
+ delivery?: string | undefined;
109
+ data: Record<string, unknown>;
110
+ } | undefined;
111
+ path?: string | undefined;
112
+ signature?: "bad" | "missing" | undefined;
113
+ answer?: number | {
114
+ status: number;
115
+ field?: string | undefined;
116
+ } | undefined;
117
+ nudge?: {
118
+ text?: string | undefined;
119
+ prompt?: string | undefined;
120
+ template?: {
121
+ name: string;
122
+ language?: string | undefined;
123
+ body?: string[] | undefined;
124
+ buttons?: {
125
+ index: number;
126
+ value: string;
127
+ }[] | undefined;
128
+ } | undefined;
129
+ respectQuietHours?: boolean | undefined;
130
+ expiresAt?: string | undefined;
131
+ } | undefined;
132
+ wait?: string | undefined;
133
+ at?: string | undefined;
134
+ }, {
135
+ say?: string | undefined;
136
+ attach?: {
137
+ kind: "audio" | "document" | "image";
138
+ file: string;
139
+ } | undefined;
140
+ after_ms?: number | undefined;
141
+ event?: {
142
+ id?: string | undefined;
143
+ type: string;
144
+ occurredAt?: string | undefined;
145
+ subject: string;
146
+ delivery?: string | undefined;
147
+ data: Record<string, unknown>;
148
+ } | undefined;
149
+ path?: string | undefined;
150
+ signature?: "bad" | "missing" | undefined;
151
+ answer?: number | {
152
+ status: number;
153
+ field?: string | undefined;
154
+ } | undefined;
155
+ nudge?: {
156
+ text?: string | undefined;
157
+ prompt?: string | undefined;
158
+ template?: {
159
+ name: string;
160
+ language?: string | undefined;
161
+ body?: string[] | undefined;
162
+ buttons?: {
163
+ index: number;
164
+ value: string;
165
+ }[] | undefined;
166
+ } | undefined;
167
+ respectQuietHours?: boolean | undefined;
168
+ expiresAt?: string | undefined;
169
+ } | undefined;
170
+ wait?: string | undefined;
171
+ at?: string | undefined;
172
+ }>>>;
173
+ tools: z.ZodOptional<z.ZodObject<{
174
+ real: z.ZodArray<z.ZodString>;
175
+ }, z.core.$strict>>;
53
176
  world: z.ZodOptional<z.ZodString>;
54
177
  max_turns: z.ZodDefault<z.ZodNumber>;
55
178
  expect: z.ZodDefault<z.ZodObject<{
@@ -63,6 +186,18 @@ declare const scenarioSchema: z.ZodObject<{
63
186
  tool: z.ZodString;
64
187
  args: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
65
188
  }, z.core.$strict>]>>>;
189
+ sent: z.ZodOptional<z.ZodArray<z.ZodObject<{
190
+ at: z.ZodOptional<z.ZodString>;
191
+ template: z.ZodOptional<z.ZodString>;
192
+ phrased: z.ZodOptional<z.ZodLiteral<true>>;
193
+ text: z.ZodOptional<z.ZodString>;
194
+ }, z.core.$strict>>>;
195
+ not_sent: z.ZodOptional<z.ZodArray<z.ZodObject<{
196
+ at: z.ZodOptional<z.ZodString>;
197
+ template: z.ZodOptional<z.ZodString>;
198
+ phrased: z.ZodOptional<z.ZodLiteral<true>>;
199
+ text: z.ZodOptional<z.ZodString>;
200
+ }, z.core.$strict>>>;
66
201
  }, z.core.$strip>>;
67
202
  }, z.core.$strip>;
68
203
  declare const toolFileSchema: z.ZodObject<{
@@ -79,6 +214,10 @@ declare const toolFileSchema: z.ZodObject<{
79
214
  /** The type of a file a scenario attaches, from its extension. */
80
215
  export declare function mimeOf(path: string): string;
81
216
  export type Scenario = z.infer<typeof scenarioSchema>;
217
+ export type OpeningStep = Scenario["opening"][number];
218
+ export type SentItem = z.infer<typeof sentItem>;
219
+ /** The business starts something in it (an event, a nudge, time passing), or it checks what was sent: a business run. */
220
+ export declare function isBusiness(scenario: Scenario): boolean;
82
221
  export type ToolFile = z.infer<typeof toolFileSchema>;
83
222
  export type ExpectedCall = z.infer<typeof expectedCall>;
84
223
  /** Parse and check a scenario. The error has one line per problem: `file:line: field: what is wrong`. */
@@ -87,6 +87,87 @@ const expectedCall = z.union([
87
87
  args: withClock(z.record(z.string(), z.unknown())).optional(),
88
88
  }),
89
89
  ], { error: "a tool name, or { tool: <name>, args: { <field>: <value> } }" });
90
+ /** "8h30m", "2d", "45m": how long a `wait` step lets pass. */
91
+ const WAIT = /^(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$/;
92
+ /** A wait's length in ms, or null when it is not one. */
93
+ export function waitMs(text) {
94
+ const m = WAIT.exec(text.trim());
95
+ if (!m || !(m[1] || m[2] || m[3]))
96
+ return null;
97
+ const ms = (Number(m[1] ?? 0) * 24 * 60 + Number(m[2] ?? 0) * 60 + Number(m[3] ?? 0)) * 60_000;
98
+ return ms > 0 ? ms : null;
99
+ }
100
+ /** A moment on the test's clock: a date and time (local to the scenario's timezone unless it has an offset),
101
+ * HH:MM (the next time the clock shows it), or a `{{now ...}}` placeholder. */
102
+ const moment = z.string().refine((t) => isDateTime(t) || /^([01]\d|2[0-3]):[0-5]\d$/.test(t) || (/^\s*\{\{[\s\S]*\}\}\s*$/.test(t) && placeholderProblem(t) === null), "a time on the test's clock: 2026-10-20T23:30, 2026-10-20T23:30+05:30, 23:30, or {{now + 1d @ 08:00}}");
103
+ const template = z.strictObject({
104
+ name: z.string().min(1),
105
+ language: z.string().optional(),
106
+ body: z.array(z.string()).optional(),
107
+ buttons: z.array(z.strictObject({ index: z.number().int().min(0), value: z.string() })).optional(),
108
+ });
109
+ /** A Flow event as the business's backend sends it; `id` and `occurredAt` default to one per step and the clock. */
110
+ const flowEvent = withClock(z.strictObject({
111
+ id: z.string().min(1).optional(),
112
+ type: z.string().min(1),
113
+ occurredAt: z.string().optional(),
114
+ subject: z.string().min(1),
115
+ delivery: z.string().optional(),
116
+ data: z.record(z.string(), z.unknown()),
117
+ }));
118
+ const businessNudge = withClock(z
119
+ .strictObject({
120
+ text: z.string().min(1).optional(),
121
+ prompt: z.string().min(1).optional(),
122
+ template: template.optional(),
123
+ respectQuietHours: z.boolean().optional(),
124
+ expiresAt: moment.optional(),
125
+ })
126
+ .refine((n) => [n.text, n.prompt, n.template].filter((v) => v !== undefined).length === 1, "exactly one of text, prompt or template"));
127
+ /** What the agent's route must answer an event: a status, or a status and the field a 400 names. */
128
+ const answer = z.union([z.number().int().min(100).max(599), z.strictObject({ status: z.number().int().min(100).max(599), field: z.string().min(1).optional() })]);
129
+ /** One thing the agent sent (`expect.sent`), or must not have (`expect.not_sent`; `{}` is anything at all). */
130
+ const sentItem = z.strictObject({
131
+ /** When, on the test's clock: HH:MM (any day), or a date and time; to the minute. */
132
+ at: moment.optional(),
133
+ /** A template by name. */
134
+ template: z.string().min(1).optional(),
135
+ /** The agent's own words rather than a template. */
136
+ phrased: z.literal(true).optional(),
137
+ /** Text the message (or the template's values) holds. */
138
+ text: withClock(z.string().min(1)).optional(),
139
+ });
140
+ const openingStep = z
141
+ .object({
142
+ say: withClock(z.string()).optional(),
143
+ /** A file the person sends with it (say is its caption): a path in the tests folder. */
144
+ attach: z.object({ kind: z.enum(["image", "audio", "document"]), file: z.string().refine(inTests, "a path inside the tests folder, such as fixtures/receipt.jpg") }).optional(),
145
+ after_ms: z.number().int().min(0).optional(),
146
+ /** The business's backend sends this event, to the agent's own route. */
147
+ event: flowEvent.optional(),
148
+ /** The route the event goes to; /v1/events when left out. */
149
+ path: z.string().regex(/^\/[^\s?#]*$/, "a path such as /v1/events").optional(),
150
+ /** bad: signed with another secret; missing: no Flow-Signature header. */
151
+ signature: z.enum(["bad", "missing"]).optional(),
152
+ /** What the route must answer; anything 2xx when left out. */
153
+ answer: answer.optional(),
154
+ /** The business nudges the person. */
155
+ nudge: businessNudge.optional(),
156
+ /** Time passes on the test's clock. */
157
+ wait: z.string().refine((t) => waitMs(t) !== null, 'a length of time such as 8h30m, 45m or 2d').optional(),
158
+ /** Move the test's clock to this time before the step. */
159
+ at: moment.optional(),
160
+ })
161
+ .superRefine((step, ctx) => {
162
+ const kinds = [step.say !== undefined || step.attach ? "say" : null, step.event ? "event" : null, step.nudge ? "nudge" : null, step.wait !== undefined ? "wait" : null].filter(Boolean);
163
+ if (kinds.length !== 1)
164
+ ctx.addIssue({ code: "custom", message: kinds.length ? `one of say, event, nudge or wait per step, not ${kinds.join(" and ")}` : "say something, attach a file, send an event, nudge, or wait" });
165
+ if (!step.event && (step.path || step.signature || step.answer !== undefined))
166
+ ctx.addIssue({ code: "custom", message: "path, signature and answer belong to an event step" });
167
+ if (kinds[0] === "say" && !step.say && !step.attach)
168
+ ctx.addIssue({ code: "custom", message: "say something, attach a file, or both" });
169
+ })
170
+ .transform((step) => (step.event || step.nudge || step.wait !== undefined ? step : { ...step, say: step.say ?? "" }));
90
171
  const scenarioSchema = z.object({
91
172
  id: z.string().min(1),
92
173
  title: z.string().optional(),
@@ -100,20 +181,15 @@ const scenarioSchema = z.object({
100
181
  .preprocess((v) => (v instanceof Date ? v.toISOString().replace(/\.\d+Z$/, "Z") : v), z.string().refine(isDateTime, "not a date and time: write YYYY-MM-DDTHH:MM, with :SS, Z or an offset such as -04:00 if needed"))
101
182
  .optional(),
102
183
  person: z.object({
184
+ /** Their address on the run's channel (a phone number on phone channels): who a business run's events and nudges are for. */
185
+ address: z.string().min(1).optional(),
103
186
  persona: withClock(z.string()),
104
187
  goal: withClock(z.string()),
105
188
  knows: z.array(withClock(z.string())).optional(),
106
189
  }),
107
- opening: z
108
- .array(z
109
- .object({
110
- say: withClock(z.string()).default(""),
111
- /** A file the person sends with it (say is its caption): a path in the tests folder. */
112
- attach: z.object({ kind: z.enum(["image", "audio", "document"]), file: z.string().refine(inTests, "a path inside the tests folder, such as fixtures/receipt.jpg") }).optional(),
113
- after_ms: z.number().int().min(0).optional(),
114
- })
115
- .refine((step) => step.say || step.attach, "say something, attach a file, or both"))
116
- .min(1),
190
+ opening: z.array(openingStep).min(1),
191
+ /** Tools whose own code runs in this scenario, in place of the cloud's simulation (state the agent keeps itself). */
192
+ tools: z.strictObject({ real: z.array(z.string().min(1)).min(1) }).optional(),
117
193
  world: withClock(z.string()).optional(),
118
194
  max_turns: z.number().int().min(1).max(50).default(8),
119
195
  expect: z
@@ -124,6 +200,10 @@ const scenarioSchema = z.object({
124
200
  calls: z.array(expectedCall).optional(),
125
201
  /** None may be called. */
126
202
  never: z.array(expectedCall).optional(),
203
+ /** What the agent sent, in order (other sends may come between): checked by code, with the test's clock. */
204
+ sent: z.array(sentItem).optional(),
205
+ /** None of these was sent; `{}` is anything at all. */
206
+ not_sent: z.array(sentItem).optional(),
127
207
  })
128
208
  .default({}),
129
209
  });
@@ -149,6 +229,10 @@ export function mimeOf(path) {
149
229
  const types = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", webp: "image/webp", gif: "image/gif", pdf: "application/pdf", ogg: "audio/ogg", opus: "audio/ogg", mp3: "audio/mpeg", m4a: "audio/mp4", wav: "audio/wav" };
150
230
  return types[extname(path).slice(1).toLowerCase()] ?? "application/octet-stream";
151
231
  }
232
+ /** The business starts something in it (an event, a nudge, time passing), or it checks what was sent: a business run. */
233
+ export function isBusiness(scenario) {
234
+ return scenario.opening.some((s) => s.event || s.nudge || s.wait !== undefined || s.at !== undefined) || !!scenario.expect.sent || !!scenario.expect.not_sent || !!scenario.tools;
235
+ }
152
236
  /** Parse and check a scenario. The error has one line per problem: `file:line: field: what is wrong`. */
153
237
  export function parseScenario(file) {
154
238
  return parseFile(scenarioSchema, file);
package/docs/agents.md CHANGED
@@ -15,16 +15,24 @@ package.
15
15
  ```sh
16
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
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
18
+ npx @flow-engineer/flow link --channel telegram --json # or whatsapp, imessage: signs in and sets up if needed (the keys, Flow's trial model key among them), then prints the phone link
19
+ npm start # after link: keep it running; the playground link it prints, and the phone link reaches it
20
20
  npx @flow-engineer/flow status --json # any time: what the folder has and lacks
21
21
  printf 'hi\nA latte please\n' | npx @flow-engineer/flow chat # your own check: talk to it locally, one message per line
22
22
  ```
23
23
 
24
24
  Before `init` (or `flow new <folder>`), ask the owner what people should call their agent, such as
25
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.
26
+ the message their phone sends to reach the agent is `Hi FlatX agent (ref <code>)`. The name takes the
27
+ starter's (Pip) place in the agent's prompt, `agent/AGENTS.md`, and the playground and MCP server
28
+ read it from `package.json`. To rename an agent made already, `link --display-name "<name>"` (or
29
+ `setup --display-name`) changes both; restart `npm start` after it. Without the flag, `init` asks in
30
+ a terminal and otherwise uses the folder's name in words.
31
+
32
+ `npm start` listens on `PORT`, else 8788: with 8788 taken and no `PORT` set it takes the next free
33
+ port and says so, and the playground link it prints has the port it uses. With `PORT` set to a port
34
+ that is taken it prints one line, `Port 8788 is in use. Run: PORT=8798 npm start`, and exits 1.
35
+ `npm run mcp` does the same with `MCP_PORT` (else 8790).
28
36
 
29
37
  ## How the owner tests it
30
38
 
@@ -64,7 +72,8 @@ before you hand it back; run them yourself, and do not offer them to the owner a
64
72
  - `link` does the rest when the folder is not set up (no `FLOW_FRAMEWORK_JOIN_CODE` or
65
73
  `FLOW_MESSAGING_KEY` in `.env`, or no sender for the WhatsApp or iMessage link asked for): it
66
74
  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.
75
+ `setup` (writes the keys and each open channel's sender into `.env`, and the chosen channel's
76
+ page as `FLOW_FRAMEWORK_TRY_URL`), then prints the link and that channel's free-trial line only.
68
77
  `--channel telegram|whatsapp|imessage` picks the channel (Telegram by default). Run it in the
69
78
  agent's folder: `setup` and `link` refuse a folder with no `package.json` (`not_an_agent_folder`).
70
79
  - A channel that is not open yet is said once ("iMessage isn't open yet; here are your Telegram
@@ -75,9 +84,13 @@ before you hand it back; run them yourself, and do not offer them to the owner a
75
84
  24 hours of their last message); it cannot message them first. `link --json` says so
76
85
  (`reply_only: true`, and in `say_to_person`): tell the owner.
77
86
  - 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."
87
+ (`FLOW_MESSAGING_WHATSAPP_SENDER`, `FLOW_MESSAGING_IMESSAGE_SENDER`), and reads `.env` once.
88
+ `npm start` marks itself running in `.flow/running.json`, so `link` ends with the step that fits:
89
+ `Next: npm start, then send the link above` when it is not running; when it is, `Next: restart npm
90
+ start (it runs at <url> and lacks iMessage), then send the link above` if `link` just wrote `.env`
91
+ or renamed it, else `Next: send the link above (npm start is running at <url>)` (`link --json`:
92
+ `running`, `false` or `{"url"}`, and `next`). `setup` alone says "If npm start is running, restart it to add
93
+ iMessage."
81
94
  - Give the owner `say_to_person` from `link --json`, and the sign-in link and code from the
82
95
  `pending` line while you wait.
83
96
  - `npm start` connects to Flow Messaging by an outbound event stream. Messages sent while it was
@@ -108,7 +121,8 @@ Every error is `{"error":{"code","message","next"}}` on stdout with `--json`, an
108
121
  `next` is the exact command or URL to run; when it came from flow.engineer it is the server's.
109
122
  A run gives one `next`: `setup --json`'s is a list in order (a model key first when there is
110
123
  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.
124
+ has none, and `link`'s `next` is one string (a model key first when there is none, then starting or
125
+ restarting `npm start`); an error's `next` replaces it.
112
126
  Run it, or give it to the owner when it needs them (a browser approval, a page to open). A
113
127
  `slow_down` error also has `retry_after` (seconds, or `null` when the server gave none). A
114
128
  trial limit's `next` is a sentence, not a command: it is printed on its own line as it came; pass
@@ -140,6 +154,14 @@ reach (`iMessage: 1 of 2 people`; `trial.imessage.contacts` `{limit, used}` in `
140
154
  with `FLOW_FRAMEWORK_LOGIN_URL` naming another server, commands stop with `signed_in_elsewhere`.
141
155
  - CI never signs in: `npx @flow-engineer/flow setup --ci` says how to give it the test cloud key.
142
156
 
157
+ ## Messages the business starts
158
+
159
+ A nudge, a scheduled message or any turn the person did not start goes with link previews off
160
+ (`preview: false` on the message; Flow Messaging's WhatsApp `preview_url: false`): on WhatsApp a
161
+ preview card's title becomes the phone's notification instead of the message's text. For a call to
162
+ action use `link` (`agent.nudge({ to, text, link: { label, url } })`, or the agent's send with
163
+ `link`), not a URL in the text.
164
+
143
165
  ## Shared test numbers and privacy
144
166
 
145
167
  The free trial runs on Flow's shared Telegram bot, WhatsApp number and iMessage line. Sending an