enabled

enabled(
  predicate: () => boolean | string | Promise<boolean | string>,
  options?: {
    refresh?: Duration | CronExpression | 'manual'
    drainGrace?: Duration | 'never'
  },
): RouteBuilder<Current>

Gate a route on a predicate. A route whose predicate is false is disabled: registered and known to the context, not started, not intaking, and not advertised as an agent tool.

This is the state a capability without its credentials never had. Without it such a route either registers and fails when something calls it, or is commented out by hand, and a route that is missing looks exactly like one that is deliberately off, though only the first is an incident.

craft()
  .id('mail-inbound')
  .description('Triage inbound mail')
  .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'))

The verdict

  • true enables the route.
  • A string disables it, and that string is the reason ops reports. There is no separate reason argument on purpose: one declaration cannot drift from the other.
  • false disables it with a generic reason, for a predicate with nothing useful to add.
  • A predicate that throws leaves the route disabled with the error message as its reason and never fails the boot. A missing credential is a configuration state, not a reason to take the process down.

The predicate may be async; the boot waits for the verdict rather than racing it.

The tool surface follows it

The agent tool list is derived from what the context has enabled, so a disabled capability is simply never offered to the model. That is what makes "the agent cannot use this until I supply credentials" true by construction rather than by the model behaving well.

ctx.capabilities() // the disabled route's endpoint is absent
Warning

Not an access check

.enabled() is a deployment switch, not an access check. The predicate runs at boot and on the refresh cadence, never per exchange, so it does not know who is calling and cannot answer differently for two callers. It decides whether the route runs at all; once it is on, every caller that reaches the route is treated the same.

So a route being off keeps everyone out, and a route being on lets everyone in who could otherwise reach it. Use .authorize() for the per-caller boundary. The two compose: .enabled() for whether this deployment should be running the route, .authorize() for who may use it once it is.

Refresh cadence

.enabled(predicate)                            // manual (default)
.enabled(predicate, { refresh: 'manual' })     // manual, said explicitly
.enabled(predicate, { refresh: '5m' })         // interval
.enabled(predicate, { refresh: '0 * * * *' })  // cron schedule

Manual is the default: the predicate runs once as the route starts and is not run again until something explicitly asks. That keeps the common case, an environment variable that cannot change without a restart, free of any recurring cost, and it stops a predicate that reaches the network from becoming an invisible repeating one.

refresh takes the framework's Duration type (number | '5m') or a cron expression. The two are told apart by shape: every cron expression contains a space or starts with @, and no Duration does. A cron cadence loads croner, the same optional peer the cron() adapter uses.

Omitting refresh and passing 'manual' mean exactly the same thing. The sentinel exists for a computed cadence, where it says what it means instead of assembling the options object conditionally:

.enabled(predicate, { refresh: pollCadence ?? 'manual' })

Both forms fail loudly on a bad value, at the earliest moment each one can. A malformed Duration is refused while the route is built. A cron expression is checked when the cadence is armed at startup, and an expression croner refuses (or an absent croner) fails the boot rather than leaving the route silently without the refresh it declared.

The cron check happens at startup rather than at build time because croner is an optional peer loaded through an async dynamic import: its grammar is not reachable from a synchronous constructor, and the package may not be installed at all. The framework deliberately does not pre-validate the expression itself, because a second grammar of its own would disagree with croner at the edges, and rejecting an expression croner accepts is worse than checking a moment later.

Transitions

Enabled to disabled is a graceful drain, identical to what shutdown does per route: the route's intake signal fires so it stops accepting new work while in-flight exchanges finish, then execution is abandoned once the shutdown.timeout grace deadline passes. A flag flip is never a data-loss event, and there is no second stop path.

Disabled to enabled starts the route normally, and it reappears in capabilities().

How long the drain gets

drainGrace sets how long in-flight exchanges may keep running after the route stops intaking, before execution is abandoned. It defaults to shutdown.timeout, because a disable is a per-route shutdown and one knob for both is what an operator expects to find.

.enabled(predicate, { drainGrace: '2m' })      // this pipeline is slow
.enabled(predicate, { drainGrace: 'never' })   // never abandon an exchange

Set it per route when you know what your own in-flight work costs: a route whose steps take a minute needs longer than the 30 second default, and a route of fast steps can be taken out of service far quicker.

'never' waits indefinitely, so every in-flight exchange reaches a terminal outcome and nothing is abandoned. That is the only setting under which the exchange-observability invariant holds unconditionally for a disable; under any bounded grace, an exchange still running when the grace elapses is abandoned mid-step and emits no terminal event, exactly as it would in a forced shutdown. Choose 'never' when the work costs more to lose than the transition costs to wait for, and only when the pipeline is known to terminate.

Re-evaluating on demand

await ctx.reevaluateEnablement('mail-inbound') // one route
await ctx.reevaluateEnablement()               // every declaring route

This is the operator loop: set the secret, ask for a re-check, and the capability comes up without a process restart.

It needs a running context. A context whose routes have all finished, or whose only routes are dormant under the default manual cadence, has nothing left to receive a re-check through and shuts down normally rather than idling. In practice something already holds such a context open, since a capability worth gating is usually reachable over HTTP, MCP or the ops surface. A declared refresh cadence also counts: the context stays alive to serve it.

Reporting

The ops health surface reports a disabled route as disabled with its reason, distinct from failed and distinct from absent, and maps it to inactive so it is excluded from aggregation. Overall health never degrades because a route is disabled: a deliberate configuration state is not an open circuit. See opsPlugin.

A genuine transition emits route:enablement:changed. A refresh that re-confirms what the route was already doing is silent, so a five-minute cadence over a stable predicate is not a heartbeat.

Not a filter

Enablement is route metadata, not a filter. It decides whether the route runs at all, per route lifecycle rather than per exchange, so it sits beside .id() and .description() and never enters the pre-from filter chain. It never sees an exchange and never runs on the hot path.

.enabled() may be declared at most once per route.