Reference
Configuration
Full reference for CraftConfig fields and logging options.
CraftConfig
The main configuration object for context settings. Export it as craftConfig (named export) alongside your capabilities when using craft run. The recommended pattern is defineConfig, an identity helper that types the object as CraftConfig, so every key autocompletes (including ecosystem keys) and a key CraftConfig does not declare is a compile error:
import { defineConfig } from '@routecraft/routecraft'
export const craftConfig = defineConfig({
store: new Map([
['my.adapter.config', { apiKey: 'xyz' }]
]),
on: {
'context:starting': ({ ts }) => console.log('Starting at', ts)
},
})
defineConfig is a no-op at runtime; it returns the input unchanged. The checking is the same as annotating const craftConfig: CraftConfig = {...} or writing {...} satisfies CraftConfig: an unknown or misspelled key is an error at any depth, so a key a release removed (such as the 0.6 http: { host, port }, now under servers) fails to compile rather than at boot.
Durations
Every time option you write takes a Duration: either a plain number of milliseconds, or a string with a unit suffix ('500ms', '30s', '15m', '72h', '7d').
shutdown: { timeout: 20_000 } // milliseconds
shutdown: { timeout: '20s' } // the same deadline, read at a glance
The two forms are interchangeable everywhere, because Duration is a superset of number. The string form exists because the realistic values are human ones: an approval window is three days, not 259_200_000.
Values the framework reports are the opposite case and stay plain millisecond numbers: durationMs on events, ageMs on ops responses, and anything else emitted rather than authored. Those are machine-readable data, not configuration, so they carry no unit ambiguity to resolve.
Validate one yourself with the exported parseDuration(value, field), which applies exactly the rules every option applies.
The one exception: clockToleranceSec
authorize(), jwt(), jwks(), HTTP auth and mcpPlugin auth all take clockToleranceSec, which is a number of seconds, not a Duration.
This is deliberate. JWT exp and nbf are epoch-seconds and the option maps directly onto the underlying verifier's own clockTolerance, so seconds is the unit the domain works in. The name says Sec, so it does not mislead, which is the test the rest of this convention is built on: a name ending in Ms that accepted "5m" would lie, and this one does not.
It is the only authored time option in the framework that is not a Duration.
Route enablement
A route can declare whether it runs at all:
craft()
.id('mail-inbound')
.enabled(() =>
env.MAIL_USER && env.MAIL_APP_PASSWORD
? true
: 'MAIL_USER and MAIL_APP_PASSWORD are not set',
)
.from(mail({ account: 'default', folder: 'INBOX' }))
.to(direct('triage'))
A disabled route is registered and known to the context, not started, not intaking, and not offered as an agent tool, and ops reports it as disabled with its reason rather than as failed. This is per-route configuration rather than a CraftConfig field because the condition belongs with the route it gates. See .enabled().
Configuration fields
Ecosystem keys (added by @routecraft/ai)
When @routecraft/ai is imported (anywhere in the project), CraftConfig is augmented with first-class keys for the AI plugins. Each key carries the same options as the corresponding factory and participates in the standard plugin lifecycle.
import { defineConfig } from '@routecraft/routecraft'
import '@routecraft/ai' // augments CraftConfig with llm/mcp/embedding/agent/sessions/acp
export const craftConfig = defineConfig({
llm: {
providers: { openai: { apiKey: process.env.OPENAI_API_KEY! } },
},
mcp: { clients: { /* ... */ } },
agent: { agents: { /* ... */ } },
acp: {},
})
The keys apply in a fixed order (llm, mcp, embedding, agent, sessions, acp), whatever order they are written in, and before anything in plugins. That is what lets acp serve the agents agent registered in any key order, where the plugins: [acpPlugin()] form has to be listed after agentPlugin().
The legacy plugins: [llmPlugin(...)] form continues to work and is the right escape hatch for shared plugin instances or programmatic composition.
Troubleshooting: if TypeScript reports Object literal may only specify known properties, and 'llm' does not exist in type 'CraftConfig' (or the same for mcp, embedding, agent, sessions, acp), the augmentation has not been loaded. Add import '@routecraft/ai' to a file that's part of your project's compilation -- usually next to defineConfig in craft.config.ts. The side-effect import is what merges the AI keys into CraftConfig.
Core adapter defaults
Core adapters have dedicated config fields so you can set context-wide defaults without importing a plugin. See Merged Options for how the merge hierarchy works.
cron
Default options applied to every cron() source in this context. Per-adapter options always take precedence.
const config: CraftConfig = {
cron: { timezone: 'UTC', maxJitter: 2000 },
}
direct
Sets the channel implementation used by all direct() endpoints in this context. Use this to swap the default in-memory channels for a distributed implementation (e.g. Kafka, Redis).
import { KafkaChannel } from 'my-kafka-adapter'
const config: CraftConfig = {
direct: { channelType: KafkaChannel },
}
When omitted, direct endpoints use the built-in in-memory channel (single-consumer, blocking send).
servers and http
Named servers own listeners. HTTP, MCP, health endpoints, and custom plugins can mount different paths on the same listener. Give a surface its own named server when it needs an isolated port.
import { defineConfig, jwt } from '@routecraft/routecraft'
export const craftConfig = defineConfig({
servers: {
public: { port: 8080, host: '0.0.0.0' },
},
http: {
server: 'public',
auth: jwt({ secret: process.env.JWT_SECRET!, issuer: '...', audience: '...' }),
},
})
Each server accepts port (required), host (default 127.0.0.1), kind (currently only http), an optional validator auth, allowedHostnames (readonly string[], default []) naming additional trusted public hostnames for MCP and ACP, shutdownGrace (default 30000) bounding the graceful drain on stop, idleTimeout (default "255s", Bun's ceiling and so refused above it rather than clamped) setting the reap window for ordinary connections, and maxStreamingRequests (default 500) capping the streaming responses one listener admits per request before it answers 503, which does not count surfaces that declare themselves long-lived for every request (today, the MCP mount). A surface inherits server auth unless it supplies its own auth; auth: false removes the wall while keeping the inherited validator reachable for routes that pull identity. Every named server must receive at least one mount, and two names cannot claim the same non-ephemeral host:port. Full listener behaviour lives on the serversPlugin reference.
ops
Configures the operational surface: liveness, readiness, and operational health over HTTP, mounted on a named server. See opsPlugin for the full behaviour and the response shapes.
import { defineConfig, defineIndicator } from '@routecraft/routecraft'
export const mailHealth = defineIndicator({
name: 'mail',
maxAge: 15 * 60_000,
route: 'probe-mail',
})
export const craftConfig = defineConfig({
servers: { ops: { port: 9090 } },
ops: {
server: 'ops',
indicators: [mailHealth],
},
})
Route lifecycle, circuit-breaker position, and the serving state are derived from events the framework already emits, so ops: {} alone gives a truthful surface. Indicators cover the dependencies the framework cannot see.
remotes
Names other running instances, and every dispatchable route they expose through their ops management API becomes a direct endpoint in this context. See remotesPlugin for the naming rule, the shadow rule, credentials, refresh and the outcome mapping.
import { defineConfig } from '@routecraft/routecraft'
export const craftConfig = defineConfig({
remotes: {
default: {
url: 'https://routecraft.example.internal',
auth: { token: () => process.env.RC_REMOTE_TOKEN },
},
lab: {
url: 'https://lab.example.internal',
auth: { token: () => process.env.RC_LAB_TOKEN },
},
},
})
Each entry is one remote, keyed by name. The routes of default are advertised bare (direct('hello')); every other remote's are qualified (direct('lab:hello')).
The key applies after ops, whatever order the two are written in, so each remote's remote.<name> health indicator has a ledger to report into when the app carries an ops surface. The credential is read from the environment; the CLI's .routecraft/settings.yaml is for the local instance craft talks to and is never read here.
Bind the server the surface sits on somewhere the probes can reach. A Kubernetes httpGet probe comes from the kubelet against the pod IP and a reverse-proxy check comes from the proxy, so neither reaches 127.0.0.1; only a Docker HEALTHCHECK, which runs inside the container, does. Keep an internal-only port private at the network layer rather than by binding loopback.
deferral
Configures where deferred exchanges are persisted, and the secret used to sign the resume tokens an approver is handed. Required as soon as any route can reach a .defer(): such a context refuses to start without it, with RC5052. A context that never defers does not need the key.
It is deliberately not implicit. This setting decides whether a deployment survives a restart and whether resume tokens outlive the process, and defaulting it silently would hand a route that promises durability an in-memory store nobody chose.
import { defineConfig } from '@routecraft/routecraft'
export const craftConfig = defineConfig({
deferral: {
// A mounted volume in a container deployment, so deferred exchanges
// outlive the container that deferred them.
store: { path: '/data/deferrals.db' },
},
})
The store and secret settings are also readable from the environment, which is how a container deployment supplies them. defaultTtl, sweepInterval, expiryLease and retention are config-only:
An explicit config value wins over the environment.
Every deferral gets a deadline unless you say otherwise. A deferred exchange with no expiry is one nothing will ever retire: it holds its serialized body indefinitely, and the route that deferred it is never told nobody answered. So defaultTtl applies to any .defer() that names no ttl, a sweeper retires deferrals once they are overdue, and the route hears about it through its own error channel (RC5047). A deployment whose approvals legitimately have no horizon sets defaultTtl: 'never' and takes on retiring them itself.
The store backend is chosen per runtime. Under Bun it is bun:sqlite, a built-in. Under Node it is better-sqlite3, an optional peer: install it (bun add better-sqlite3) to get a durable store on Node. Without it, an unconfigured context falls back to the in-memory backend and warns that deferred exchanges will not survive a restart, while a context that named a store path fails to start rather than silently losing durability it asked for.
If you defer exchanges, pin your line endings and build settings across deploys. A deferred exchange records a hash of the steps that have not run yet, so that a change to what an approval authorises invalidates it rather than resuming into different behaviour. That hash reads step identity from function source text verbatim, with no normalisation, because any normalisation that made two different sources hash alike would also be a way to miss a real change. The consequence is that the hash is sensitive to more than your source tree: a formatting pass, a checkout with different line endings, and a build that changes emitted text (a different minifier, new bundler settings, a TypeScript target bump) each move it for steps whose behaviour did not change. Every one of those outcomes is safe rather than silent, since affected exchanges re-enter their route's error channel and can be re-asked; none of them can resume an approval into different behaviour, which is the trade being made. For a repository whose routes defer approvals for days, commit a .gitattributes with * text eol=lf (or the equivalent pin for your platform) so checkouts agree on line endings, and keep build settings stable between releases. Running TypeScript directly (the default under Bun, with no bundler in the path) removes the build half of this entirely.
sessions
Added by @routecraft/ai. Configures where agent sessions keep their records: the transcript, the inbox and the background calls a conversation is waiting on. Optional: without it the SQLite backend at .routecraft/sessions.db is used, and the file is created by the first session written rather than at boot, so a context whose agents never hold a conversation never grows a database. The continuation a turn stores between turns is a deferred exchange and lives in the deferral store, which session therefore still needs.
import { defineConfig } from '@routecraft/routecraft'
import '@routecraft/ai'
export const craftConfig = defineConfig({
deferral: { store: { path: '/data/deferrals.db' } },
sessions: { store: { path: '/data/sessions.db' } },
})
The backend is chosen per runtime exactly as the deferral store's is: bun:sqlite under Bun, better-sqlite3 under Node. An explicitly configured path that cannot be opened fails at startup with AI1012; the unconfigured default falls back to memory with a warning when no driver is available. A value that names neither a location nor a backend is refused at startup with RC5003. A store instance you supply is never closed by the context.
Each SQLite store keeps its own file. A SQLite database carries one PRAGMA user_version, and every file-backed store versions its own schema through it, so one path given to both sessions and deferral can satisfy neither. Two stores configured onto one path are refused at startup, naming both settings; a file that already belongs to another store is refused when it is opened, naming what the file actually is.
This is a property of the file-backed default rather than a rule about stores. A SessionStore or DeferralStore of your own may hold several stores in one database, each with its own table and no shared version counter. Where conversations live stays your choice.
Records have no retention: nothing expires a session, so a deployment that opens one per inbound message sizes its volume by how many it will ever open. Retiring a session is not built yet. In tests, testContext() substitutes an in-memory deferral store but not a session store, so a test that holds a conversation passes sessions: { store: 'memory' } to leave no database behind.
acp
Added by @routecraft/ai. Serves the Agent Client Protocol so a person can talk to this instance's agents from their editor, the way mcp serves MCP. Takes exactly the options acpPlugin() takes, and none of them is required: an empty object serves every agent the context holds at /acp on the default server, behind that server's wall.
import { defineConfig, jwt } from '@routecraft/routecraft'
import '@routecraft/ai'
export const craftConfig = defineConfig({
servers: { default: { port: 8080 } },
acp: {
auth: jwt({
secret: process.env.JWT_SECRET!,
issuer: 'https://idp.example.com',
audience: 'https://acp.example.com',
}),
},
})
The agents it serves are the ones craft start discovered under agents/ or the ones written under agent; the key applies after agent whichever is written first. Agents registered only through plugins: [agentPlugin()] are not visible to it, because plugins apply after every key: use the plugins: [agentPlugin(), acpPlugin()] form for that, in that order. A context holding no agents when the key applies fails the build with RC5003 rather than serving a protocol with nothing behind it.
The signing secret is required, at least 32 bytes, and never generated for you. A context whose routes can reach a durable defer fails at startup with RC5040 when no secret is configured. The secret is deliberately not stored alongside the deferrals: a store compromise must not also yield forgeable resume tokens. testContext(), NODE_ENV=development and NODE_ENV=test mint an ephemeral in-memory key so tests and local iteration need no setup; that key is regenerated on every start, so tokens minted before a restart stop verifying, and the framework warns when it is in use.
shutdown
Shutdown runs in two stages. Stage one closes intake: sources stop producing and no new exchange is admitted, while work already in the pipeline runs to its natural end. Stage two is forced, and abandons that work.
shutdown.timeout is how long stage one may drain before stage two
fires. It defaults to 30 seconds.
export const craftConfig = defineConfig({
shutdown: { timeout: 20_000 },
})
Set it below your platform's kill timer. Kubernetes
terminationGracePeriodSeconds and its equivalents send SIGKILL once they
elapse, and a SIGKILL takes the decision away: nothing drains, nothing is
released, and the forced stage never runs. A timeout under that timer
keeps the outcome the process's own.
The bound applies to every shutdown, not just a signal: craft start --once, a test, and an embedder calling context.stop() are all bounded
the same way, because shutdown behaviour is a property of the app rather
than of one invocation.
What a forced shutdown accepts losing
When the deadline hits, one warn line names the routes still working and how much each had in flight. It is the only forensic record the forced stage leaves, so it names route ids rather than a total. Then:
- In-flight exchanges are abandoned mid-step and emit no terminal
event. A subscriber counting
route:exchange:completedagainstroute:exchange:startedwill see the difference. - A sweep the deferral sweeper had already begun does not finish its round. This is not specific to the forced stage: the sweeper stops cooperatively as soon as shutdown begins, gracefully or not. Records it had claimed are not lost either way, because the claim is a lease and the next process to run the sweep picks them up once it expires. Note that plugin teardown waits for that in-flight sweep rather than abandoning it, which is part of why teardown sits outside the bound.
- Nothing is settled or denied on the way down. A deferred exchange survives a forced shutdown exactly as it survives a graceful one. Only a cancelled run denies its own deferral, and shutdown is not that.
A clean shutdown inside the bound exits 0. A forced one exits 1, so
exit-code-sensitive tooling can tell them apart; craft start --once
reports a forced shutdown as a failure for the same reason. In-process the
same answer arrives twice: context.stop() resolves with
{ forced, pending }, and
context:stopped carries the same
pair for a subscriber that never sees the return value.
Plugin teardown itself is not bounded by this key. Teardown releases resources, and cutting it short would trade a bounded shutdown for a leaked one.
So timeout is not the whole shutdown budget. Teardown runs after the
deadline, and a plugin that drains on its own clock adds to the total: a
named server drains up to its
shutdownGrace, which also
defaults to 30 seconds. A context with an HTTP mount and both defaults can
therefore take up to 60 seconds to exit. When you size timeout against a
platform kill timer, budget for the teardown that follows it, not for
timeout alone.
Logging configuration
Logging uses a single pino instance configured at module load. Precedence (highest wins):
- Environment variables --
LOG_LEVEL/CRAFT_LOG_LEVEL,LOG_FILE/CRAFT_LOG_FILE,LOG_REDACT/CRAFT_LOG_REDACT(comma-separated paths to redact) - Config file in cwd --
craft.log.cjsorcraft.log.jsin the current working directory - Config file in home --
craft.log.cjsorcraft.log.jsin~/.routecraft/ - Defaults -- level
"warn", stdout, no redact
The config file exports a native pino options object (e.g. level, redact, formatters, transport). Env vars are merged on top, so env always wins.
Example craft.log.js (or craft.log.cjs in a CommonJS project):
// craft.log.js
export default {
level: "info",
redact: ["req.headers.authorization"],
};
When using the CLI, pass --log-level or --log-file to set the corresponding env var before the logger initializes, so CLI flags override any config file. The env files the CLI loads are in place before the logger initializes too, and a flag beats them.
A log file that cannot be opened for appending is handled differently by the two entry points. The CLI refuses to start and names the path. An embedding application is never stopped by its logger, so there the logs go to a file of the same name in the system temporary directory, or to stderr when that fails too.
Service name
Set name on the context to tag every log line with a service.name field (the OpenTelemetry semantic convention). This identifies the originating application when shipping logs to an aggregator, and lines up with OTel resource mappings such as BetterStack's resources.service.name.
import { defineConfig } from "@routecraft/routecraft";
export const craftConfig = defineConfig({
name: "eywa",
});
Every log emitted through the context, its routes, and their exchanges then carries the field:
{ "level": "info", "service.name": "eywa", "route": "zoe-mail", "msg": "..." }
When name is omitted, no service.name field is added. The value is a per-context log binding; it does not configure OpenTelemetry trace resources. If you also export traces via telemetry({ tracerProvider }), set the matching service.name on that provider's Resource so logs and spans agree.
Environment variables
Routecraft automatically loads environment variables from .env files when using the CLI:
# .env
LOG_LEVEL=debug
NODE_ENV=development
.env is read first, then .env.<profile> when a settings profile is selected, then .env.local, each overriding the one before it. A profile can also name one exact file or carry its values inline, and --env <path> overrides all of it; the CLI reference owns that chain in full.
The environment is in place before the logger is built and before craft.config.ts is imported, which is what lets an env file set LOG_LEVEL and LOG_FILE, and the config file read process.env at module scope.
Related
Events reference
All lifecycle and runtime events available in the on field.
Plugins reference
Full API for plugin interfaces and context methods.
Adapters reference
All adapters, options, and signatures.