Reference
CLI
Run Routecraft capabilities from the command line.
Bun-only runtime
The craft CLI runs on Bun (>=1.1.0). Node users should embed @routecraft/routecraft programmatically instead -- see Programmatic Invocation and the Runtime reference.
Basic usage
craft <command> [options]
Global options (must appear before the subcommand):
start and run also take --log-level and --log-file as their own options, and the command's own flag wins over the global one. On run they belong before the file, because anything after run <file> is pass-through: it is forwarded to the route file's CLI adapter, which is what lets a route own a flag the CLI also defines.
craft start ./my-project --log-level info
craft run --log-level info --log-file craft.log ./capabilities/orders.ts
craft --log-level info run ./capabilities/orders.ts
LOG_LEVEL and LOG_FILE also work from the env files run and start load, whether the ones a profile selects or the one --env names, and a flag still beats the same setting from a file. A log file that cannot be opened for appending stops the command before it runs, exiting 2 with the path and where it came from (--log-file, LOG_FILE, CRAFT_LOG_FILE, or a craft.log.js / craft.log.cjs file, reported as craft.log); a missing file and missing parent directories are created. An empty --log-file "" keeps logs on standard output.
Project scaffolding
New projects are created via bunx create-routecraft (or the equivalent for your package manager), a separate scaffolding package -- not a craft subcommand:
bunx create-routecraft [project-name]
Options:
Scaffolding from a repository
--example also takes a public GitHub URL, which is how the larger starting points ship.
craft-harness is the reference one: a working agent harness, laid out in the
project convention, with every capability an
ordinary route you own and can edit.
bunx create-routecraft my-app --example https://github.com/routecraftjs/craft-harness
The URL form accepts a whole repository or a subdirectory of one
(https://github.com/owner/repo/tree/<branch>/<path>). A plain repository URL always takes
main; templates are not tagged, so what you get is the template's current state rather
than a pinned release. Pass a /tree/<branch>/ URL when you need a specific one.
A branch is read as one path segment. /tree/feature/login is understood as branch
feature with subpath login, because nothing in the URL says which slash is the boundary
and deciding would take a call to GitHub. Scaffold from a single-segment branch, or from the
repository root.
The template's files win over the base scaffold, except for two the base keeps: the project
name you passed and the package manager you chose. Its dependencies, devDependencies and
scripts merge into the base manifest rather than replacing it, so the template declares
only its own additions. node_modules, .git and the lockfiles (package-lock.json,
npm-shrinkwrap.json, yarn.lock, pnpm-lock.yaml, bun.lock, bun.lockb) are never
copied; the scaffolded project resolves its own dependency tree.
Commands
run
Load one or more capabilities from a TypeScript file and start the Routecraft context. The process runs as long as the capabilities run -- finite capabilities exit after completing; long-lived sources keep the process running until the context is stopped or a signal is received.
craft run <file> [--env <.env path>] [--profile <name>]
The file must export a capability (or array of capabilities) as its default export, and optionally a craftConfig named export. See the Configuration reference for the config export format.
Options:
A file outside a project
The file needs no package.json. Where @routecraft/routecraft is not installed, it runs on the copy that ships with the CLI. The CLI does not bundle adapter dependencies, so an adapter that needs one names it and stops: install it next to the file (bun add croner), or globally with the same tool that installed the CLI (bun add -g croner).
start
Boot a whole project from its folder convention
instead of a hand-written barrel file. Where run executes one entry file, start reads
craft.config.ts and then discovers what the project declares on disk: capabilities,
plugins, agents, and skills.
craft start [dir] [--env <.env path>] [--profile <name>] [--once] [--timeout <duration>]
With no argument it starts the project in the current directory. Both the root-level and the
src/-nested layouts work.
Options:
What it loads, and in what order:
craft.config.tsfrom the project root, via its namedcraftConfigexport. A default export is accepted with a warning. Importing this file is what pulls ecosystem packages into the module graph.plugins/, one plugin instance per module.- Folder discoverers, ordered by the dependencies they declare.
@routecraft/aiclaimsskills/andagents/, and the agents discoverer declaresafter: ["skills"]so an agent can compose the house skills rather than replacing them. capabilities/, one or more routes per capability.
Code wins and convention fills the gaps: whatever craft.config.ts declares is kept, and
discovery supplies only what it left out. Every discovered capability, plugin and agent is
logged with the file it came from.
--once is for CI smoke checks and cron-style one-shot invocations. A failure, a drop and a
deferral all count as terminal alongside a completion, so a broken or deferred exchange
reports instead of hanging until the job is killed; a first exchange that failed exits
non-zero.
A route that throws on the way up fails the command too, in either mode. Starting settles
every route rather than racing them, so a project can come up with one route dead and the
rest healthy; start reports that as a failed boot rather than a clean one.
Pair it with --timeout <duration> in CI. Without one, a project whose sources never produce an
exchange waits indefinitely, which reports as a killed job rather than as a diagnosis; with
one, the command exits non-zero saying nothing reached a terminal outcome in time.
Two limits worth knowing. --once settles on the first terminal exchange on any route, so
on a project with a heartbeat timer beside the route you meant to exercise, the timer can win
the race. Scoping the wait to a single route is tracked in
#584. And a project with a
server ingress (http, direct, mcp) never finishes starting by design, which is why
--once races the first exchange against startup rather than waiting for startup to complete.
craft start ./apps/eywa --once
exec
Dispatch to a route on a running instance through its management API, and print what came back.
craft exec [options] <route> [--field=value ...]
craft exec works against craft start, not craft run, and hard-fails when it cannot connect: there is no cold boot. Craft's own flags come before the route name and the route's input comes after it, the same split run uses, so a route field can never collide with a CLI flag.
Options:
Route input is passed as --field=value pairs, or as JSON on stdin for anything richer. The two are mutually exclusive: passing both is refused rather than silently resolved.
craft exec greet --name=world
echo '{"name":"world","tags":["a","b"]}' | craft exec greet
craft exec --url http://10.0.0.5:8080 --token "$CRAFT_TOKEN" greet --name=world
Flags become a flat body: a value may be joined with = or follow as its own argument (--name world), a --flag with no value after it is true, a repeated key becomes an array, and every other value stays the string the shell passed. Nothing is coerced to a number, because a route that wants one declares it with .input() and a CLI guessing types produces bugs that only appear for values which happen to look numeric.
A dispatch runs the app's own guardrails. craft exec reaches a route only through the door that route declares, so the full pre-from chain runs: .authorize(), .input() and .throttle() all apply, per message, exactly as they would for any other caller. This is a property to rely on rather than an accident of the plumbing. It also means the CLI is just another client: there is no bypass and no synthetic operator identity, and .authorize() sees the principal the mount's validator minted from the credential you presented.
Only some routes are dispatchable. A direct() ingress makes a route id into a door; a route sourced from cron(), mail() or http() runs from its own trigger and has none. craft exec with no route named lists exactly the routes that will accept a dispatch.
A deferred exchange is a success. A route that reaches a durable .defer() replies with the standard Deferred acknowledgment instead of its output, and craft exec renders it as an outcome at exit 0, with the deferral id, the resume token and the expiry. It is not an error, and the operator at the terminal is often exactly who the deferral is waiting for.
Exit codes, which are the API for anything scripting this:
A refusal reports how to get in. On a 401 or 403 the CLI reads the WWW-Authenticate challenge, follows its RFC 9728 resource_metadata hint (best effort, short timeout), and folds the answer into the refusal: which scope is required, who issues acceptable tokens (or that the instance advertises no authorization server, in which case ask whoever operates it how to obtain a credential), and where to supply one (--token, CRAFT_TOKEN, the settings file). The credential ladder walks the four ways an instance is secured.
acp
Bridge an editor to a running instance over the Agent Client Protocol. This is the command an editor is configured to run.
craft acp [options]
It is a pipe that re-establishes itself: newline-delimited JSON on the editor's standard input and output, Streamable HTTP on the instance's side, every message forwarded verbatim in both directions. The bridge reads each message only far enough to know what it is, so a version of the protocol this build has never heard of passes through unchanged.
It survives the instance restarting. An editor keeps this process for as long as its window is open and does not start another when the instance behind it goes away, so the bridge stays up through an outage and reconnects on its own: it waits for the address to answer again (a quarter of a second, then doubling to every five seconds, for as long as the editor is open), initializes the new instance with what the editor said the first time, and resumes every conversation the editor had open by its id, without replaying anything onto a screen that already shows it. The editor's next message is answered as if nothing happened. What was in flight when the instance went away cannot be recovered, because the protocol does not replay it: a prompt that was waiting is answered cancelled, any other request gets an error naming the outage, and a conversation the instance came back without (a session store that does not outlive the process) is named on standard error with the advice to start a new one. Each loss and each reconnection is one line on standard error.
It never starts an app. craft start owns running, which is what lets one editor entry reach a laptop or a company instance by switching a profile and changing nothing else.
Options:
craft acp
craft acp --profile company
craft acp --url https://eywa.example --token "$CRAFT_TOKEN" --agent zoe
The protocol is mounted at /acp, and the bridge is not where that is configurable: the mount's own path option is the instance's to choose, and a person pointing an editor at an instance that moved it says so with the full URL.
There is no login. A token in the profile authenticates, exactly as token: does for every other command, and it travels as Authorization: Bearer on every request. An instance that advertises no authentication method is conforming, not misconfigured. A cleartext bearer is only ever sent to a loopback address, and a reconnection presents it again on every attempt: whatever answers there when the bridge reconnects receives it, so do not leave a bearer-authenticated craft acp reconnecting against a loopback port something else could come to hold.
The agent travels as a header. --agent becomes a Routecraft-Agent request header, and every conversation the connection opens belongs to that agent for its whole life. A name the instance does not hold is refused by name rather than quietly served by somebody else. Register one editor entry per agent to talk to more than one: see One agent per editor entry.
Standard output belongs to the protocol, so nothing but protocol frames is ever written to it and every diagnosis goes to standard error. It shares exec's exit codes: a profile named nowhere exits 2 naming the profiles that do exist, and an address that never answered exits 3 naming it and which setting supplied it. That last one is only for the first connection: an address nothing has ever answered on is configuration to check, where an instance that answered and then went away is an outage to wait out. The editor's own side of the pipe breaking, rather than closing, exits 1: an editor closing the connection normally is exit 0.
Editor configuration, and what a conversation carries once it is open, are in Talk from your editor.
ops
Inspect a running instance: health, readiness, routes, indicators.
craft ops health
craft ops ready
craft ops routes [id] [--dispatchable] [--source <kind>]
craft ops deferrals [id] [--state <state>] [--route <id>] [--limit <n>] [--after <cursor>]
craft ops indicators [name]
Every subcommand takes the same --profile, --url, --token and --format options as exec, resolved the same way.
ready is its own command rather than a flag on health because it answers a different question. The report itself carries a view field precisely so the two are never confused: acting on the operational aggregate would route traffic on a deployment-wide signal, which helps only when the peers are in a better position, and they are not.
The CLI groups by operator task, not by URL prefix
craft ops health reads /health/**, which is deliberately not under /ops. Health never walls, so an orchestrator probe works with no credential; /ops always answers by tier. Separate prefixes make "expose health, never expose ops" a coarse rule that is hard to get wrong at an ingress, while the command family groups by the question an operator is asking. The two answer different things and are allowed to differ; this is not an inconsistency to fix in either direction.
Present a credential whenever you have one, including for health. Health does not wall, and it returns more when authenticated: per-component details are gated by ops.health.details, which defaults to when-authenticated. So craft ops presents whatever credential the settings provide on both surfaces and degrades when there is none. That is a correctness matter rather than a nicety: without a credential a route component says degraded and nothing else, while with one it says degraded because a breaker is open. The output names which view you are reading, so a thin answer never passes for the whole picture.
tui
Browse what a context recorded: routes, agents, tool calls, exchanges and live events, read from the SQLite database the telemetry() plugin writes. It reads the file rather than a running instance, so it works while the context runs and after it has stopped.
craft tui [--db <path>]
Navigation, views and keys are in the Terminal UI guide.
Settings file
craft exec, craft ops and craft acp read one personal settings file, and craft run and craft start read it for the profile that selects their environment. It is your file, not the app's: craft.config.ts decides what exists in an instance and what it exposes, while this decides how you like to talk to it.
Two locations, both YAML and both optional:
settings.yml is accepted as an alternate spelling in either location. A location carrying both spellings is refused with both paths named: a silent preference would leave edits to the unread file unexplained.
Every option it accepts:
# .routecraft/settings.yaml
url: http://127.0.0.1:9090
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
format: pretty
Precedence, highest first: a flag, then an environment variable (CRAFT_URL, CRAFT_TOKEN, CRAFT_FORMAT, CRAFT_PROFILE), then the project-local file, then the global file, then the built-in default. When a connection fails, the error names both the address it used and which of those supplied it, so a wrong pinned address is diagnosable from the message alone.
Profiles
A profile is one selection that carries the address, the credential, the output format, the agent and the environment together, so moving every command in the family to another instance is one word rather than four flags.
# .routecraft/settings.yaml
profile: local
profiles:
local:
url: http://127.0.0.1:8080
company:
url: https://eywa.example
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
format: json
agent: zoe
env: .env.company
craft ops health --profile company
CRAFT_PROFILE=company craft exec greet --name=world
Which profile is selected, highest first: --profile, then CRAFT_PROFILE, then the project-local file's profile:, then the global file's. The two files' profiles: maps are one namespace as far as selection is concerned, so a profile defined only in your home directory is selectable from any project.
Once one is selected, each value resolves through four file layers rather than two, highest first: the project profile, the project file's top level, the global profile, then the global file's top level. Inside one file a profile beats a bare key, because it is the more specific thing you asked for. Between files the project still beats the home directory, so a profile somebody keeps in their home directory can never redirect an instance a repository pinned.
A profile named nowhere is refused, naming the profiles that do exist and the file that chose it. Falling back to the loopback default would send a command meant for a company instance to whatever is listening on your laptop.
Defining profiles changes nothing until one is selected: with no selection, every value resolves exactly as it did before profiles existed.
The environment a profile selects
A profile is the mode, so selecting one selects the environment that goes with it. The commands that load a project (run and start) read env files in this order, each overriding the one before it:
.env.env.<profile>, when a profile is selected.env.local
A missing .env.<profile> is not an error: a mode with nothing extra to say is an ordinary case.
The environment is loaded before anything else the command does, including building the logger, so these files can set LOG_LEVEL and LOG_FILE like any other variable. --log-level and --log-file still win over them.
Two escapes from that cascade, for when the convention is not what you want:
env:on the profile. A string is a path to one env file, resolved against the project root, and that file is read instead of the cascade. A map is values written inline, applied instead of the cascade, and a variable the process already carries is left alone exactly as an env file leaves it alone.--env <path>. A path typed on the command line is the most specific thing anybody said, so it wins over the profile's ownenv:and over the cascade alike.
A file either escape names is required: if it cannot be read, the command stops before it runs and exits 2 naming the path. The cascade is different: a file in it that does not exist is skipped, though one that exists and cannot be read stops the command the same way.
profiles:
ing:
url: https://ing.example
env:
REGION: eu-west-1
TIER: acceptance
The project-local file can hold a token
Keep .routecraft/ out of version control. Projects scaffolded with create-routecraft ignore it already; if your project predates that, or you created the directory by hand, add .routecraft/ to your .gitignore yourself before putting a token in it.
The two halves can come from different places. A project-local file names the server, while the token usually comes from a higher-precedence source (CRAFT_TOKEN, or your home-directory file), so a settings file committed to a repository chooses which server receives a credential you never put in that repository. That is the intended team workflow, where the repository pins the instance and each person holds their own token. It is also the reason to treat a repository you run craft in the way you treat one you run npm install in.
A settings file that exists but cannot be parsed is an error rather than a silent fallback to defaults, because an operator who wrote a settings file and got default behaviour would reasonably conclude the setting does not work.
craft login does not exist yet. Until it does, the token is a signed bearer you paste in here, pass with --token, or export as CRAFT_TOKEN; when it ships, it writes to this same file.
Shutdown helpers
When building a custom runner (e.g. embedding Routecraft inside an Express server or CLI tool), use shutdownHandler for graceful two-stage shutdown:
import { ContextBuilder, shutdownHandler } from '@routecraft/routecraft';
const { context, client } = await new ContextBuilder()
.routes(myRoutes)
.build();
const cleanup = shutdownHandler(context);
await context.start();
First signal (Ctrl+C): closes intake so sources stop producing, lets in-flight exchanges run to their natural end, runs plugin teardown, then exits 0. Work already running is not cancelled here: an agent mid-tool-call finishes its call.
Dedicated force signals (Ctrl+Backslash/SIGQUIT or Ctrl+Break/SIGBREAK): force an immediate exit for when graceful shutdown is stuck or taking too long, including before graceful shutdown begins.
Signal meanings vary across ecosystems: nginx uses SIGQUIT for a graceful shutdown, while current Gunicorn uses SIGTERM for graceful shutdown and SIGQUIT for an immediate shutdown. If a container uses STOPSIGNAL SIGQUIT expecting a drain, this handler treats that signal as a force exit instead. Under bun --filter, the force-path log can be lost by the supervising command, so do not use that log as the sole evidence of a forced shutdown; also check the exit status or the result from context.stop().
Stage one is also bounded by shutdown.timeout, which matters under an orchestrator: it sends one SIGTERM and then SIGKILLs, so no dedicated force signal is coming. On that deadline in-flight execution is abandoned and the process exits 1, so a forced shutdown is distinguishable from a clean one.
context.stop() resolves with { forced, pending } if you drive shutdown yourself: forced says the deadline was reached, and pending names the routes that still had work in flight.
The function returns a cleanup callback that removes the signal handlers, useful in tests or when you manage the lifecycle yourself.