@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.
- package/README.md +167 -10
- package/coding-agents/auth-skill.md +137 -0
- package/coding-agents/context-skill.md +94 -0
- package/coding-agents/skill.md +219 -17
- package/coding-agents/test-skill.md +3 -1
- package/coding-agents/tools-skill.md +78 -1
- package/dist/cli/src/auth.d.ts +11 -0
- package/dist/cli/src/auth.js +147 -0
- package/dist/cli/src/commands.d.ts +119 -0
- package/dist/cli/src/commands.js +212 -0
- package/dist/cli/src/index.d.ts +2 -0
- package/dist/cli/src/index.js +5 -0
- package/dist/cli/src/run.d.ts +28 -0
- package/dist/cli/src/run.js +523 -0
- package/dist/cli/src/terminal.d.ts +3 -0
- package/dist/cli/src/terminal.js +135 -0
- package/dist/flow-messaging/src/client.d.ts +2 -2
- package/dist/flow-messaging/src/client.js +3 -3
- package/dist/flow-messaging/src/index.js +17 -9
- package/dist/mcp/src/client.d.ts +54 -0
- package/dist/mcp/src/client.js +175 -0
- package/dist/mcp/src/describe.d.ts +41 -0
- package/dist/mcp/src/describe.js +110 -0
- package/dist/mcp/src/index.d.ts +4 -0
- package/dist/mcp/src/index.js +7 -0
- package/dist/mcp/src/server.d.ts +70 -0
- package/dist/mcp/src/server.js +252 -0
- package/dist/mcp/src/terminal.d.ts +24 -0
- package/dist/mcp/src/terminal.js +54 -0
- package/dist/simulators/src/index.d.ts +1 -1
- package/dist/simulators/src/page.html +267 -49
- package/dist/simulators/src/server.d.ts +27 -0
- package/dist/simulators/src/server.js +264 -40
- package/dist/src/agent.d.ts +69 -3
- package/dist/src/agent.js +162 -8
- package/dist/src/channels.d.ts +6 -4
- package/dist/src/channels.js +4 -3
- package/dist/src/conversation.d.ts +17 -1
- package/dist/src/conversation.js +170 -16
- package/dist/src/events.d.ts +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +1 -1
- package/dist/src/model.d.ts +27 -0
- package/dist/src/model.js +85 -13
- package/dist/src/skills.d.ts +4 -0
- package/dist/src/skills.js +23 -0
- package/dist/src/store.d.ts +2 -0
- package/dist/src/store.js +18 -0
- package/dist/src/tools.d.ts +69 -0
- package/dist/src/tools.js +17 -1
- package/dist/src/traces.js +32 -4
- package/dist/src/types.d.ts +5 -0
- package/dist/testing/src/account.d.ts +1 -1
- package/dist/testing/src/account.js +368 -57
- package/dist/testing/src/chat.js +24 -16
- package/dist/testing/src/check.d.ts +4 -0
- package/dist/testing/src/check.js +90 -0
- package/dist/testing/src/cli.js +77 -19
- package/dist/testing/src/flow-login.d.ts +33 -0
- package/dist/testing/src/flow-login.js +30 -5
- package/dist/testing/src/new.d.ts +14 -5
- package/dist/testing/src/new.js +54 -20
- package/dist/testing/src/plugin.d.ts +4 -0
- package/dist/testing/src/plugin.js +116 -0
- package/docs/agents.md +87 -12
- package/examples/cafe/src/cli.ts +24 -0
- package/examples/cafe/src/mcp.ts +22 -0
- package/examples/cafe/src/tools.ts +13 -0
- package/flow-messaging/README.md +3 -1
- package/package.json +28 -1
package/coding-agents/skill.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
14
|
-
tool,
|
|
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.
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
93
|
-
|
|
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>`.
|
|
299
|
-
|
|
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
|
|
305
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
npx @flow-engineer/flow
|
|
311
|
-
|
|
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
|
+
}
|