@flow-engineer/flow 0.2.1 → 0.2.3

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 (70) hide show
  1. package/README.md +167 -10
  2. package/coding-agents/auth-skill.md +137 -0
  3. package/coding-agents/context-skill.md +94 -0
  4. package/coding-agents/skill.md +219 -17
  5. package/coding-agents/test-skill.md +3 -1
  6. package/coding-agents/tools-skill.md +78 -1
  7. package/dist/cli/src/auth.d.ts +11 -0
  8. package/dist/cli/src/auth.js +147 -0
  9. package/dist/cli/src/commands.d.ts +119 -0
  10. package/dist/cli/src/commands.js +212 -0
  11. package/dist/cli/src/index.d.ts +2 -0
  12. package/dist/cli/src/index.js +5 -0
  13. package/dist/cli/src/run.d.ts +28 -0
  14. package/dist/cli/src/run.js +523 -0
  15. package/dist/cli/src/terminal.d.ts +3 -0
  16. package/dist/cli/src/terminal.js +135 -0
  17. package/dist/flow-messaging/src/client.d.ts +2 -2
  18. package/dist/flow-messaging/src/client.js +3 -3
  19. package/dist/flow-messaging/src/index.js +17 -9
  20. package/dist/mcp/src/client.d.ts +54 -0
  21. package/dist/mcp/src/client.js +175 -0
  22. package/dist/mcp/src/describe.d.ts +41 -0
  23. package/dist/mcp/src/describe.js +110 -0
  24. package/dist/mcp/src/index.d.ts +4 -0
  25. package/dist/mcp/src/index.js +7 -0
  26. package/dist/mcp/src/server.d.ts +70 -0
  27. package/dist/mcp/src/server.js +252 -0
  28. package/dist/mcp/src/terminal.d.ts +24 -0
  29. package/dist/mcp/src/terminal.js +54 -0
  30. package/dist/simulators/src/index.d.ts +1 -1
  31. package/dist/simulators/src/page.html +267 -49
  32. package/dist/simulators/src/server.d.ts +27 -0
  33. package/dist/simulators/src/server.js +264 -40
  34. package/dist/src/agent.d.ts +69 -3
  35. package/dist/src/agent.js +162 -8
  36. package/dist/src/channels.d.ts +6 -4
  37. package/dist/src/channels.js +4 -3
  38. package/dist/src/conversation.d.ts +17 -1
  39. package/dist/src/conversation.js +170 -16
  40. package/dist/src/events.d.ts +1 -1
  41. package/dist/src/index.d.ts +2 -2
  42. package/dist/src/index.js +1 -1
  43. package/dist/src/model.d.ts +27 -0
  44. package/dist/src/model.js +85 -13
  45. package/dist/src/skills.d.ts +4 -0
  46. package/dist/src/skills.js +23 -0
  47. package/dist/src/store.d.ts +2 -0
  48. package/dist/src/store.js +18 -0
  49. package/dist/src/tools.d.ts +69 -0
  50. package/dist/src/tools.js +17 -1
  51. package/dist/src/traces.js +32 -4
  52. package/dist/src/types.d.ts +5 -0
  53. package/dist/testing/src/account.d.ts +1 -1
  54. package/dist/testing/src/account.js +368 -57
  55. package/dist/testing/src/chat.js +24 -16
  56. package/dist/testing/src/check.d.ts +4 -0
  57. package/dist/testing/src/check.js +90 -0
  58. package/dist/testing/src/cli.js +77 -19
  59. package/dist/testing/src/flow-login.d.ts +33 -0
  60. package/dist/testing/src/flow-login.js +30 -5
  61. package/dist/testing/src/new.d.ts +14 -5
  62. package/dist/testing/src/new.js +54 -20
  63. package/dist/testing/src/plugin.d.ts +4 -0
  64. package/dist/testing/src/plugin.js +116 -0
  65. package/docs/agents.md +87 -12
  66. package/examples/cafe/src/cli.ts +24 -0
  67. package/examples/cafe/src/mcp.ts +22 -0
  68. package/examples/cafe/src/tools.ts +13 -0
  69. package/flow-messaging/README.md +3 -1
  70. package/package.json +28 -1
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: flow-framework
3
- description: Build or change a chat agent on Flow Framework (WhatsApp, SMS, iMessage, Telegram, web). Load when creating a Flow Framework agent, editing its prompt or skills, wiring it into the playground, or when code imports @flow-engineer/flow (formerly chinwag). Also load the flow-framework-tools skill (tools-skill.md) before writing tools and flow-framework-tests (test-skill.md) before writing tests.
3
+ description: Build or change a chat agent on Flow Framework (WhatsApp, SMS, iMessage, Telegram, web), and serve its tools as an MCP server for Claude and ChatGPT or as a CLI. Load when creating a Flow Framework agent, editing its prompt or skills, wiring it into the playground, adding MCP or a CLI, or when code imports @flow-engineer/flow. Also load the flow-framework-tools skill (tools-skill.md) before writing tools and flow-framework-tests (test-skill.md) before writing tests.
4
4
  ---
5
5
 
6
6
  # Building an agent on Flow Framework
@@ -10,17 +10,20 @@ reply, acknowledges people fast, runs tools in parallel and waits for them, movi
10
10
  background only when the person writes again or it passes its limit.
11
11
  You write the agent's prompt, skills and tools; Flow Framework handles the conversation.
12
12
 
13
- Two more skills go with this one: `flow-framework-tools` (`tools-skill.md`) before writing or changing a
14
- tool, and `flow-framework-tests` (`test-skill.md`) before writing or changing a test.
13
+ More skills go with this one: `flow-framework-tools` (`tools-skill.md`) before writing or changing a
14
+ tool, `flow-framework-tests` (`test-skill.md`) before writing or changing a test, `flow-framework-context`
15
+ (`context-skill.md`) before writing tools for a company that already has an app, API or database,
16
+ and `flow-framework-auth` (`auth-skill.md`) when tools need to know who the person is.
15
17
 
16
18
  ## Start a new agent
17
19
 
18
20
  `npx @flow-engineer/flow new <name>` (lowercase letters, digits and dashes) makes `./<name>/` from the café
19
21
  example: a complete agent with tools and tests that depends on the `@flow-engineer/flow` package and imports
20
22
  `@flow-engineer/flow` and `@flow-engineer/flow/simulators`, with these skills in `.claude/skills/`. Run `npm install` there.
21
- Build in it, changing Pip's prompt, skills, tools and tests into yours. The owner copies
22
- `.env.example` to `.env` and puts one model key in it (never read or print it). Flow Framework does not
23
- issue that key; it is made at the provider: `OPENROUTER_API_KEY` at https://openrouter.ai/keys,
23
+ Build in it, changing Pip's prompt, skills, tools and tests into yours. A provider key is optional:
24
+ `npx @flow-engineer/flow link` (or `setup`) writes `FLOW_KEY`, the agent's one Flow key, into `.env` (never
25
+ read or print it), and the agent calls models through Flow with it on $2 of Flow trial credit. A key of the owner's own wins over it, made
26
+ at the provider: `OPENROUTER_API_KEY` at https://openrouter.ai/keys,
24
27
  `OPENAI_API_KEY` at https://platform.openai.com/api-keys, `ANTHROPIC_API_KEY` at
25
28
  https://console.anthropic.com/settings/keys, or `GEMINI_API_KEY` at https://aistudio.google.com/apikey.
26
29
  The first one with a value is used, and `CAFE_MODEL` sets the café's model id. Then `npm start`
@@ -41,6 +44,10 @@ my-agent/
41
44
  src/tools.ts the agent's tools (see tools-skill.md)
42
45
  src/agent.ts buildAgent(): the one place the agent is built
43
46
  src/main.ts runs it in the playground
47
+ src/mcp.ts optional: the tools as an MCP server (below)
48
+ src/cli.ts optional: the CLI file, for the organization's command line (below)
49
+ context/ optional: the context pack the tools are written from (context-skill.md)
50
+ mcp.lock.json optional: the tools of other MCP servers the agent uses (tools-skill.md)
44
51
  tests/tools/*.yaml how each tool behaves in tests (see tools-skill.md)
45
52
  tests/scenarios/*.yaml the tests (see test-skill.md)
46
53
  ```
@@ -89,8 +96,10 @@ is how long a finished tool's result waits for the others of its response; `shar
89
96
  guidance with a string.
90
97
 
91
98
  Models are reached through any OpenAI-compatible API. `openAICompatible()` with no key reads the
92
- first of `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` and `GEMINI_API_KEY` that has a
93
- value, and uses that provider's default model (`MODEL` overrides it). With none it stops at once and
99
+ first of `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY` and
100
+ `FLOW_KEY` (the agent's Flow key, on its trial credit, from `setup`) that has a value, and uses that provider's
101
+ default model (`MODEL` overrides it). `flowModel()` uses Flow alone; when its credit is used up a
102
+ call fails with code `trial_credit_used` and a next step (tell the owner to add their own key). With none it stops at once and
94
103
  names them. Pass `apiKey` and `baseURL` for anywhere else (`baseURL` alone for a local server that needs no key).
95
104
 
96
105
  Which model: recommend Claude Haiku 5.5 (`anthropic/claude-haiku-5.5`, the default above) or OpenAI
@@ -294,23 +303,32 @@ To demo what your backend does (an order ships), send the nudge it would send fr
294
303
  On a deployed agent, mount it in your own server with a token of at least 32 characters, from a
295
304
  secret: `const playground = play.handler(agent, { token: process.env.PLAYGROUND_TOKEN!, base: "/playground" })`,
296
305
  and hand it the requests under `/playground` (`if (url.pathname.startsWith("/playground")) return playground(req, res)`).
297
- Open `/playground/?token=...` once (it becomes an HttpOnly cookie and leaves the URL) or call the API
298
- with `Authorization: Bearer <token>`. Wrong tokens are limited per address, and every simulated
299
- message is logged with the person, channel, time and address.
306
+ Open `/playground/?token=...` once (it becomes an HttpOnly, SameSite=Lax cookie and leaves the URL) or call the API
307
+ with `Authorization: Bearer <token>`. The cookie survives a top-level return from sign-in on another site;
308
+ the API still accepts only requests from the playground’s own origin. Wrong tokens are limited per address,
309
+ and every simulated message is logged with the person, channel, time and address.
300
310
 
301
311
  ## Put it on a phone
302
312
 
303
313
  To chat with the agent from the owner's phone, with no tunnel and no key made by hand, run these
304
- from the agent's folder (`npx @flow-engineer/flow init` makes a starter in an empty one). Only
305
- `chat` reads stdin; nothing else asks for input or needs a terminal.
314
+ from the agent's folder (`npx @flow-engineer/flow init --display-name "<name>"` makes a starter in
315
+ an empty one: first ask the owner what people should call their agent, such as "FlatX agent"; the
316
+ phone's first message is `Hi FlatX agent (ref <code>)`, and `package.json`'s `displayName` keeps it).
317
+ Only `chat` reads stdin; `init` asks for the name only in a terminal, and nothing else asks for input.
306
318
 
307
319
  ```sh
308
- printf 'hi\nA latte please\n' | npx @flow-engineer/flow chat # locally, no account: one message per line, replies printed
309
- npx @flow-engineer/flow link --json # signs in and runs setup when needed, then the link; give its say_to_person to the owner
310
- npx @flow-engineer/flow test --synthetic # the tests, on the key setup wrote
311
- npm start # the agent; the owner texts it while it runs
320
+ npx @flow-engineer/flow link --json # signs in and runs setup when needed (Flow's trial model key too), then the channel's own link (t.me, wa.me, sms:); give its say_to_person to the owner
321
+ npm start # the agent: its playground link, and the owner texts it while it runs
322
+ npx @flow-engineer/flow status --json # what the folder has and lacks, at any time
323
+ printf 'hi\nA latte please\n' | npx @flow-engineer/flow chat # your own check: one message per line, replies printed
324
+ npx @flow-engineer/flow test --synthetic # your own check: the tests, on the key setup wrote
312
325
  ```
313
326
 
327
+ When the owner asks how to test it, recommend first the playground (`npm start`, then open the
328
+ link it prints: the test people and the channels side by side, with Debug), then their phone (the
329
+ `link`, while `npm start` runs). `chat` and `test --synthetic` are for you to check your own work;
330
+ do not offer them to the owner as ways to test.
331
+
314
332
  `link` runs `login` first when not signed in: it opens the owner's browser itself and prints the
315
333
  link and code at once (with `--json` the first line is `{"status":"pending","verification_uri":...,
316
334
  "user_code":...,"next":...}`), waits at most 120 s (`--timeout <s>`), then exits 4 with the code
@@ -351,3 +369,187 @@ touches a chat; call `await agent.flushTraces()` before the process exits. Tests
351
369
  `node --test` never send from the environment. Under `flow test` the traces say they are a test's
352
370
  (`chinwag.run_kind` is "test", else "production", and each turn names the test session as
353
371
  `chinwag.test.session`), so the dashboard keeps them apart from real conversations.
372
+
373
+ ## Serve the tools over MCP
374
+
375
+ The same tools and skills go to Claude, ChatGPT and any MCP host as an MCP server. Install the
376
+ SDK it runs on (`npm i @modelcontextprotocol/server@2.3.1`, the version Flow Framework pins) and add
377
+ `src/mcp.ts`:
378
+
379
+ ```ts
380
+ import { createServer } from "node:http";
381
+ import { fileURLToPath } from "node:url";
382
+ import { mcpServer } from "@flow-engineer/flow/mcp";
383
+ import { tools } from "./tools.ts";
384
+
385
+ export const mcp = mcpServer({
386
+ name: "acme",
387
+ version: "1.0.0",
388
+ tools,
389
+ dir: fileURLToPath(new URL("../agent", import.meta.url)), // its skills; AGENTS.md is not sent
390
+ // auth: { issuer, resource, scopes, verify } // sign-in: the flow-framework-auth skill
391
+ });
392
+ createServer((req, res) => void mcp.node(req, res)).listen(8790); // Streamable HTTP at /mcp
393
+ ```
394
+
395
+ - `mcp.fetch(request)` serves a web-standard `Request` instead (Cloudflare Workers, Deno, Bun), so
396
+ it mounts beside the chat agent in one process or runs on its own: it needs no model, channels
397
+ or store.
398
+ - Hosts get the skills as the server's instructions (each one's description) and read one with the
399
+ `read_skill` tool. A skill that tells the chat agent to use `send_*` or `share_updates` makes
400
+ no sense to a host: `flow check` warns about it.
401
+ - Every part can be changed: `instructions` replaces the instructions, `descriptor(tool, generated)`
402
+ changes any tool's MCP descriptor, `extend(server)` adds handlers of your own (resources,
403
+ prompts) to the SDK's server, `path` moves the endpoint, `allowedHosts` checks the Host header.
404
+ - Try it locally with `claude mcp add --transport http acme http://localhost:8790/mcp`.
405
+ - Run `npx flow check` from the agent's folder before serving: it refuses tools that cannot be
406
+ described exactly (the `flow-framework-tools` skill lists the rules).
407
+
408
+ ## A CLI for the organization
409
+
410
+ The same tools as commands, over the organization's MCP server, with the same sign-in and gating.
411
+ It runs on Stricli (`@stricli/core`, the framework Sentry's CLI is built on). Every tool is already a
412
+ command under its own name (`place_order` is `acme place-order`); a CLI file adds what needs
413
+ judgement. Install `@modelcontextprotocol/client@2.3.1` and `@stricli/core@1.3.0` (the versions
414
+ Flow Framework pins) and write `src/cli.ts`:
415
+
416
+ ```ts
417
+ #!/usr/bin/env node
418
+ import { fileURLToPath } from "node:url";
419
+ import { defineCli, runCli } from "@flow-engineer/flow/cli";
420
+
421
+ export const cli = defineCli({
422
+ name: "acme",
423
+ server: process.env.ACME_SERVER ?? "https://acme.example.com/mcp",
424
+ description: "Acme's orders and customers",
425
+ groups: { order: "Place and track orders" },
426
+ commands: {
427
+ "order list": { tool: "list_orders", columns: ["order_id", "status", "total"], alias: "orders" },
428
+ "order view": { tool: "get_order", args: ["order_id"], examples: ["acme order view BL-1042 --json"] },
429
+ "order place": { tool: "place_order" },
430
+ },
431
+ });
432
+
433
+ if (process.argv[1] === fileURLToPath(import.meta.url)) process.exit(await runCli(cli, process.argv.slice(2)));
434
+ ```
435
+
436
+ - Words are groups then a verb: `order` is a **command group**, `list`, `view` and `place` its
437
+ subcommands. `acme order` and `acme order -h` list the group's subcommands, `acme order place -h`
438
+ that command's flags and examples, and `acme -h` the groups and top-level commands. A word is a
439
+ group or a command, never both. `groups` gives each group its line in help.
440
+ - `args` are the input fields taken as positional arguments, `columns` a list's table, `view` one
441
+ record's fields, `alias` another name, `examples` lines for help. Every other input field is a
442
+ flag (`order_id` is `--order-id`); a list is a repeatable or comma-separated flag; an object or a
443
+ list of objects takes JSON, or `@file.json`, and its help shows the shape
444
+ (`JSON list of {item, size, milk?}`).
445
+ - A command of the team's own is any Stricli command: `"report daily": { command: buildCommand({ ... }) }`
446
+ with `buildCommand` from `@stricli/core`; inside it, `this.cli.call(tool, input)` calls the server's
447
+ tools and `this.cli.print(value, { json })` prints like the rest.
448
+ - Installed, it is the organization's own command: `"bin": { "acme": "src/cli.ts" }` in its package
449
+ (node runs `.ts` files outside `node_modules`; publish a compiled `.js` bin to npm). While building
450
+ it, run it with `npm run cli -- order -h`: npm takes flags before `--` as its own.
451
+ - Every tool command takes `--json` and `--fields a,b`; one that changes things takes `--dry-run`; one
452
+ that may delete or overwrite asks first in a terminal and needs `--yes` without one. Exit codes: 0
453
+ done, 1 the tool failed, 2 wrong command or arguments (with the help to read), 3 sign-in needed, 4
454
+ needs `--yes`, 5 the server could not be reached. `--server <url>` and `--token <token>` work on
455
+ every command. `acme auth login` signs in on the organization's own login (tokens in
456
+ `~/.config/acme/`, mode 600), `ACME_TOKEN` is for CI, `acme tools` lists every tool and schema, and
457
+ `acme skill --write ~/.claude/skills` installs the CLI's SKILL.md, made from the tools and the CLI
458
+ file, for coding agents. For any MCP server without a CLI file, `npx flow cli --server <url> -h`.
459
+ - `npx flow check` checks the CLI file against the tools: a command naming a tool that does not
460
+ exist, an `args` field the input lacks, a `columns` or `view` field the output lacks, two commands
461
+ with one name, a word that is both a command and a group, or one of the CLI's own words (`auth`,
462
+ `tools`, `skill`) is an error; a tool no command names is a warning (it keeps its own name).
463
+
464
+
465
+ ### Native plugins and standalone MCP
466
+
467
+ The standalone `/mcp` endpoint includes the skills list and `read_skill`. Native plugins
468
+ copy the agent's `agent/skills/` folder and connect to `/mcp/plugin`, which serves tools only,
469
+ so a host receives each skill once. Both paths share tools, auth and descriptor overrides.
470
+ `mcpServer({ ..., plugin: false })` disables the plugin path; `plugin: { path: "/native" }`
471
+ moves it. `handle.plugin` exposes its path and descriptors. Your `instructions` override
472
+ applies to both endpoints, and `extend` can add SDK handlers on both.
473
+
474
+ From the agent folder, run:
475
+
476
+ ```sh
477
+ flow plugin --server https://agent.example.com/mcp --out plugins
478
+ flow check --plugin plugins
479
+ ```
480
+
481
+ `plugins/claude/` uses Claude Code's `.claude-plugin/plugin.json` and `.mcp.json` with
482
+ `type: "http"`. `plugins/agent-plugins/` uses Agent Plugins 1.0.0 root `plugin.json` and
483
+ `mcp.json` with `type: "streamable-http"`; Codex supports this portable format.
484
+ Pass `--name` to override the package name. For a custom plugin route, pass its URL ending
485
+ in `/plugin`, or edit the generated MCP config. No token is written into either package.
486
+ Regenerate after changing a skill or its assets; `check --plugin` compares content hashes.
487
+ Generated files remain plain files in your repository: customize manifests and add components
488
+ alongside them. Regeneration rewrites manifests and source skill copies; keep hand-written
489
+ skills outside those source paths.
490
+
491
+ Formats checked 18 October 2026: [Agent Plugins 1.0.0](https://agent-plugins.org/specification),
492
+ [OpenAI plugin packaging](https://developers.openai.com/plugins/build/plugins), and
493
+ [Claude Code plugin reference](https://code.claude.com/docs/en/plugins-reference).
494
+
495
+ MCP calls emit `tool.start`/`tool.end` with surface (`mcp`, `mcp-plugin`, `cli`), client name
496
+ and version, session, person, duration, outcome and error. CLI calls send command and an
497
+ idempotency key in `_meta`; a caller can reuse its key on a retry. `defineCli`'s `version`
498
+ sets its client version (default `"1"`). Neither CLI arguments nor trace credentials go in `_meta`.
499
+ `mcpServer` uses `FLOW_FRAMEWORK_TRACES_*`, like chat agents, or explicit `traces` options;
500
+ `traces: false` disables export without disabling your event sinks. `await handle.flushTraces()`
501
+ flushes its bounded background queue. Each tool call is a root trace without a conversation;
502
+ arguments and results require `FLOW_FRAMEWORK_TRACES_CONTENT=1`. Legacy clients' initialization
503
+ metadata is retained for up to 24 hours in at most 4,096 sessions; bearer tokens are never cached.
504
+
505
+ ### Local playground CLI
506
+
507
+ To exercise the real CLI on your computer, pass `localCli: { channel: "terminal", config: cli }`
508
+ to `play.listen(agent, port, options)` or a locally mounted `play.handler(agent, options)`.
509
+ `cli` is the team's existing `defineCli` configuration, with its server URL; the named channel
510
+ must exist. This opens an independent CLI session in that tab, with real stdout/stderr, exit
511
+ codes, interactive input and confirmation prompts. It does not use the selected test person's
512
+ identity. Run `auth login`, open the login link and return, then use commands or `chat` (`/exit`
513
+ leaves chat). Reload reconnects. New CLI session discards only this runner's temporary sign-in;
514
+ server restart or 30 minutes without browser activity also ends it. Clear output keeps sign-in.
515
+ An operation already accepted by the agent may still complete after its client session ends.
516
+
517
+ Enable this only in local startup code. The runner is off by default, rejects non-loopback or
518
+ forwarded connections, and is not part of the hosted test cloud. It calls the actual CLI runner
519
+ only locally. Hosted CLI support is deferred pending security review; do not add it to cloud startup
520
+ or deployment wiring. See the hosted CLI TODO in the README. The runner invokes the CLI
521
+ without a shell: quoting works, pipelines/expansion do not. The browser cannot override server
522
+ or token flags, or use `skill --write` to write onto the developer's computer.
523
+ For other embedded clients, `CliConfig.directory` overrides credential/conversation storage;
524
+ `CliIO.env` overrides the inherited process environment and `CliIO.signal` cancels sign-in and
525
+ terminal HTTP requests. Installed CLI defaults stay unchanged.
526
+
527
+ ### Terminal conversations
528
+
529
+ `chat` talks directly to the agent, using the same bearer as MCP; it is not an MCP tool. `defineCli({ name: "acme", server, agent: "https://agent.example.com/agent/terminal" })`
530
+ overrides the terminal URL; the default is the MCP server's origin plus `/agent/terminal`.
531
+ `acme chat "Which orders are ready?" --json` prints one response; `acme chat` reads a TTY until
532
+ Ctrl-D or `/exit`. Without a TTY pass a message to `chat` (an interactive chat exits 2); sign-in failures exit 3.
533
+ Human chat shows a `You ›` input prompt, a waiting indicator and agent replies under the CLI's
534
+ name. `--json` keeps stdout as one JSON response per line, without those labels. Embedded CLIs
535
+ can supply `CliIO.lines(prompt?)` and their own output handlers to replace the presentation.
536
+ `chat.json` is mode 600 and scoped by terminal endpoint. `--new` forgets the terminal's saved
537
+ reference; the agent keeps its one-person history across channels. It does not erase a person's
538
+ WhatsApp history. Teams can replace `chat` with their own Stricli command in `commands`.
539
+
540
+ Mount the optional terminal handler beside MCP (no model or extra dependency in the CLI):
541
+
542
+ ```ts
543
+ import { terminalServer } from "@flow-engineer/flow/mcp";
544
+ const terminal = terminalServer({ agent: () => agent, auth });
545
+ const agent = createAgent({ ...options, channels: [...channels, terminal.channel] });
546
+ // Route terminal.path to terminal.fetch(request), and MCP's paths to mcp.fetch(request).
547
+ ```
548
+
549
+ `terminalServer` accepts `path` and `channel` overrides. It verifies auth on every POST,
550
+ serializes concurrent requests per person, and returns `{ conversationId, replies }` after the
551
+ turn settles. The authenticated key becomes the same person that `resolve` returns on other
552
+ channels. A body cannot choose its own person; a conversation reference from another person is
553
+ refused. Its custom UI uses Markdown and exports turns as channel `cli`.
554
+ For your own HTTP adapters, `agent.request({ channel, key, text, conversationId? })` exposes this
555
+ without the handler. Supply a verified key and serialize calls for that channel and person.
@@ -5,7 +5,9 @@ description: Write or change tests for a Flow Framework agent (tests/scenarios/*
5
5
 
6
6
  # Writing tests for a Flow Framework agent
7
7
 
8
- Read the `flow-framework` skill (`skill.md`) first if you haven't.
8
+ Read the `flow-framework` skill (`skill.md`) first if you haven't. Turn the situations in
9
+ `context/examples/` (the `flow-framework-context` skill) into scenarios, and run `npx flow check`
10
+ before the tests when the agent serves MCP or a CLI.
9
11
 
10
12
  A test is a scenario: a situation to put the agent in, played through a platform simulator by a
11
13
  model acting as the person, with the agent's tools played by a model from `tests/tools/*.yaml`
@@ -6,7 +6,9 @@ description: Write or change a Flow Framework agent's tools and their test files
6
6
  # Writing tools for a Flow Framework agent
7
7
 
8
8
  Read the `flow-framework` skill (`skill.md`) first if you haven't. Tests that use these tool files are
9
- in the `flow-framework-tests` skill (`test-skill.md`).
9
+ in the `flow-framework-tests` skill (`test-skill.md`). For a company with an existing app or API, build
10
+ the context pack first (the `flow-framework-context` skill, `context-skill.md`) and write the tools from
11
+ it.
10
12
 
11
13
  ## The tool
12
14
 
@@ -49,6 +51,77 @@ export const findSlots = tool({
49
51
  searched ("Mon Oct 12 through Sun Oct 18", "all open times") and never cut results short
50
52
  silently. If there are more, say so.
51
53
 
54
+ ## Tools from the context pack
55
+
56
+ - One tool per task in `context/workflows.arazzo.yaml`, never one per endpoint: "place an order"
57
+ is one tool even when it is three API calls. A host or a small model chooses well among a few
58
+ task-shaped tools and badly among dozens of raw ones.
59
+ - Take the input and output shapes from `context/openapi.yaml` with `context/overlay.yaml` applied,
60
+ the words and rules from `business.md`, and leave out the personal fields `data.md` marks unless
61
+ the task needs them.
62
+ - `signIn` and `hints` come from `context/access.yaml` (below).
63
+ - When the team sends a new spec, re-read the overlay and the workflows against it, change only the
64
+ tools whose operations changed, and run `flow check` and the tests.
65
+
66
+ ## The fields MCP and the CLI read
67
+
68
+ The same tool is served to Claude and ChatGPT over MCP (`@flow-engineer/flow/mcp`) and as a command (`@flow-engineer/flow/cli`).
69
+ These optional fields describe it there. **Chat ignores all of them**: they change nothing on
70
+ WhatsApp, SMS or iMessage.
71
+
72
+ ```ts
73
+ export const placeOrder = tool({
74
+ name: "place_order",
75
+ title: "Place an order", // default: "Place order", from the name
76
+ description: "Place a confirmed pickup order. Returns the order number and when it will be ready.",
77
+ input: z.object({ name: z.string(), items: z.array(item) }),
78
+ output: z.object({ order_id: z.string(), ready_by: z.string() }), // the result's shape
79
+ status: { running: "Placing your order…", done: "Order placed" }, // at most 64 characters each
80
+ signIn: "required", // "required" (default) | "optional" | "none"
81
+ hints: { readOnly: false, destructive: false, idempotent: false, openWorld: false },
82
+ run: async (input, context) => pos.place(input, context.idempotencyKey),
83
+ });
84
+ ```
85
+
86
+ - `output`: over MCP the result is checked against it and sent as structured content, and the CLI
87
+ shows its fields with `--json` and `--fields`. Give it whenever the result has a fixed shape.
88
+ - `signIn`: "none" for what anyone may do signed out (the menu, opening hours), "optional" for what
89
+ works either way and does more signed in, "required" (the default) for everything about a
90
+ person's own account. It applies only where sign-in is set up (the `flow-framework-auth` skill).
91
+ - `hints` describe the tool to hosts, which ask the person before risky calls; nothing enforces
92
+ them. `readOnly` (default false: a tool counts as a write), `destructive` (a write that may delete
93
+ or overwrite; default true for writes), `idempotent` (calling again with the same input changes
94
+ nothing more; default false), `openWorld` (reaches outside the business's own systems, such as the
95
+ web; default true). Set them honestly: a read-only tool should say so, or hosts treat it as a
96
+ write and the CLI asks for `--yes`.
97
+ - `flow check` (from the agent's folder) refuses what cannot be served exactly: a name outside
98
+ 1 to 128 of `A-Z a-z 0-9 _ - .`, an empty description, a description naming a chat-only tool
99
+ (`send_*`, `share_updates`, `load_skill`), an input that is not a `z.object`, an input or output
100
+ with no exact JSON Schema (`z.date()`, transforms; use an ISO string), `readOnly` with
101
+ `destructive`, or a status line over 64 characters.
102
+
103
+ ## Tools from another MCP server
104
+
105
+ An existing MCP server's tools become the agent's own, so the agent uses them on WhatsApp or
106
+ iMessage like any tool:
107
+
108
+ ```ts
109
+ import { mcpTools } from "@flow-engineer/flow/mcp";
110
+
111
+ export const tools = [
112
+ ...myTools,
113
+ ...(await mcpTools({ name: "todoist", url: "https://ai.todoist.net/mcp", headers: { authorization: `Bearer ${process.env.TODOIST_KEY}` } })),
114
+ ];
115
+ ```
116
+
117
+ - They are pinned: `flow mcp update` (from the agent's folder) writes the server's tools to
118
+ `mcp.lock.json`, and only those are used. Commit it with the tests. A tool the server changes or
119
+ removes later is refused with a clear error until the lock is updated, tested and committed; a
120
+ tool it adds is not offered until then. `only: [...]` keeps a few of its tools, `prefix` keeps
121
+ two servers' names apart, and `live: true` skips the lock (for a server you do not build around).
122
+ - Each needs a `tests/tools/<tool>.yaml` like any tool. A tool without `readOnlyHint` counts as a
123
+ write. Keep keys in the environment, never in the lock.
124
+
52
125
  ## The tool's test file
53
126
 
54
127
  In tests no real tool runs: a model plays each tool from the scenario's world. `tests/tools/<tool>.yaml`
@@ -87,3 +160,7 @@ responses:
87
160
  never as written dates, which go stale.
88
161
  - Latency lives only here. Scenarios describe situations ("the point of sale is slow this
89
162
  morning"); the simulator picks the matching response and latency on each call.
163
+
164
+ ## Authenticated backend access
165
+
166
+ A tool can use `await context.credentials!.get<YourClient>("backend")` when the agent/server supplies a `credentials` provider. That provider resolves access from Flow Framework’s verified `context.key`/`context.person`, independently of model input. Share it between chat and MCP; never put credentials into tool schemas, outputs, errors or logs. See `auth-skill.md` for verified-phone and OAuth entry points.
@@ -0,0 +1,11 @@
1
+ import { type OAuthClientProvider } from "@modelcontextprotocol/client";
2
+ import type { CliConfig } from "./commands.ts";
3
+ export declare function configDir(config: CliConfig): string;
4
+ /** An OAuth client provider whose state lives in the CLI's auth file, for one server. */
5
+ export declare function storedProvider(config: CliConfig, server: string, redirectUrl?: string, signal?: AbortSignal): OAuthClientProvider;
6
+ /** Terminal HTTP uses the same stored OAuth session as MCP, including refresh and issuer binding. */
7
+ export declare function storedAccessToken(config: CliConfig, server: string, forceRefresh?: boolean, signal?: AbortSignal): Promise<string | undefined>;
8
+ export declare function isSignedIn(config: CliConfig, server: string): boolean;
9
+ export declare function signOut(config: CliConfig, server: string): void;
10
+ /** Sign in: the browser goes to the organization's own login, and comes back to a loopback port. */
11
+ export declare function signIn(config: CliConfig, server: string, signal?: AbortSignal): Promise<void>;
@@ -0,0 +1,147 @@
1
+ // The CLI's sign-in: the same OAuth a host such as Claude runs against the organization's MCP
2
+ // server, as a native app with a loopback redirect, so people sign in on the organization's own
3
+ // pages. Tokens are kept per server in ~/.config/<name>/auth.json (XDG_CONFIG_HOME), mode 600, and
4
+ // refreshed by the SDK. A token in <NAME>_TOKEN (CI, agents) is used as is and never stored.
5
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { createServer } from "node:http";
7
+ import { randomBytes } from "node:crypto";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { auth, discoverOAuthServerInfo, refreshAuthorization, } from "@modelcontextprotocol/client";
11
+ export function configDir(config) {
12
+ return config.directory ?? join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), config.name);
13
+ }
14
+ function authFile(config) {
15
+ return join(configDir(config), "auth.json");
16
+ }
17
+ function readAll(config) {
18
+ const file = authFile(config);
19
+ return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
20
+ }
21
+ function writeAll(config, all) {
22
+ mkdirSync(configDir(config), { recursive: true, mode: 0o700 });
23
+ const file = authFile(config);
24
+ writeFileSync(file, JSON.stringify(all, null, 2) + "\n", { mode: 0o600 });
25
+ chmodSync(file, 0o600);
26
+ }
27
+ /** An OAuth client provider whose state lives in the CLI's auth file, for one server. */
28
+ export function storedProvider(config, server, redirectUrl, signal) {
29
+ const read = () => readAll(config)[server] ?? {};
30
+ const write = (change) => {
31
+ signal?.throwIfAborted();
32
+ const all = readAll(config);
33
+ all[server] = { ...all[server], ...change };
34
+ writeAll(config, all);
35
+ };
36
+ const metadata = {
37
+ client_name: `${config.name} CLI`,
38
+ application_type: "native",
39
+ redirect_uris: redirectUrl ? [redirectUrl] : [],
40
+ grant_types: ["authorization_code", "refresh_token"],
41
+ response_types: ["code"],
42
+ token_endpoint_auth_method: "none",
43
+ };
44
+ return {
45
+ get redirectUrl() {
46
+ return redirectUrl;
47
+ },
48
+ get clientMetadata() {
49
+ return metadata;
50
+ },
51
+ ...(config.clientMetadataUrl ? { clientMetadataUrl: config.clientMetadataUrl } : {}),
52
+ clientInformation: () => config.clientId ? { client_id: config.clientId } : read().client,
53
+ saveClientInformation: (client) => write({ client }),
54
+ tokens: () => read().tokens,
55
+ saveTokens: (tokens) => write({ tokens, tokensSavedAt: Date.now() }),
56
+ state: () => { const state = randomBytes(32).toString("base64url"); write({ state }); return state; },
57
+ discoveryState: () => read().discovery,
58
+ saveDiscoveryState: (discovery) => write({ discovery }),
59
+ redirectToAuthorization: async (url) => {
60
+ if (config.openUrl)
61
+ await config.openUrl(url.toString());
62
+ else
63
+ console.error(`Open this link to sign in:\n ${url}`);
64
+ },
65
+ saveCodeVerifier: (verifier) => write({ verifier }),
66
+ codeVerifier: () => {
67
+ const verifier = read().verifier;
68
+ if (!verifier)
69
+ throw new Error("No sign-in in progress.");
70
+ return verifier;
71
+ },
72
+ };
73
+ }
74
+ /** Terminal HTTP uses the same stored OAuth session as MCP, including refresh and issuer binding. */
75
+ export async function storedAccessToken(config, server, forceRefresh = false, signal) {
76
+ signal?.throwIfAborted();
77
+ const saved = readAll(config)[server];
78
+ if (!saved?.tokens)
79
+ return undefined;
80
+ const { tokens } = saved;
81
+ const expired = saved.tokensSavedAt !== undefined && tokens.expires_in !== undefined && saved.tokensSavedAt + tokens.expires_in * 1000 <= Date.now() + 30_000;
82
+ if (!expired && !forceRefresh)
83
+ return tokens.access_token;
84
+ if (!tokens.refresh_token || (!saved.client && !config.clientId))
85
+ return undefined;
86
+ const info = await discoverOAuthServerInfo(server);
87
+ const issuer = info.authorizationServerMetadata?.issuer ?? String(info.authorizationServerUrl);
88
+ if (tokens.issuer && tokens.issuer !== issuer)
89
+ throw new Error("The authorization server changed; sign in again.");
90
+ const fresh = await refreshAuthorization(info.authorizationServerUrl, { metadata: info.authorizationServerMetadata, clientInformation: config.clientId ? { client_id: config.clientId } : saved.client, refreshToken: tokens.refresh_token, resource: info.resourceMetadata?.resource });
91
+ await storedProvider(config, server, undefined, signal).saveTokens({ ...fresh, issuer });
92
+ return fresh.access_token;
93
+ }
94
+ export function isSignedIn(config, server) {
95
+ return Boolean(readAll(config)[server]?.tokens);
96
+ }
97
+ export function signOut(config, server) {
98
+ const all = readAll(config);
99
+ delete all[server];
100
+ writeAll(config, all);
101
+ }
102
+ /** Sign in: the browser goes to the organization's own login, and comes back to a loopback port. */
103
+ export async function signIn(config, server, signal) {
104
+ signal?.throwIfAborted();
105
+ const callback = createServer();
106
+ await new Promise((resolve, reject) => { callback.once("error", reject); callback.listen(config.redirectPort ?? 0, "127.0.0.1", resolve); });
107
+ const { port } = callback.address();
108
+ const redirectUrl = `http://127.0.0.1:${port}/callback`;
109
+ let cancel;
110
+ try {
111
+ const provider = storedProvider(config, server, redirectUrl, signal);
112
+ const code = new Promise((resolve, reject) => {
113
+ cancel = () => reject(new Error("Sign-in cancelled."));
114
+ signal?.addEventListener("abort", cancel, { once: true });
115
+ if (signal?.aborted)
116
+ cancel();
117
+ callback.on("request", (req, res) => {
118
+ const url = new URL(req.url ?? "/", redirectUrl);
119
+ if (url.pathname !== "/callback")
120
+ return void res.writeHead(404).end();
121
+ const error = url.searchParams.get("error");
122
+ const got = url.searchParams.get("code");
123
+ if (req.method !== "GET" || req.headers.host !== `127.0.0.1:${port}` || !readAll(config)[server]?.state || url.searchParams.get("state") !== readAll(config)[server]?.state) {
124
+ res.writeHead(400, { "content-type": "text/plain" }).end("Invalid sign-in response.");
125
+ return;
126
+ }
127
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end(got ? `Signed in. You can close this tab and go back to ${config.name}.` : `Sign-in failed: ${error ?? "no code"}.`);
128
+ if (got)
129
+ resolve({ code: got, iss: url.searchParams.get("iss") ?? undefined });
130
+ else
131
+ reject(new Error(`Sign-in failed: ${error ?? "no code came back"}.`));
132
+ });
133
+ });
134
+ // Cancellation may arrive while discovery or the authorization request is still in flight.
135
+ void code.catch(() => { });
136
+ const first = await auth(provider, { serverUrl: server });
137
+ if (first === "AUTHORIZED")
138
+ return;
139
+ const answer = await code;
140
+ await auth(provider, { serverUrl: server, authorizationCode: answer.code, iss: answer.iss });
141
+ }
142
+ finally {
143
+ if (cancel)
144
+ signal?.removeEventListener("abort", cancel);
145
+ callback.close();
146
+ }
147
+ }