Skip to main content

CLI API

@flow-state-dev/fsdev — Terminal interface for running flows, executing blocks, and inspecting definitions.

Commands​

fsdev run <flowId> <action>​

Execute a flow action with streaming NDJSON output.

pnpm fsdev run my-agent chat -i '{"message": "Hello!"}'

The first argument is the flow instance id — see Flows for how that id is formed. An id nothing is registered under fails, and the error lists the ids that are. Running a session through a copy that doesn't own it exits non-zero and leaves the session untouched.

Options:

FlagDescription
-i, --input <json>Inline JSON input
-f, --input-file <path>JSON input from file
-m, --model <model>Override model for generator blocks that run in this process. See Model overrides
-s, --session <id>Session ID for reuse across invocations
--org <id>Run in this organization and skip the app's resolver. See Who a run is
-u, --user <id>Run as this user. The organization comes from the app's resolver unless you also pass --org. Default: the user your app's resolver returns, or devuser (the DevTool's default user) if the app has none or you pass --org
--seed-session <json|path>Seed session-level state (JSON or file path). A new session is created with it, through the flow's stateSchema and session.createCheck. The run fails, and nothing is written, if either refuses it, or if it changes a readonly field of a session that already exists. See Example: one session per project
--flow-dir <path>Override flow discovery root (repeatable). Errors if a config is loaded.
--config <path>Load an explicit fsdev.config file instead of searching the cwd
--no-configIgnore any config and force directory discovery
--dotenv <path>Load a specific .env file before the cwd .env.local walk-up (repeatable, resolved from cwd)
--store <kind>Without a config: sqlite (default) or filesystem. See Where a run keeps its data. Errors if a config is loaded.

When a config is loaded, fsdev run looks up the flow by instance id in the config's registry and uses its stores. --model <id> still applies, routed through the config's own resolver (your gateways and providers stay in effect), and it covers the generators that run in this process but not background work handed to a queue. --flow-dir together with a config is an error; the message suggests --no-config if directory discovery is what you want. The config's FlowState is disposed on exit, and disposal waits for any background work the run started in this process. See App Configuration and Background work.

Without --org, the run is whoever your app's resolver says it is. See Who a run is.

NDJSON events:

Each line of stdout is a JSON object with a type field:

{"type":"item_added","item":{"id":"...","type":"message","role":"assistant"}}
{"type":"content_delta","itemId":"msg_1","delta":"Hello there!"}
{"type":"state_change","scope":"session","resourcePath":"counter","changeType":"update"}
{"type":"flow_complete","output":{"reply":"Hello there!"},"durationMs":1234,"items":3}

A run that ends waiting for a person ends with a suspended event instead of flow_complete:

{"type":"suspended","requestId":"req_...","sessionId":"sess_...","suspensionId":"susp_...","reason":"user_approval","message":"Publish \"Q3 notes\"?","answer":"Answer it in fsdev dev, which reads this store: .fsdev/data/fsdev.db (SQLite)."}
Event typeWhen it fires
item_addedA new output item (message, reasoning, tool call, etc.) was created
content_deltaIncremental content chunk for a streaming item
state_changeA scope state or resource was modified
flow_completeThe action completed successfully
suspendedThe action paused for a person and is waiting for an answer. The exit code is 0
errorThe action failed

When a run waits for a person​

A flow that pauses for a person (ctx.suspend(), an approval, a question) doesn't fail the run. The paused request is held in the store, and fsdev run exits 0 and prints a waiting report on stderr:

[fsdev] waiting for a person: Publish "Q3 notes"?
[fsdev] suspension susp_... · request req_... · session sess_...
[fsdev] Answer it in fsdev dev, which reads this store: .fsdev/data/fsdev.db (SQLite).
[fsdev] Its deadline fires only while a server runs on this store.

The same suspension is in --capture as result.suspended. Answer it from the DevTool's Suspensions tab in fsdev dev, started in the same folder, or through the resume route. A suspension's deadline passes unnoticed until a server runs on the same store. A server that starts after a deadline has passed expires that suspension as soon as it is up.

With an app config, the suspension is held in the app's stores and is answered by a server on them, such as fsdev dev with the same config.

Where a run keeps its data​

Without a config, fsdev run writes to .fsdev/data/fsdev.db in the working directory, the SQLite file fsdev dev and fsdev chat use, as the user the DevTool lists sessions for. A request a run left paused is listed by the fsdev dev you start in the same folder. SQLite lets a run write while fsdev dev is running; under heavy load, a run can briefly wait on the dev server's writes. Read a run's state through fsdev dev's routes or from the SQLite file. --capture holds the command, its events and its result, not session or resource state.

--store filesystem keeps the run's state as JSON files under .fsdev/data instead. That store is single-process: don't run fsdev run and fsdev dev on it at the same time. A suspension held there is answered by a fsdev dev --store filesystem started after the run exits. Sessions written to it are not visible to the SQLite default, and the other way round.

Session reuse:

# State persists between invocations with the same --session
pnpm fsdev run stateful increment -i '{"increment": 1}' --session my-session
# → {"count": 1}

pnpm fsdev run stateful increment -i '{"increment": 1}' --session my-session
# → {"count": 2}

fsdev dev​

Start an HTTP dev server that serves the flow API and DevTool UI together.

pnpm fsdev dev

Options:

FlagDescription
-p, --port <port>Port to listen on (default: 4200). 0 picks a free port.
--app <package|dir>Serve an app's pages at the root instead of the DevTool. See Serving an app beside your flows.
--host <host>Host to bind (default 127.0.0.1). A non-loopback host runs the same check as fsdev serve and refuses a config that hands its page a bearer token.
--allow-unauthenticatedWith a non-loopback --host, bind even when a flow has no authentication configured, as on fsdev serve.
--watchRestart when a file your config loaded changes, and reload the open pages. Loopback only. See Restarting as you edit.
--flow-dir <path>Override flow discovery root (repeatable). Errors if a config is loaded.
--config <path>Load an explicit fsdev.config file instead of searching the cwd
--no-configIgnore any config and force directory discovery
--dotenv <path>Load a specific .env file before the cwd .env.local walk-up (repeatable, resolved from cwd)
-m, --model <model>Override model for generator blocks that run in this process. See Model overrides. Errors if a config is loaded.
--no-openDon't open the browser automatically
--store <kind>Without a config: sqlite (default, .fsdev/data/fsdev.db) or filesystem (JSON files under .fsdev/data, single-process). Errors if a config is loaded.

Requires: @flow-state-dev/devtool installed (provides the pre-built UI assets). With --app it's optional.

Without a config, the server discovers flows, registers them in an in-memory flow registry, opens the SQLite store at .fsdev/data/fsdev.db (the one fsdev run writes to), and starts listening. It has durable execution, so a paused request is held and answered from the DevTool, and its sweeper fires suspension deadlines. With an fsdev.config.ts present, it serves the app's own router (await flowState.getRouter()) using the config's registry, resolver, and stores. Because the config builds the router with its own resolver, --model together with a config is an error. API routes are served at /api/flows/*. The DevTool UI is served for all other paths. See DevTool Setup and App Configuration for full details.

A non-loopback --host needs a config, so its flows' authentication can be checked. On such a host the debug surface the DevTool's Resources panel reads stays closed.

Restarting as you edit​

Add --watch and leave fsdev dev running while you work on your flows:

pnpm fsdev dev --config ./fsdev.config.mts --watch

When you save a file your config loaded, such as a flow module, any file in the config's folder, such as a WORKER.md read from disk, or a .env.local file fsdev loaded, the server restarts on the same port and any page open on it reloads by itself. Adding a new file to the config's folder, such as a new worker's WORKER.md, restarts it too. Saving a data file, something under node_modules, or a file in a folder whose name starts with a dot does nothing. With --port 0, the free port is picked once and kept across restarts.

A restart is a new process. A config with in-memory stores starts empty each time, so use SQLite if you want your data to survive an edit. If the server fails to start after a save, for example on a syntax error, the error prints and fsdev dev waits for the next save.

--watch binds loopback only: it's refused with a non-loopback --host.

Serving an app beside your flows​

fsdev dev normally puts the DevTool at the root of its port. With --app, your own app's pages go there instead and the DevTool moves to a port of its own:

pnpm fsdev dev --config ./fsdev.config.mts --app @acme/ops-console

--app takes a directory with an index.html, or a package that exports getAssetPath(), a function returning that directory. A relative path or a package name resolves from the directory you run the command in. The path of a package's module file names that package too, which lets a command that wraps fsdev dev pass itself from wherever it is run. The API stays at /api/flows on the same origin, so your pages call it with relative URLs and need no proxy.

Every HTML page fsdev serves carries two things your app can read on boot. The connection config from your config's devtool block is window.__FSD_DEVTOOL_CONFIG__, as the DevTool gets it, on a loopback host only. The DevTool's address is <meta name="fsdev-devtool-url" content="…">, absent when the DevTool isn't installed, or when --host is 0.0.0.0 or ::, since a browser can't reach an all-interfaces address. The command still prints the DevTool's port. In that case the app still starts, and the command says why there's no DevTool.

With --watch, a package that also exports getSourceRoot() is served from that folder through Vite, loaded from the app's own install, so its pages update as you edit them. The folder needs an index.html, and its HTML carries the same config and meta as built pages. When getSourceRoot() returns nothing, or Vite isn't installed there, the built pages are served and the command says so. Saving one of those page files doesn't restart the server.

This is for development. To host an app's pages in production, pass its directory as staticDir to serve().

fsdev serve​

Start a production HTTP server for the flow API and MCP endpoints, with no DevTool UI. This is the production counterpart to fsdev dev.

pnpm fsdev serve

Options:

FlagDescription
-p, --port <port>Port to listen on (default: $PORT, then 3000)
--host <host>Host to bind (default: $HOST, then 0.0.0.0)
--config <path>Load an explicit fsdev.config file instead of searching the cwd
--dotenv <path>Load a specific .env file before the cwd .env.local walk-up (repeatable, resolved from cwd)
--allow-unauthenticatedSkip the loopback-bind guard and allow a network bind even when a flow has no authentication configured

fsdev serve requires a committed fsdev.config.* that default-exports a FlowState. It does not do directory discovery; without a config it errors. There is no --flow-dir and no --no-config.

The two server commands differ in what they mount and where they bind. fsdev dev serves the flow API plus the DevTool UI, opens a browser, and binds a fixed port 4200 on a loopback host for local work. fsdev serve serves the flow API and MCP endpoints only, binds $PORT (then 3000) on $HOST (then 0.0.0.0) for a PaaS, and never mounts the DevTool SPA. Under the hood it wraps serve() from @flow-state-dev/node with no staticDir, so a non-API GET like /index.html returns 404. Bind defaults are the inverse of dev: serve reaches for a network host and an env-provided port, dev stays on loopback and 4200.

Loopback-bind guard. Before binding a non-loopback host, fsdev serve checks that every served flow has authentication configured. A flow whose authentication.resolvePrincipal is unset or left at the framework default runs on an unauthenticated principal, and exposing it on a network interface would open it to anyone who can reach the port. The guard refuses that bind. Resolve it one of three ways: configure authentication.resolvePrincipal on the flow, bind a loopback host (--host 127.0.0.1), or pass --allow-unauthenticated to opt out deliberately. Loopback binds skip the guard. A configured resolver protects the flow's whole API surface, session and resource endpoints included, so passing the guard means the deployment is covered rather than just its action routes. See Authentication.

Exit code 3 covers an invalid port, a missing committed config, a guard refusal, and a bind or listen failure such as EADDRINUSE or EACCES. The server shuts down gracefully on SIGTERM and SIGINT.

# Bind $HOST (then 0.0.0.0) on $PORT (then 3000)
pnpm fsdev serve

# Override the port from the environment
PORT=8080 pnpm fsdev serve

# Point at an explicit config file
pnpm fsdev serve --config ./fsdev.config.ts

See App Configuration for the config contract, Host adapters for the serve() wrapper this command uses, MCP Server for the MCP endpoints it stands up, and the Deployment overview for platform guidance.

fsdev chat [flow] [action]​

Start an interactive session (a REPL) over a flow. Type messages that route to a default target and stream replies back; use slash commands to switch the target and inspect the session.

pnpm fsdev chat hello-chat chat

Options:

FlagDescription
-s, --session <id>Resume an engine session for the initially bound flow. Requires a bound target.
-m, --model <model>Override model for generator blocks that run in this process. See Model overrides
-u, --user <id>Run turns as this user. The organization comes from the app's resolver unless you also pass --org. Default: the user your app's resolver returns, or devuser (the DevTool's default user) if the app has none or you pass --org
--org <id>Run turns in this organization and skip the app's resolver. See Who a run is
--flow-dir <path>Override flow discovery root (repeatable). Errors if a config is loaded.
--config <path>Load an explicit fsdev.config file instead of searching the cwd
--no-configIgnore any config and force directory discovery
--dotenv <path>Load a specific .env file before the cwd .env.local walk-up (repeatable, resolved from cwd)
--quietSuppress runtime logs on stderr
--log-level <level>Stderr log level: debug | info | warn | error (default: warn)

Built-in commands:

CommandDescription
/helpList the built-in commands
/targetsList available flow · action targets; the current default is marked
/use <flow> [action]Switch the default target
/statusShow target, session id, turn count, and runtime source
/session [new|<id>]Print, rotate (new), or bind (<id>) the current flow's session
/exitLeave the session (also Ctrl-D, double Ctrl-C)

A /name that no built-in claims is sent to the flow as chat text (this is how a project's skills are invoked). A leading space escapes command parsing. Messages are sent as { message: "<text>" }; a target whose action rejects that shape fails the turn without ending the session.

Startup binds a default target from the positional arguments, a config chat.default, or a sole discovered flow; otherwise the session starts unbound. Runtime resolution matches fsdev run (an fsdev.config.ts wins over discovery). No new exit codes: startup failures reuse EXIT_CONFIG_ERROR / EXIT_DISCOVERY_ERROR / EXIT_INVALID_ARGS; a failed turn never exits the loop, and in piped (non-TTY) mode a run with any failed turn or built-in exits EXIT_EXECUTION_ERROR. See Interactive Chat for the guide.

fsdev block <specifier>​

Execute a single block in isolation using the testing harness.

pnpm fsdev block ./src/flows/my-app/blocks/counter.ts -i '{"increment": 1}'

Options:

FlagDescription
-i, --input <json>Inline JSON input
-f, --input-file <path>JSON input from file
-m, --model <model>Model override for generator blocks
--format <format>Output format (default: json)

Output:

{
"success": true,
"block": { "kind": "handler", "name": "counter" },
"output": { "count": 1 },
"schemaValidation": {
"input": { "passed": true },
"output": { "passed": true }
},
"execution": { "durationMs": 12 }
}

fsdev benchmark <file>​

Load a defineBenchmark file and print the scorecard.

pnpm fsdev benchmark ./benchmark.ts

Narrative and flags: Benchmarks and Choosing a pattern with benchmarks.

fsdev ui add <name> / fsdev ui list​

Install or list components from the Flow State UI registry. add shells out to the shadcn CLI.

pnpm fsdev ui add model-badge
pnpm fsdev ui list

Narrative: UI.

fsdev worktrees <root>​

List the git worktrees a workspace host made under <root> (the root you passed to localWorkspaceHost), or remove one.

pnpm fsdev worktrees /var/fsd/runs
pnpm fsdev worktrees /var/fsd/runs --remove /var/fsd/runs/acme/u1/run-7/checkout --delete-branch

The listing prints one line per worktree: clean, dirty or gone (its directory is missing), then the branch ((detached) for a detached HEAD), then the directory. It covers every clone under the root and every repository on this machine that a host on the root listed, found from the pin files in <root>/.fetch-pins/. Add others with --repo.

Options:

FlagDescription
--repo <path...>More repositories on this machine to look in
--remove <dir>Remove the worktree at this directory instead of listing
--delete-branchWith --remove, delete its branch too
--forceWith --delete-branch, delete a branch that holds the only copy of some commits
--jsonPrint the listing as JSON, one object per worktree: { place, dir, cwd, repository, branch, head, dirty }

--remove refuses what the host's remove refuses: a worktree with uncommitted or untracked changes, one still being set up, one a run is working in (in-use), or, with --delete-branch and no --force, a branch holding commits no other branch or remote holds. A refusal removes nothing, prints Refused (<reason>): <message> and exits 1. A directory that isn't one of the listed worktrees also exits 1, and a <root> that doesn't exist exits 2. See listing and removing worktrees.

Flow Discovery​

fsdev run discovers flows automatically from these directories (relative to cwd):

src/flows/<flow-name>/flow.ts → default exports a FlowInstance
flows/<flow-name>/flow.ts → default exports a FlowInstance
flows/<flow-name>.ts → direct file export

Monorepo support​

In monorepo structures, the CLI also scans one level of subdirectories under packages/, examples/, apps/, and labs/:

packages/*/src/flows/
packages/*/flows/
examples/*/src/flows/
apps/*/src/flows/
labs/*/src/flows/

This means flows defined anywhere in your monorepo are automatically discoverable without configuration.

Custom flow directories​

Use --flow-dir to override default discovery with explicit paths. This is repeatable:

pnpm fsdev run my-flow action -i '{}' \
--flow-dir ./packages/api/src/flows \
--flow-dir ./shared/flows

When --flow-dir is specified, only the given directories are searched — the default and monorepo scanning is skipped.

Error messages​

When a flow or action isn't found, the error lists what was discovered, where it searched, and any modules that were found but failed to import:

Flow "review" not found. Available flows: echo, review-east, review-west
Searched: src/flows, flows, examples/hello-chat/src/flows
1 flow module(s) failed to import:
/repo/examples/chat/src/flows/chat/flow.ts: Error: Cannot find package 'left-pad'

A module that throws during import also produces a stderr warning at discovery time, so a broken flow is distinguishable from a missing one even when the run otherwise succeeds.

Discovery is the default. When an fsdev.config.ts is present, the CLI uses the app's registry, resolver, and stores instead, and these directories are not scanned. See App Configuration.

Programmatic API​

The CLI exports its utilities for use in scripts and CI:

discoverFlows(cwdOrOptions?)​

Scan conventional directories and return all discovered flow instances. Accepts a string (cwd) or an options object.

import { discoverFlows } from "@flow-state-dev/fsdev";

// Simple: scan from a directory
const flows = await discoverFlows("./my-project");

// With options: explicit directories
const flows2 = await discoverFlows({
cwd: "./my-project",
flowDirs: ["packages/api/src/flows", "shared/flows"],
});

// Observe modules that throw during import
const flows3 = await discoverFlows({
cwd: "./my-project",
onImportFailed: (failure) => {
console.warn(`${failure.filePath}: ${failure.message}`);
},
});

Modules that throw during import are skipped and reported through the onImportFailed callback; without it they are skipped silently. Each FlowImportFailure carries filePath (absolute path), message (normalized error text), and cause (the original thrown value).

resolveFlow(specifier)​

Load a single flow from an explicit file path.

import { resolveFlow } from "@flow-state-dev/fsdev";

const flow = await resolveFlow("./src/flows/my-chat/flow.ts");

resolveBlock(specifier)​

Load a single block from a file path.

import { resolveBlock } from "@flow-state-dev/fsdev";

const block = await resolveBlock("./src/blocks/counter.ts");

parseInputArg(options)​

Parse input from --input or --input-file flags.

formatOutput(value, format)​

Format a value for terminal output.

Type Exports​

import type {
FlowRunResult, // Structured result from fsdev run
FlowEvent, // NDJSON event union type
BlockExecResult, // Structured result from fsdev block
} from "@flow-state-dev/fsdev";

Exit Codes​

CodeMeaning
0Success
1Execution error (flow or block failed at runtime)
2Invalid arguments (bad JSON input, missing required flags), or a run whose identity was refused. See Who a run is
3Configuration error (invalid port, missing devtool assets)
4Discovery error (flow or block not found, import failed)
10Internal error (unhandled exception)