App Configuration
fsdev.config.ts is a file you put at your project root that default-exports your createFlowState handle. A FlowState is the object createFlowState() returns: it holds your registered flows, your model resolver, and your store profiles, and it knows how to build the HTTP router. Your server already imports one of these to mount its API. When you also export it from fsdev.config.ts, the fsdev CLI picks it up and runs your flows with your models and your stores, the same wiring your server uses.
One file. Two entry points. No second copy of your config to keep in sync.
Why a config file
Without a config, the CLI does its best with defaults. It discovers flows from conventional directories, resolves models from a built-in resolver keyed off environment variables, and writes to a default filesystem and SQLite store. That covers a simple app whose providers are all env-keyed.
It does not cover an app that maps intents to ordered model candidates, routes through a gateway, or uses a custom store adapter. Those live in your createFlowState call. Point the CLI at that call and fsdev run resolves models through your resolver, persists to your stores, and looks up flows in your registry. The behavior you debug from the terminal matches the behavior your server serves.
The convention
The CLI searches the current working directory for a config file, in this precedence order:
fsdev.config.tsfsdev.config.mtsfsdev.config.jsfsdev.config.mjs
The search is cwd-only. It does not walk up the tree. Run fsdev from the directory that holds the config (for a single app, the project root; in a monorepo, the app folder).
Two flags control config loading on both fsdev run and fsdev dev:
--config <path>points at an explicit file, skipping the search.--no-configignores any config and forces directory discovery (the legacy behavior).
With no config file present and no --config, nothing changes: the CLI falls back to directory discovery and its default stores and resolver.
The default export must be a FlowState. The CLI reads its registry, stores, and resolver directly off that handle.
import { createFlowState, inMemoryStores } from "@flow-state-dev/server";
import chatFlow from "./src/flows/chat/flow";
export default createFlowState({
flows: { chat: chatFlow },
models: { default: "openai/gpt-5.4-mini" },
stores: { default: { primary: inMemoryStores() } },
});
Sharing it with your server entry
Your server entry imports the same FlowState. Re-export it from a small server-entry file so both sides reference one object:
export { default as flowstate } from "../fsdev.config";
A Next.js catch-all route handler then awaits the router off that handle:
import { flowstate } from "@/lib/server";
export async function GET(req: NextRequest, ctx: RouteContext) {
const router = await flowstate.getRouter();
return router.GET(req, { params: await ctx.params });
}
Three in-repo apps ship a config and work as references. hello-chat is the minimal case: a couple of flows and in-memory stores, nothing else. kitchen-sink is the full case: multiple store profiles, intent-mapped models, and a gateway. Read whichever matches how far along your own setup is.
What the CLI uses (and overrides)
When a config loads, the registry, store profiles, and model resolver all come from your FlowState. The CLI does not substitute its own. It does layer one thing on top: its own stderr logger, so you still get [flow-state] * runtime logs while it runs.
A few interactions are worth knowing:
--model <id>still works with a config. The id is routed through your config's resolver, so your gateways and providers still apply. You are picking a model your resolver knows how to resolve, not bypassing it.--flow-dirtogether with a config is an error. Directory discovery and a config are two different ways to find flows; mixing them is ambiguous. The error suggests--no-configif directory discovery is what you actually want.- On exit,
fsdev rundisposes the config's FlowState, releasing pooled resources (database connections, for example).
Three caveats follow from running your real wiring from the terminal:
- In-memory stores persist nothing. If your active store profile is in-memory, a CLI run executes fine but writes nothing durable. It will not show up in your app's data afterward, because there is no shared backing store.
- A colocated queue worker starts for the run. If your config declares a queue worker alongside the stores (a BullMQ worker, say), a
fsdev runstarts that worker for the duration of the run and drains it on dispose. - Concurrent writes can lose an update. The app and the CLI can write the same
.fsdev/dataat once. Filesystem writes are torn-write-safe across processes, so you won't read a half-written record. But two processes updating the same record can still race, and one update can be lost.
Runtime requirements
A .ts config needs a runtime that can load TypeScript. Inside the framework monorepo, tsx handles that. In a consumer repo, you need one of:
- Node >= 22.18, which strips TypeScript types natively.
- Run the CLI under tsx.
- Use an
fsdev.config.mjsorfsdev.config.jsinstead.
Native type stripping ignores tsconfig path aliases. Keep the config's import chain on relative paths (./src/flows/chat/flow), not aliases like @/flows/chat/flow, or the import will fail to resolve.
One sharp edge: the CLI's engines field allows Node 20. Node 20 does not strip TypeScript, so a .ts config will not load there. Allowed-to-install is not the same as can-load-a-.ts-config. If you're on Node 20, run under tsx or ship an .mjs config.
See also
- Server Setup — the
createFlowStatefactory and store profiles in full. - Models — model strings, intents, and how the resolver picks a provider.
- Persistence — the store adapters and what each one durably keeps.
- CLI API Reference — the full flag reference for
fsdev runandfsdev dev.