For coding agents
Build, connect, test and deploy a Flow project with CLI exit codes and next steps.
The published guide is at https://docs.flow.engineer/agents: that is the link to give.
You are building or changing a Flow project for a person (the owner): tools and skills exposed
through an MCP server (Claude, ChatGPT, Cursor, Codex), plugins, a CLI or a messaging agent on
WhatsApp, iMessage, Telegram, SMS or their own app. Start with the interfaces the owner needs.
Every command here runs without a terminal and never reads stdin, except chat, which reads the
messages you pipe to it.
Always name the package: npx @flow-engineer/flow .... npx flow on its own fetches another npm
package.
Starting from the owner’s product
Section titled “Starting from the owner’s product”When the owner has a product (a codebase), the agent is built from it:
- Read the product’s code (what it does, who uses it, its API, how users sign in). Not in its folder? Ask the owner for the project’s path or a few lines about it.
- Propose what goes first and wait for a yes: an MCP server and a CLI for a business or developer product (SaaS, B2B, a dev tool); a WhatsApp and iMessage agent, plus the MCP server and CLI, for a consumer app. Ask what to call the agent in the same message.
npx @flow-engineer/flow@latest new <name>-agent --display-name "<name>"beside the product’s code (never inside its package), thennpm installthere.- Read
.claude/skills/flow-framework/SKILL.mdfor setup and routing. Then load only the relevant skills:flow-framework-build-agentfor messaging runtime and operations,flow-framework-mcpfor MCP, CLI and plugins, and the context, tools, auth or tests skills when those tasks apply. - Replace the café with the product: context, tools,
agent/AGENTS.md, sign-in, then check it (npx @flow-engineer/flow check,npm run mcp,npm run cli -- --help, and the phone below).
MCP and CLI first
Section titled “MCP and CLI first”After creating the project and running npm install, start the server with npm run mcp.
In another terminal, use npm run cli -- --help and npm run cli -- menu --json. The starter
prints the MCP URL and a command to connect Claude Code. These tool calls need no model key or
Flow account. Replace the café tools and configure account sign-in when needed.
For remote hosts such as Claude or ChatGPT, expose the server over HTTPS and connect its URL in the host. Generating a plugin does not publish it to a directory; follow the host’s installation or review flow. See MCP servers, plugins and CLIs.
In Framework 0.8.0, tool({ ..., mcp: { deferred: true } }) puts a less-used tool behind MCP’s
search_tools and execute_tool; unmarked tools stay upfront. Chat agents and CLI commands keep
their normal access. mcpServer({ deferred: [...] }) replaces the individual flags for that server,
and deferred: [] puts everything upfront. See deferred tools.
Messaging setup
Section titled “Messaging setup”For phone testing, link sets up the keys and an outbound event stream, with no tunnel or public
URL needed on the developer’s laptop. For the chat agent, it can also supply a trial model key.
npx @flow-engineer/flow init --display-name "FlatX agent" # the starter in this (empty) folder; ask the owner what to call it firstnpm installnpx @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 linknpm start # after link: keep it running; the playground link it prints, and the phone link reaches itnpx @flow-engineer/flow status --json # any time: what the folder has and lacksprintf 'hi\nA latte please\n' | npx @flow-engineer/flow chat # your own check: talk to it locally, one message per lineBefore init (or flow new <folder>), ask the owner what people should call their agent, such as
“FlatX agent”, and pass it as --display-name. It is kept as displayName in package.json, and
the message their phone sends to reach the agent is Hi FlatX agent (ref <code>). The name takes the
starter’s (Pip) place in the agent’s prompt, agent/AGENTS.md, and the playground and MCP server
read it from package.json. To rename an agent made already, link --display-name "<name>" (or
setup --display-name) changes both; restart npm start after it. Without the flag, init asks in
a terminal and otherwise uses the folder’s name in words.
npm start listens on PORT, else 8788: with 8788 taken and no PORT set it takes the next free
port and says so, and the playground link it prints has the port it uses. With PORT set to a port
that is taken it prints one line, Port 8788 is in use. Run: PORT=8798 npm start, and exits 1.
npm run mcp does the same with MCP_PORT (else 8790).
How the owner tests it
Section titled “How the owner tests it”Match the way they test to the interface they are building. For MCP, connect the server in their chosen host and try a real product request. For a CLI, show its help and a command that reads data. For a plugin, install the generated package in the host and exercise its tools and skills.
For a messaging agent, recommend these, in this order:
- The playground in the browser:
npm start, then open the link it prints. The test people (testPeople; Maria, Sam and Priya in the starter) and the channels are side by side, and Debug shows each tool call and how fast each message was answered. - Their phone: the link from
link(say_to_personinlink --json), whilenpm startruns. It is the channel’s own link (url):https://t.me/<bot>?start=<code>(press Start),https://wa.me/<number>?text=...or, for iMessage,sms:<number>?&body=...(an iPhone or a Mac), each withHi <displayName> (ref <code>)filled in (join_message).pageis the same on flow.engineer, for a computer without that app.
flow chat and npm test -- --synthetic (the test cloud) are for you, to check your own work
before you hand it back; run them yourself, and do not offer them to the owner as ways to test.
- The model key: a provider key is optional.
setup(whichlinkruns; alone it needsloginfirst) writesFLOW_KEY, the agent’s one Flow key, into.env(printed only as its prefix and last 4,FLOW_KEY=flow_test_…abcd added; never read or print its value): with no provider key the agent calls models through Flow with it, on the account’s $2 of Flow trial credit (printed asModel: Flow trial credit ($1.87 left)). The owner may add their own: one ofOPENROUTER_API_KEY,OPENAI_API_KEY,ANTHROPIC_API_KEYorGEMINI_API_KEYin.envwins overFLOW_KEYfor the model (to switch back, empty that line), and a key they set is never written over. When the credit is used up, a model call fails with codetrial_credit_used(trial_credit_unavailablewhen the account gets none) and the next step: add your own key. With no key,chatandnpm startstop with one sentence andNext: run npx @flow-engineer/flow setup to get Flow's free trial credit, or add your own key.(chat --json:{"error":{"code":"no_model_key","message":...,"next":"npx @flow-engineer/flow setup"}}), andinit,setup,linkandstatussay so too, with the way to get one first innext. Each folder’ssetupmakes its ownFLOW_KEY(the credit is the account’s);setup --rotate keyreplaces a leaked one (the old one works 24 h more;--nowends it at once). - From 0.8.1, with a test traces key (
otk_test_, the Staging dashboard),setupalso writesFLOW_FRAMEWORK_TRACES_CONTENT=1below it, under a comment saying it sends message text there and that 0 sends metadata only, so playground and phone chats can be read and graded. It never does for a live key, or when.envalready has the line (any value), and only in the run that writes the key: a later run that keeps the key does not put back a line the owner deleted (to keep content off across--rotate traces, set it to 0). setup --workspace <name|acc_id>(andlink --workspace) puts the agent, its cloud and traces keys in that observe workspace of yours and keeps itsacc_id in.envasFLOW_FRAMEWORK_WORKSPACE, which later runs send; another workspace makes those two keys again (403not_a_member, 409workspace_ambiguous: pass the id).statusreportsFLOW_KEYand the model key (never their values;flow_key.setin--json; withFLOW_KEYthe model’s key, the credit left from Flow,model_key.flow_credit; a key Flow does not know getssetup --rotate key), the workspace (FLOW_FRAMEWORK_WORKSPACE, elsepersonal), the sign-in, each channel’s sender in.envand, signed in, which channels are open now with their trial counts. It writes nothing.chatbuilds the agent fromsrc/agent.tsand writes as its first test person. Each reply is printed (agent: ...) once the turn is over;--jsongives one JSON line per thing it sends.linkdoes the rest when the folder is not set up (noFLOW_FRAMEWORK_JOIN_CODEorFLOW_MESSAGING_KEYin.env, or no sender for the WhatsApp or iMessage link asked for): it runslogin(opens the owner’s browser itself and prints the link and code at once), thensetup(writes the keys and each open channel’s sender into.env, and the chosen channel’s page asFLOW_FRAMEWORK_TRY_URL), then prints the link and that channel’s free-trial line only.--channel telegram|whatsapp|imessagepicks the channel (Telegram by default). Run it in the agent’s folder:setupandlinkrefuse a folder with nopackage.json(not_an_agent_folder).- A channel that is not open yet is said once (“iMessage isn’t open yet; here are your Telegram
and WhatsApp links”), with the open channels’ links, and exits 0:
link --jsonthen hasnot_open(the channel asked for),links(each open channel’s) andurl/channelfor the first of them. Only when no channel is open does it exit 3 (channel_not_open). - Texting needs only Flow Messaging’s part of setup (and a model key). When setup’s answer has it
(the join code, the Messaging key and senders) but observe’s part failed (traces and the test
cloud:
observe_failed, or observe’s own limit),linkprints one warning line on stderr,Traces and tests won't work until .env has FLOW_FRAMEWORK_CLOUD_KEY and FLOW_FRAMEWORK_TRACES_KEY: <the server's reason>. Then: <its next>, then the link, the channel’s trial line and itsNext:as usual, and exits 0;link --jsonhas the line inwarnings, on setup’s line and on its own. Give it to the owner;setupmakes the missing keys once it is fixed. Run again,linkfinds Messaging’s part in.envand does not run setup.setupon its own still exits 3 for it. - On WhatsApp and iMessage the agent can only reply after the owner writes first (WhatsApp: within
24 hours of their last message); it cannot message them first.
link --jsonsays so (reply_only: true, and insay_to_person): tell the owner. - The starter’s
npm startis on Telegram, plus WhatsApp and iMessage when.envhas their senders (FLOW_MESSAGING_WHATSAPP_SENDER,FLOW_MESSAGING_IMESSAGE_SENDER), and reads.envonce.npm startmarks itself running in.flow/running.json, solinkends with the step that fits:Next: npm start, then send the link abovewhen it is not running; when it is,Next: restart npm start (it runs at <url> and lacks iMessage), then send the link aboveiflinkjust wrote.envor renamed it, elseNext: send the link above (npm start is running at <url>)(link --json:running,falseor{"url"}, andnext).setupalone says “If npm start is running, restart it to add iMessage.” - Give the owner
say_to_personfromlink --json, and the sign-in link and code from thependingline while you wait. npm startconnects to Flow Messaging by an outbound event stream. Messages sent while it was stopped arrive when it starts again (for as long as Flow keeps events, 30 days by default).
Exit codes (login, setup, link)
Section titled “Exit codes (login, setup, link)”status exits 0 whatever it finds (1 for a wrong flag): read its fields.
| Code | Meaning | What to do |
|---|---|---|
| 0 | Done | Go on |
| 1 | Usage, not signed in, not an agent’s folder, or a login URL that is not https | Fix the command, or run next |
| 2 | flow.engineer could not be reached, or did not answer in time (timeout) | Try again |
| 3 | Refused: denied, expired, a product failed (for link, only when Messaging’s part did: observe’s alone is exit 0 with a warning), no channel open yet (one closed while others are open is exit 0 with their links), a server with no CLI sign-in (server_unsupported), or a free-trial limit (sandbox_allowance_used, the iMessage people cap; HTTP 403) | Run next; for a trial limit next is a sentence: give it to the owner as it is |
| 4 | Sign-in still waiting for the owner’s approval, or flow.engineer asked to wait (slow_down, HTTP 429) | Approval: ask the owner to approve, then run next (the same code is kept). slow_down: wait retry_after seconds (printed as Wait <n> s), then run next |
login and link wait at most --timeout seconds (120 by default) before exit 4, and no single
request waits longer than 30 s. login --wait (or link again) goes on with the same code: no new
code and no new browser tab. flow.engineer decides when a code has expired (an approved one can be
collected a while after its expires_in); a waiting code that has expired is replaced by one new
sign-in, and so is a code from another login URL.
The next lines
Section titled “The next lines”Every error is {"error":{"code","message","next"}} on stdout with --json, and
<message> Next: <next> on stderr. A waiting sign-in is {"status":"pending",...,"next":...}.
next is the exact command or URL to run; when it came from flow.engineer it is the server’s.
A run gives one next: setup --json’s is a list in order (a model key first when there is
none, then npm start for the playground, then link for the phone); inside link setup’s line
has none, and link’s next is one string (a model key first when there is none, then starting or
restarting npm start); an error’s next replaces it.
Run it, or give it to the owner when it needs them (a browser approval, a page to open). A
slow_down error also has retry_after (seconds, or null when the server gave none). A
trial limit’s next is a sentence, not a command: it is printed on its own line as it came; pass
it on to the owner unchanged.
flow.engineer may add warnings (up to 20 lines) to any setup answer. Each is printed on its own
line on stderr; with --json setup’s line has warnings (an empty list when there are none), and
an error line has it when there were any. Pass them on to the owner.
setup also shows the free trial: messages used per channel, and on iMessage the people it may
reach (iMessage: 1 of 2 people; trial.imessage.contacts {limit, used} in --json).
chat exits 1 for anything that stops it before the first message: no_model_key, no_agent
(no src/agent.ts), not_built, no_test_people, no_channel; with --json as
{"error":{"code","message","next"}} on stdout.
- Never ask the owner to paste a key into the chat, and never print, read aloud or commit
.env.setupwrites the keys itself and prints only their last 4 characters. - The model key: Flow’s trial key from
setup, or the owner’s own, typed into.envthemselves. setup --rotate cloud,traces,messaging,model(orall) makes those keys again and replaces their lines in.env.setupkeeps every value already in.env(--replacetakes the new ones), but a name there with no value (cp .env.example .env) counts as missing and is filled on its own line. Keys flow.engineer made are written even when the rest of setup fails, since they are shown only once..envis set to mode 0600.- The sign-in token goes only to the server that issued it, over https (http only to localhost):
with
FLOW_FRAMEWORK_LOGIN_URLnaming another server, commands stop withsigned_in_elsewhere. - CI never signs in:
npx @flow-engineer/flow setup --cisays how to give it the test cloud key.
Messages the business starts
Section titled “Messages the business starts”A nudge, a scheduled message or any turn the person did not start goes with link previews off
(preview: false on the message; Flow Messaging’s link_preview: false): on WhatsApp a
preview card’s title becomes the phone’s notification instead of the message’s text. For a call to
action use link (agent.nudge({ to, text, link: { label, url } }), or the agent’s send with
link), not a URL in the text.
When the business’s backend should tell people what happened (an order shipped, an item is back),
mount flowEvents instead of writing a route: one signed POST /v1/events, each event sent by its
delivery level (critical at once, standard after quiet hours, optional merged or dropped), with the
agent’s own wording checked against the template’s values. The envelope, signing, answers and a
mounting sample are in docs/events.md in the package
(https://github.com/flow-engineer/flow/blob/main/docs/events.md).
The agent’s own records
Section titled “The agent’s own records”What the agent itself must remember across days and people, and nothing else reads (a buyer’s
question waiting for the seller’s answer), goes in records: collections declared on
createAgent({ records: { questions: { people: ["buyer", "seller"], fields: ["listing", "status"] } } }),
context.records in a tool and agent.records elsewhere (put, get, query by equality on
declared fields, delete, forgetPerson, export). Put each field that holds a person
(context.person) in people, so forgetting them deletes the record. Keep records small (64 KB
each, 10,000 an agent): if anything other than the agent reads the data, it belongs in the owner’s
database, through a tool. npx @flow-engineer/flow records export prints them as JSON Lines.
Needs 0.6.0 or later. See the agent’s own records.
Wake-ups
Section titled “Wake-ups”When the agent must look again later and no one will write (no answer from the seller in 24
hours), use a wake-up, never a timer or setTimeout. It is a row in the store that gives the agent
a turn in that conversation at its time, with its note, and sends nothing by itself. The model has
wake_me_up and cancel_wake_up built in (createAgent({ wakeUps: false }) removes them).
From a tool, context.wakeUps?.add({ inMs, note, key }) sets one for this person, and
context.wakeUps?.cancel(key) cancels the active ones with that key in every conversation. Outside
a turn, use agent.wakeUp({ to, ... }), agent.cancelWakeUp(key) and agent.listWakeUps(to).
A wake-up keeps quiet hours. After STOP or during a pause it runs, but Flow refuses any send to the person in that turn. Past WhatsApp’s window, only a template reaches them. Limits: 10 per person, 10,000 per agent, 90 days ahead.
agent.cancelNudge(key) cancels a nudge sent with at, by its idempotency key. Needs 0.8.0 or
later. See wake-ups.
Shared test numbers and privacy
Section titled “Shared test numbers and privacy”The free trial runs on Flow’s shared Telegram bot, WhatsApp number and iMessage line. Sending an
agent’s join message (Hi <name> (ref <code>), what the link fills in) binds that phone to that
agent’s app on the shared sender, so the person’s messages go only to that app; sending another
app’s join message moves them there. Trial chats are
test data, kept for the account’s retention (30 days by default). Do not use the trial for real
customers’ data: connect your own number for that.
Stability
Section titled “Stability”Flow Framework is 0.x. Existing commands, --json fields and exit codes remain compatible;
a breaking change requires a minor version bump and a migration note. Pin the version in
package.json for production.
Host it
Section titled “Host it”npx @flow-engineer/flow deploy puts the agent on Flow’s hosting, so it answers while the laptop
sleeps. It needs package.json, package-lock.json and src/agent.ts, whose buildAgent passes
options.host to createAgent as host: options.host (never spread into its options), and the agent’s CI key in FLOW_FRAMEWORK_CLOUD_KEY (the
environment, else .env; flow setup writes it).
One command deploys, the first time included:
npx @flow-engineer/flow deployIt sends the .env’s values as the agent’s secrets, then builds and follows it to live. The rest:
npx @flow-engineer/flow deploy [--env staging|production] [--version <v>] [--no-secrets] [--json]npx @flow-engineer/flow deploy promote [--no-secrets] [--json]npx @flow-engineer/flow deploy status [--env staging|production] [--json]npx @flow-engineer/flow logs [--env staging|production] [--since 1h] [--follow] [--json]npx @flow-engineer/flow deploy secret NAME [--env staging|production] < value.txtnpx @flow-engineer/flow deploy secrets --from-env [<file>] [--env staging|production] [--json]deployfirst sends each non-empty value in the agent’s.envas a secret for that environment, before the service starts, so the first live version has its keys: never the CI key, the cloud’s address or a name Flow sets itself (FLOW_FRAMEWORK_TRACES_*,FLOW_STORE_*,PORT, …). Later deploys send only the new or changed ones (.flow/secrets.jsonkeeps a hash per name, never a value). It prints the names it sent, never values;--no-secretssends none. A secret the host refuses stops the deploy before it starts, naming it.deploypacks the folder (withoutnode_modules,.git,.env*and.flow, at most 50 MB), uploads it, and follows the build until it is live, refused or failed. The default is staging; production refuses an agent that ignoreshostor brings its own database, and the build refuses an agent on a newer framework than Flow hosts yet.--jsonprints{ ok, deploy: { id, environment, status, version, findings, error }, url, next }on stdout (urlis null when the cloud does not say it yet; the human output then says “URL: not available on this Flow yet”); progress goes to stderr. The deploy’s id is kept in.flow/deploys.json. The version is the folder’s git commit,<sha>+dirty(with a warning) when the folder has uncommitted changes, since what is packed is the folder as it is;--versionnames it yourself.promotemoves staging’s live version to production, with no new build. Production keeps its own secrets: promote sends the.env’s new or changed values there first (--no-secretssends none).statusshows, per environment (both unless--env), the deploy serving it (status,version,id,updated_at), its URL and a newer one still building.--jsonprints{ ok, environments: { staging: { deploy, pending, url, from } | null, production: ... }, next };fromislist(the cloud’s list) orthis_folder(the last deploy from.flow/deploys.json, on a Flow whose cloud does not list deploys to a CI key yet).logsprints the hosted agent’s log lines from--since(30s, 15m, 1h, 2d; default 1h), and with--followkeeps printing new ones;--jsonis one{ time, severity, text }per line. On a Flow whose cloud has no logs route yet it says so:error.codenot_available, exit 1.secret NAME(one value from stdin) andsecrets --from-env(every value in a file, now) are for changing a secret by hand;deployalready sends the.env.--jsonprints{ ok, environment, saved, skipped, refused, next }. Before the agent’s first deploy the host has no agent to keep them for: they are refused, exit 1, andnextisdeploy. A secret takes effect on the next deploy.- Exit codes: 0 live or saved, 1 refused, failed or not reachable, 2 a usage problem (no CI key, a
missing file, a bad name). On an error,
--jsonprints{ ok: false, error: { code, message }, next }.
Run each answer’s next. Promote, or deploy to production, only when the owner says so.
Thanks, we read every comment.