@flowmails/cli — the flowmails command
The Flowmails CLI: a thin, scriptable shell around the official SDK
(@flowmails/flowmails-sdk). It exists so an agent — or a
human — can check a connection, look at the mailboxes an account
owns, tail the inbound log and wait for a message without
hand-rolling HTTP.
It implements AP-06 of
docs/agent-platform.md (§6.3, §10.4).
Install
npm i -g @flowmails/cli
The binary is called flowmails:
flowmails --help
The SDK it needs
The CLI is built on @flowmails/flowmails-sdk and keeps it
external — the published binary imports the SDK at runtime rather
than carrying it inside the bundle. The published manifest therefore
pins an exact SDK version, and the CLI needs:
@flowmails/cli | 0.1.1 |
@flowmails/flowmails-sdk | 0.2.1 (exact) |
flowmails --version prints the CLI's version on standard output and
the SDK it needs on standard error; flowmails --help states it too.
The pin is a contract, not a formality: 0.2.0 is the newest SDK on
the public registry, and it has no fm.workerVersion() — the only
way an agent can ask "is my receive worker new enough to fill my event
log?". A CLI pinned to 0.2.0 cannot answer that question for its
user (audit P0-4, 2026-10-31).
Publish order matters. The SDK must reach npm before the CLI, because the CLI's install pulls the exact version the manifest names. From the repo root:
pnpm run publish:sdk # @flowmails/flowmails-sdk 0.2.1
pnpm run publish:cli # @flowmails/cli 0.1.1
To work on the CLI itself, install it from a checkout instead:
# from the repo root
pnpm install
pnpm --filter @flowmails/cli build
# put `flowmails` on your PATH (a symlink into the checkout)
cd packages/cli && npm link
Without linking, run the bundle straight from the checkout:
node packages/cli/dist/cli.js --help
The build needs the SDK's bundle, because the CLI is built on it:
pnpm --filter @flowmails/flowmails-sdk build # builds packages/sdk/dist
pnpm --filter @flowmails/cli build # bundles packages/cli/dist
Both build and typecheck in this package run the SDK build first,
so pnpm --filter @flowmails/cli build on its own is enough.
Check it:
flowmails --help
The human does the one-time onboarding
The CLI cannot onboard an account, and neither can an agent. These steps are dashboard-only, by design:
- create the Flowmails account and sign in;
- connect a Cloudflare account;
- add and verify a domain (the DNS records are yours);
- deploy the per-account receive worker;
- mint the API key itself.
Do them once at https://flowmails.net/dashboard/onboarding**, then create a key at https://flowmails.net/dashboard/settings/api-keys** and hand it to the CLI. After that, everything the CLI does works from the command line — including creating a mailbox and waiting for the first mail.
flowmails login — store a credential
The device flow is not implemented yet (AP-07). There is no device endpoint to talk to, so
flowmails logindoes not authenticate for you: it stores a key you paste. It says so every time it runs — the CLI never pretends to be logged in when it is not.
flowmails login # prompts for the key (input is not echoed)
flowmails login --key fm_… # non-interactive
cat key.txt | flowmails login # non-interactive, from stdin
The key is validated by shape and stored in the config file with mode
0600. No command ever prints a raw key — output shows a short
prefix (fm_012…) and nothing else.
Credential types
| Prefix | What it is |
|---|---|
fm_… | The SDK key. One tenant, bound to one domain for sending. |
agc_… | The agent credential. The same tenant plus a scope list. |
Both are accepted by flowmails login.
flowmails test — the connection check
This is the first thing the install instructions tell you to run, and the first signal in the "time to first agent action" metric. It is quiet (one line on stdout, the steps on stderr) and decisive (the exit code means something).
flowmails test
# flowmails test: OK — credential fm_012…, domain yourapp.com, API alive (no message sent), domain can send
It checks four things:
- Auth — the credential is accepted by the platform.
- The domain — which domain the account owns, and which one the
key is bound to. (The platform does not expose the binding to a
scoped credential —
GET /api/v1/meis the SDK key's surface — so with several domains the CLI says "unknown" rather than guessing.) - API liveness — the send origin answers for this credential. No mail is sent.
- The send capability — whether the bound domain can actually
send. The platform reports it on the agent's domain projection
(
canSend+sendingState) and onGET /api/v1/me.
Step 3 is a liveness check, not a send
The probe reads the status of a message id that does not exist, so a
404 is the answer that proves the API is reachable. That is all
it proves. The command used to call it "send reachability" and print
flowmails test: OK for a domain whose sends failed 100% of the
time — a false positive on the first command a new user runs (audit
P0-2, 2026-10-31).
So the claim is now the claim: the check says "API alive", and the capability is read from the platform.
The verdict
| Verdict | Exit | Meaning |
|---|---|---|
OK | 0 | Authenticated, the API is alive, and the bound domain can send. |
UNVERIFIED | 0 | Authenticated, the API is alive, but the platform does not report whether the domain can send (an older backend). This is not a pass — verify with flowmails test --send-to <addr>. |
FAILED | 1 | Something is broken: the credential was rejected, the platform was unreachable, or the bound domain cannot send. The message names the domain. |
A domain that cannot send never prints flowmails test: OK.
flowmails test
# ✓ auth ok (fm_012…)
# ✓ domains: mysoul.fun
# ✓ bound domain: mysoul.fun
# ✓ API alive (no message sent)
# ✓ domain mysoul.fun cannot send (not_onboarded)
# ✗ mysoul.fun cannot send (sending state: not_onboarded)
# flowmails test: FAILED — fm_012…, mysoul.fun cannot send (sending state: not_onboarded)
# $? = 1
(The first five lines are the progress trail on stderr; the
FAILED line is the summary, also on stderr. With --json the summary
is one JSON object on stdout instead.)
Sending for real is opt-in
Nothing is sent by default. A real message is only sent when you ask for one:
flowmails test --send-to [email protected]
--from <local@domain> overrides the sender address when the account
owns several domains. A send that the platform recorded as failed
(status: "failed", audit P0-1) fails the check too, with the
platform's reason when it gives one.
Machine-readable output
flowmails test --json
# {"ok":true,"verdict":"ok","credential":"fm_012…","domains":[…],"boundDomain":"yourapp.com",
# "api":{"alive":true},"send":{"canSend":true,"state":"ready","verifiedBy":"capability","sent":null}}
verdict is ok, unverified or failed; send.canSend is null
when the platform does not report it.
flowmails addresses — the mailboxes you own
flowmails addresses
# yourapp.com (catchall, ready)
# [email protected]
flowmails addresses --json
# {"domains":[{"id":"dom_1","name":"yourapp.com","routingMode":"catchall","ready":true,…}]}
On a catchall domain every local part delivers; on a rules domain
only the listed ones do — the listing tells you which.
flowmails watch <addr> — tail the inbound log
flowmails watch [email protected]
{"id":"evt_…","messageId":"<…>","event":"inbound.received","recipient":"[email protected]","sender":"[email protected]","subject":"Your invoice","createdAt":"…"}
One JSON object per line, newline-delimited, so it pipes straight into
jq or a line reader. The event carries routing facts and the subject
— never a body; fetch the body with the SDK's messages.get().
flowmails wait <addr> — wait for a message
flowmails wait [email protected]
# <3f8a…@stripe.example> # the resolved SMTP Message-ID
flowmails wait [email protected] --subject invoice --sender [email protected] --timeout 5m
The wait is durable on the platform: it answers immediately, it resolves when a matching message lands, and it survives the CLI being killed. Standard output carries exactly one thing — the resolved message id — so the command can be used in a substitution:
id=$(flowmails wait [email protected])
The events observed while waiting are JSON lines on stderr.
Expiry is not a failure. A wait that reaches its deadline without
a matching message exits 3 (expired); a wait that could not do its
job exits 1 (failure). They used to be the same code, which left a
script unable to tell "timed out" from "broken" (audit P1-6,
2026-10-31).
Resuming after a disconnect
watch and wait persist their cursor — the id of the last event
they printed — to the state file, and resume from it on the next run:
- the cursor is an event id, never a timestamp (two events can land in the same millisecond, and a cursor that can skip a row loses mail);
- it is written after each event is printed, so a crash between two events replays at most the last one.
That is at-least-once delivery, and it is the right trade: the
alternative is silently losing mail. If your consumer needs
exactly-once, dedupe on messageId.
A failed page (a dropped socket, a 502) does not end the command: the
error is reported on stderr and the next poll resumes from the cursor
that is already on disk. A cursor the platform does not know is a
different failure — the platform answers 400, not an empty page, so
watch stops and says to delete the state file rather than spinning or
silently replaying the whole log. wait additionally persists the
wait id, so a reconnect resumes the same wait instead of opening a second
one.
Files
| Path | Mode | What it holds |
|---|---|---|
~/.config/flowmails/config.json | 0600 | The credential, and optional baseURL / platformURL overrides. |
~/.config/flowmails/state.json | 0600 | The cursors (and the wait id) per command and mailbox. |
The directory is created with mode 0700. Both paths are
overridable:
flowmails --config /path/to/config.json test
FLOWMAILS_CONFIG=/path/to/config.json flowmails test
FLOWMAILS_STATE=/path/to/state.json flowmails watch [email protected]
$XDG_CONFIG_HOME is honoured for the default location.
Command surface
flowmails login [--key <key>] Store a credential (paste-key fallback)
flowmails test [--send-to <addr>] Connection check: auth, domain, API liveness, send capability
flowmails addresses [--json] List the account's domains and mailboxes
flowmails watch <addr> Tail inbound events as JSON lines
flowmails wait <addr> [--subject <s>] [--sender <s>] [--timeout <ms>]
Wait for a message and print its id
Global options: --config <path>, --state <path>, --json,
-h/--help, -v/--version.
flowmails <command> --help prints that command's own help — its
options and the exit-code table.
--timeout accepts milliseconds (60000) or a duration (30s, 5m,
1h).
Exit codes
The exit codes are a contract: a script branches on them, so a code that means two things is a bug.
| Code | Meaning |
|---|---|
0 | Success — the command did what it was asked to do. |
1 | Failure — the credential was rejected, the platform was unreachable, the bound domain cannot send, or the command hit an error. |
2 | Usage error — the arguments did not parse. |
3 | Expired — flowmails wait reached its deadline without a matching message. |
3 is separate from 1 on purpose: "timed out" and "broken" are
different answers, and a script that cannot tell them apart retries a
broken setup forever and gives up on a healthy mailbox that simply
had no mail (audit P1-6, 2026-10-31).
flowmails test exits 0 for both OK and UNVERIFIED — nothing is
known to be broken in the second case — so read the verdict in the
summary (OK / UNVERIFIED / FAILED), not just the exit code.
What this package is not
- It is not an onboarding tool. See the boundary above.
- It is not an MCP server (that is AP-09) and it does not expose the ten agent tools — it is a command-line surface over the same SDK.
- It does not implement the device flow (AP-07).
- It does not expose per-tool scopes (AP-03) — the SDK key is tenant-wide read plus domain-bound send.
Development
pnpm --filter @flowmails/cli typecheck
pnpm --filter @flowmails/cli build
node --test ./test/cli-*.test.mjs # from the repo root
The command logic lives in small modules under src/commands/, and
the SDK client is injected through the runtime — so the tests drive
the commands without a network, without a process and without the
SDK's bundle.