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

FieldTypeRequiredDefaultDescription
namestringNo--Service / application name. Emitted on every log line as service.name (details)
storeMap<keyof StoreRegistry, StoreRegistry[keyof StoreRegistry]>No--Initial values for the context store
onPartial<Record<EventName, EventHandler | EventHandler[]>>No--Event handlers to register on context creation
oncePartial<Record<EventName, EventHandler | EventHandler[]>>No--One-time event handlers that fire once then auto-unsubscribe
cronPartial<CronOptions>No--Default options for all cron() sources (details)
direct{ channelType?: DirectChannelType }No--Custom channel implementation for all direct() endpoints (details)
serversRecord<string, HttpServerDefinition>Only when a surface mounts HTTP--Named HTTP listeners that HTTP, MCP, ops, and custom plugins mount onto (details)
httpHttpPluginOptionsNo--Serve routes over HTTP for the http() source (details)
opsOpsConfigNo--Health, readiness, and liveness endpoints (details)
remotesRemotesConfigNo--Other running instances whose dispatchable routes become direct endpoints here (details)
mailMailContextConfigNo--Mail adapter accounts (IMAP/SMTP) keyed by name
telemetryTelemetryOptionsNo--Telemetry plugin configuration (SQLite, OpenTelemetry)
deferralDeferralConfigOnly when a route can reach .defer() or .resume()--Where deferred exchanges are stored and how resume tokens are signed. A context with a deferrable route and no deferral block refuses to start with RC5052; the fields inside it are individually optional (details)
shutdown{ timeout?: Duration }No{ timeout: 30000 }How long a graceful shutdown may drain before it is forced (details)
pluginsCraftPlugin[]No--Custom plugins to initialize before routes are registered

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.

FieldTypeEquivalent factory
llmLlmPluginOptionsllmPlugin(options)
mcpMcpPluginOptionsmcpPlugin(options)
embeddingEmbeddingPluginOptionsembeddingPlugin(options)
agentAgentPluginOptionsagentPlugin(options)
sessionsAgentSessionsConfigsessionsPlugin(options); see sessions
acpAcpPluginOptionsacpPlugin(options); see acp
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.

Note

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 },
}
OptionTypeDescription
timezonestringIANA timezone (e.g. "America/New_York", "UTC")
maxFiresnumberMaximum fires before stopping
maxJitterDurationUpper bound on a random delay added to each fire
namestringHuman-readable job name for observability
protectbooleanPrevent overlapping handler execution
startAtDate | stringDate/ISO string at which cron jobs start
stopAtDate | stringDate/ISO string at which cron jobs stop

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 },
}
OptionTypeDescription
channelTypeDirectChannelTypeChannel constructor used for all direct endpoints

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.

OptionTypeDefaultDescription
serverstringdefaultSingle-mount sugar: named entry under servers for the lone default mount. Mutually exclusive with mounts.
authHttpAuth | falseinheritedSingle-mount sugar: wall for a lone default mount at /. false removes the wall; the inherited validator stays reachable for .authorize(). Mutually exclusive with mounts.
mountsRecord<string, { path, server?, auth? }>one default mount at /Named path-scoped surfaces, each naming its own listener and wall; routes pick one via http({ mount }). See the adapter reference.
maxBodySizenumber10485760 (10 MB)Maximum request body in bytes; larger requests return 413.
events{ perRequest?: boolean }{ perRequest: true }Toggle the per-request plugin:http:request:completed event.
builtins{ health?, ready?, openapi?: { enabled?: boolean; requireAuth?: boolean } }see adapter referencePer-endpoint config for /health, /ready, /openapi.json. Uniform { enabled, requireAuth } shape per built-in (inspired by Spring Boot Actuator).

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],
  },
})
OptionTypeDefaultDescription
serverstring'default'Named server to mount /health and /ops on. Give the surface its own entry in servers to put it on a dedicated port.
authHttpAuth | falseinherits servers.<name>.authValidator for the details gate, and the one that identifies callers for the management tiers. Never a wall over /health: probes always answer without a credential. false opts the mount out, so a scope-gated tier then fails the boot.
health{ details?: 'always' | 'when-authenticated' | 'never' }{ details: 'when-authenticated' }How much per-component detail to expose. Statuses are always served; with no validator in scope the default withholds details (explicit when-authenticated with none refuses the boot).
tiers.introspectionfalse | true | stringfalseAdmission to GET /ops/routes and GET /ops/routes/{id}. See the management API.
tiers.dispatchfalse | true | stringfalseAdmission to POST /ops/routes/{id}/exchanges. Always its own scope.
tiers.eventsfalse | true | stringfalseAdmission to GET /ops/events, the live event tail.
indicatorsIndicator[][]Indicators to register, declared with defineIndicator.

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')).

OptionTypeDefaultDescription
urlstringrequiredThe instance's base URL
auth.tokenstring | () => string | undefined | Promise<...>--Bearer for the remote's door, evaluated per request when a function
timeoutDuration'30s'Per-request deadline against the remote
refreshDuration | false'60s'How often the inventory is re-read; false disables the interval

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' },
  },
})
OptionTypeDefaultDescription
storestring | { path: string } | "memory" | DeferralStore.routecraft/deferrals.dbWhere deferred exchanges live. A path opens the SQLite backend. "memory" opts into the in-process backend, accepting that deferred exchanges die with the process. A DeferralStore instance plugs in a backend of your own.
secretstring--HMAC secret for signing resume tokens. Prefer the environment variable below; this field is for deployments that fetch secrets at boot and pass them in code.
defaultTtlDuration | "never"72hHow long a deferral stays resumable when .defer() names no ttl of its own. "never" opts the context out of default expiry entirely, so an unanswered deferral is kept until something else retires it.
sweepIntervalDuration60sHow often the sweeper looks for overdue deferrals. TTLs are measured in hours, so sub-minute precision buys nothing; the knob is for tests and for deployments that want expiry noticed sooner.
expiryLeaseDuration60mHow long an expiry-delivery claim is honoured before the sweeper releases it for redelivery. Only a crash mid-delivery ever spends it; keep it longer than your slowest .error() handler, or one healthy process will double-deliver by itself.
retentionDuration | "never"90dHow long settled deferrals (resumed, expired, denied) are kept before the sweeper purges them, measured from the moment the record settled, not from when it deferred: an approval that waited 89 days and resolved yesterday still gets its full window. "never" keeps everything for audit deployments, and the store then grows with every exchange that ever deferred.

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:

VariablePurpose
ROUTECRAFT_DEFERRAL_STOREStore location: a file path, or the literal memory.
ROUTECRAFT_DEFERRAL_SECRETResume-token signing secret.

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' } },
})
OptionTypeDefaultDescription
storestring | { path: string } | "memory" | SessionStore.routecraft/sessions.dbWhere session records live. A path opens the SQLite backend. "memory" opts into the in-process backend, accepting that every conversation dies with the process. A SessionStore instance plugs in a backend of your own: five operations (get, create, replace under a version compare-and-swap, keys, remove) plus close, the contract both shipped backends pass the same test suite against.
VariablePurpose
ROUTECRAFT_SESSION_STOREStore location: a file path, or the literal memory. An explicit config value wins.

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.

Warning

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',
    }),
  },
})
OptionTypeDefaultDescription
pathstring'/acp'HTTP path the protocol is mounted on
serverstring'default'Named entry under servers to mount on
authHttpAuth | falseinherits servers.<name>.authAuth for the mount; false removes the wall
agentstringthe only registered agentThe agent a harness serves when it names none
corsHttpCorsOptions | falseloopback-onlyCORS for the mount
browserOriginsreadonly string[][]Exact HTTP(S) origins allowed to send browser requests, independently of CORS. Clients without Origin are unaffected.
agentInfo{ name?, title?, version? }{ name: 'routecraft', title: 'Routecraft', version }What the editor calls this agent
toolCallPayloadsbooleantrueWhether a tool call's arguments and result reach the editor

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:completed against route:exchange:started will 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):

  1. Environment variables -- LOG_LEVEL / CRAFT_LOG_LEVEL, LOG_FILE / CRAFT_LOG_FILE, LOG_REDACT / CRAFT_LOG_REDACT (comma-separated paths to redact)
  2. Config file in cwd -- craft.log.cjs or craft.log.js in the current working directory
  3. Config file in home -- craft.log.cjs or craft.log.js in ~/.routecraft/
  4. 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.


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.

Previous
Events
Next
CLI