<!--
Sitemap:
- [Installation](/installation)
- [Upgrading](/upgrading): Version-specific steps for upgrading an existing Bento install.
- [Concepts](/concepts)
- [Build your first pipeline](/tutorials/pipeline-args)
- [Target a specific issue or PR from a URL](/tutorials/url-targeting)
- [Keep state across runs](/tutorials/pipeline-state)
- [Fire a pipeline on a schedule or on demand](/tutorials/schedule-and-fire)
- [Deploy a box to Railway](/tutorials/deploy-to-railway)
- [Operate a hosted daemon](/tutorials/operate-a-hosted-daemon)
- [Configuration](/configuration)
- [Members](/members)
- [Knowledge base](/knowledge-base/)
- [Method and delivery](/knowledge-base/modes)
- [Config](/knowledge-base/config)
- [MCP](/knowledge-base/mcp)
- [Pipeline configuration reference](/pipelines/config)
- [Filters](/pipelines/filters)
- [Triggers](/triggers/)
- [GitHub trigger](/triggers/github)
- [Linear trigger](/triggers/linear)
- [Webhook trigger](/triggers/webhook)
- [Schedule trigger](/triggers/schedule)
- [Manual trigger](/triggers/manual)
- [Traces](/pipelines/traces)
- [Slack](/integrations/slack)
- [Public access](/public-access)
- [Context engineering](/context-engineering)
- [Best practices](/best-practices)
- [Troubleshooting](/troubleshooting)
- [Architecture](/architecture/vision)
- [Workspaces](/workspaces)
- [Authentication](/authentication)
- [Identity](/identity)
- [Security](/security)
- [References](/references)
- [Changelog](/changelog): Bento release history.
- [CLI reference](/cli/)
- [Setup](/cli/setup)
- [Secrets](/cli/secrets)
- [Lifecycle](/cli/lifecycle)
- [Sandbox image](/cli/image)
- [Sandboxes](/cli/sandbox)
- [Observability](/cli/observability)
- [Diagnostics](/cli/diagnostics)
- [Triggers](/cli/triggers)
- [Workbench](/cli/workbench)
- [Auth](/cli/auth)
- [Knowledge](/cli/knowledge)
- [Evals](/cli/evals)
- [Bento](/index)
- [Runtime wrapper](/architecture/runtime-wrapper)
- [Skill evolve](/architecture/skill-evolve)
-->

# Configuration

`bento init` creates these configuration files in your project directory:

* **`.bento/daemon.yaml`** — daemon-wide settings
* **`.bento/pipelines/*.yaml`** — one file per pipeline (see [Pipeline Config](/pipelines/config))

Runtime state lives under `~/.bento/<name>/` and is never committed. Environment variables expand with `${VAR}` syntax in both files.

***

## Identity

```yaml
name: bento
```

Sets the launchd/systemd service name and the runtime directory (`~/.bento/<name>/`). Make it unique per machine when you run several daemon instances.

***

## Port

```yaml
port: 7890
```

The port for the daemon's HTTP server, which serves both webhooks and the CLI API. CLI commands read this from the project config automatically, so the daemon of each project runs on its own port with no flags. Defaults to `7890`. Supersedes the legacy `webhooks.port`.

***

## GitHub

```yaml
github:
  username: your-org-agent
```

Sets the GitHub Agent principal used by daemon API calls, post-backs, git attribution, and actor runs. `username` is required when any pipeline has a GitHub trigger, GitHub output, or repo-backed checkout, and when the top-level `repos` map is non-empty. Authenticate it with `bento setup gh` or `bento setup gh --env AGENT_GITHUB_TOKEN`. Credentials live under `~/.bento/<name>/gh`. On a dedicated daemon host, add `--host` to authenticate the machine's default `gh` profile too.

This is the single-principal compatibility form. A pipeline instead declares [principal bindings](/identity#principal-bindings) and selects a canonical GitHub principal at each actor boundary. The top-level `repos` HTTP API still requires `github.username` because it has no pipeline execution boundary.

***

## Linear

```yaml
linear:
  client_id: ${LINEAR_CLIENT_ID}
  client_secret: ${LINEAR_CLIENT_SECRET}
```

Sets the OAuth application that Bento installs in a Linear workspace as an agent. Both fields are required when the block is present. `bento setup linear` prefers `LINEAR_CLIENT_ID` and `LINEAR_CLIENT_SECRET` from the environment over the corresponding YAML fields. It completes the `actor=app` installation and stores encrypted credentials in Postgres under the daemon's `name`. It does not write tokens to `~/.bento/credentials`. Complete the [encrypted credential prerequisites](/cli/setup#encrypted-credentials) before setup. Remove the YAML fields and setup variables afterward. The daemon reads the client secret from Postgres. The command prints `linear.principal.<app-user-id>` for use in [principal bindings](/identity#principal-bindings).

***

## Database

```yaml
database:
  url: postgresql://bento:bento@localhost:8421/bento
```

PostgreSQL connection string. **Required** — the daemon refuses to boot without it, and there is no built-in default (a silent fallback could land two daemons on the same database). An unset `${VAR}` reference expands to empty, which counts as unset. The quickstart Compose file starts Postgres on port `8421` with the credentials shown above.

***

## Webhooks

```yaml
webhooks:
  host: 127.0.0.1
  sources:
    github:
      verify: github
      secret: ${GITHUB_WEBHOOK_SECRET}
    linear:
      verify: linear
      secret: ${LINEAR_WEBHOOK_SECRET}
    deploy:
      verify: bearer
      secret: ${BENTO_DEPLOY_TOKEN}
```

The daemon listens for inbound webhook payloads here (on the root `port:` above). Each entry under `sources` is one inbound source. The key names the route. `verify` selects the check for requests to that route.

| `verify` | Check |
|---|---|
| `github` | HMAC-SHA256 over the raw body, against `x-hub-signature-256` |
| `linear` | HMAC-SHA256 over the raw body, against `linear-signature`, plus a `webhookTimestamp` within one minute |
| `bearer` | `authorization: Bearer <secret>` |

A source named `<name>` serves `/webhooks/<name>`, and `/events` is an alias for the `github` route.

How a pipeline reaches a source follows `verify`, not the key. `verify: bearer` delivers a generic event under the source's own name, so a pipeline reaches it with `trigger.webhook: [<name>]`. `verify: github` and `verify: linear` deliver GitHub and Linear events, so a pipeline reaches them with `trigger.github` and `trigger.linear` whatever the source is called.

That makes a second GitHub-signed sender an extra route rather than an extra trigger name:

```yaml
  sources:
    gh-enterprise:
      verify: github
      secret: ${GHE_WEBHOOK_SECRET}
```

Deliveries to `/webhooks/gh-enterprise` verify against that secret and then match `trigger.github` alongside the `github` source's own. A `trigger.webhook: [gh-enterprise]` that points at it fails at startup, because that trigger never fires.

Set a `github` or `linear` secret to the signing secret the provider shows you. For `bearer` you pick the value and give it to the caller.

Use `secret_ref` instead of `secret` to load a stored secret from the [secrets source](/cli/secrets#sources) of the daemon:

```yaml
webhooks:
  sources:
    linear:
      verify: linear
      secret_ref: webhook/linear-signing
```

A reference is the full secret name, `<kind>/<leaf>`, in lower case with hyphens. Store the secret with `bento setup webhook linear-signing`, or pipe a value into `bento secrets set webhook/linear-signing`. Each source accepts exactly one of `secret` and `secret_ref`. A reference that does not match the name grammar fails validation, and the message names the full form. The daemon resolves references within its own `name` and rejects a missing or unreadable secret at boot. Resolved secrets stay outside the cached configuration. See [secrets](/cli/secrets) and [key rotation](/cli/secrets#rotate).

The daemon rejects a request that fails its check with `401`, before it parses the body or records an event. The daemon rejects a route with no source the same way, so a caller learns nothing about which sources exist. See [Security](/security#webhook-validation).

Before `sources`, credentials were `webhooks.secret`, `webhooks.linear`, and `webhooks.tokens.<name>`. All three are gone. If the daemon finds one at startup, it names the replacement.

***

## Targets

```yaml
targets:
  claude-high:
    runtime: claude
    model: claude-opus-5-5
    effort: high
    credential_pool: [max-0x, max-1x, max-2x]
  claude-backup:
    runtime: claude
    model: claude-sonnet-5
    credential: max-0x
  codex-high:
    runtime: codex
    model: gpt-5.6-sol
    effort: high
    credential: oai-personal
    description: strong non-Claude route for a diverse second opinion on code
```

Defines reusable execution targets. Each target binds a runtime, model, optional reasoning effort, and credential selection. `credential` selects one fixed named credential. `credential_pool` selects a non-empty ordered list of unique credentials. A pool mixes subscription and API-key credentials. A target sets one field or the other, never both.

`dispatchable: false` hides a target from the orchestrator's per-task catalog, so the model cannot name it in a `task` dispatch. The target stays valid as a pipeline `targets:` route and as an orchestrator route — the opt-out covers model-chosen dispatch only. Targets default to dispatchable.

`description` is an optional note on what the route is good at. The orchestrator's per-task catalog renders it after the route facts, so the model reads `codex-high (codex/gpt-5.6-sol, effort high) — strong non-Claude route for a diverse second opinion on code` instead of the route facts alone. Write it to steer a choice the model already makes: which target suits a scout, which suits the core work, which pair gives a jury two different model families. A target without a description renders as before. The daemon rejects a description that is empty or longer than 140 characters, because the whole catalog goes into every orchestrated run's prompt. The field is operator-authored, and the daemon copies only the target name, runtime, model, effort, and description into the catalog. A credential name never reaches the model.

Pipelines, the orchestrator, capabilities, MCP and HTTP invocations, `bento ask`, and knowledge rewrite reference targets. On quota or authentication failure, Bento tries each credential in a target's pool within the same run before advancing to the next target in the route.

A subscription cap — session, daily, weekly or monthly — cools that credential down until the reset time it reports.

A cooldown window is fixed once it opens: a cap reported again while the credential is already cooling does not move the reset further out, so repeated failures cannot hold a credential that has recovered. Bento retests a cooling credential about every 45 minutes, so a reset time longer than the real cap clears early instead of standing until the reported instant. Cooling the last live credential of a runtime shortens the window to 15 minutes rather than honouring the reported reset, so a runtime that loses its whole pool recovers without an operator.

Authentication failure has two different consequences, and the difference is which channel reported it.

Fallback within the current run is the weaker one. Any authentication error Bento can classify — from the agent's stderr, from a spawn error, from the runtime's own verdict — advances the run to the next candidate and drops every later candidate that shares the failed credential. The credential stays in the pool and is attempted again on the next invocation. A quoted error, or a line of tool output that reads like an authentication failure, reaches no further than this. That is what keeps a valid credential from being disabled by something the agent printed.

Removal from rotation is the stronger one, and only the runtime's own terminal event earns it. Claude reports a rejected identity there as a structured HTTP 401 or 403. Codex sends no status at all and states it in that event's message instead — `Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.` — so Bento reads the message on that one field, and nowhere else. Bento then marks the credential dead: it leaves every queue and every target that names it, receives no probes, and stays dead across restarts. An interrupted or timed-out attempt never disables the credential, whatever its output says, because an abort proves nothing about the credential.

For a Codex refresh-token rejection, sign in again and replace the configured credential with the new authentication data, then clear the dead credential as below.

To bring one back, replace the secret, delete its row from the `credential_cooldowns` table, and restart the daemon. All three steps are needed: the pool reads that table once at startup and is authoritative from then on, and `credentials` is restart-required, so neither a replaced secret nor a deleted row reaches a running daemon.

Leave the row alone and Bento drops it on the first restart more than 30 days after the failure, and the credential is attemptable again from then on.

Knowledge rewrite uses an ordered target route. On quota or authentication failure, it tries the next candidate on each call and records no cooldown. Bento searches on the unrewritten prompt when the route gives no answer.

A capability definition declares an optional ordered `targets` list. Without one, the capability inherits the parent invocation route.

## Orchestrator

```yaml
orchestrator:
  targets: [claude-high, claude-backup]
```

Sets ordered target defaults for pipelines that explicitly declare `orchestrator.strategy.type: auto`. This block does not enable orchestration by itself. A pipeline that omits its own `orchestrator` field spawns its named agent directly. There is no built-in target default. An opted-in pipeline requires this block. The in-process orchestrator supports a target whose runtime is `claude`. Bento tries the fallback targets before it dispatches any specialist work, and they share the invocation timeout.

The orchestrator target is independent of each pipeline's specialist targets.

## Host tools

Host tools are daemon-host executables that the daemon exposes through MCP. Each tool is registered as `host_<name>`:

```yaml
host_tools:
  deploy_status:
    description: Check deploy status for a service
    input:
      service: { type: string, required: true }
    exec: ./scripts/deploy-status.sh
    args: ["--service", "$service"]
```

| Field | Description |
|-------|-------------|
| `description` | Tool title shown to the agent. |
| `input` | Parameter map. Each entry has `type` (`string` | `number` | `boolean`), optional `required`, optional `default`. |
| `exec` | Executable that runs on the daemon host with a 120 s timeout. Use an absolute path, `./` path, or `../` path. It takes no placeholder. |
| `args` | Argument list. Each entry becomes exactly one argv element after `$name` substitution. |
| `stdin` | Written to the process's stdin. Put structured input here. |
| `env` | Extra environment variables for the process. Put secrets here. |

The daemon starts the process directly. It does not use a shell. A value that contains `;`, `&&`, `|`, a backtick, `$(…)`, a quote, or a newline stays one argument. It never starts a second command.

Bento does not support the `run:` shell template, and it rejects a config that contains one. Put the program in `exec`. Put each remaining word in one `args` entry.

Bento also rejects `orchestrator.tools`. Move its definitions to `host_tools`.

Do not put an input placeholder in the `PATH` value.

Each host tool needs its own grant. To call `host_<name>`, the token of the caller holds the scope `host:execute:<name>` or the `*` wildcard. `mcp:write` does not authorize a host tool. The daemon refuses the token it issues to a sandboxed agent, whatever scopes that token carries. An agent inside a sandbox never runs a command on the daemon host. The daemon logs the token id, the tool, and the decision. It does not log argument values.

***

## Formatter

```yaml
formatter:
  target: claude-high
```

Runs an optional model pass over structured `reply_body` output before posting it. Omit the block to post the extracted body unchanged.

***

## Ask

```yaml
ask:
  targets: [claude-haiku-low, codex-low]
```

Sets the ordered target route used by `bento ask` invocations. The first name is the primary target. A quota or authentication failure advances to the next candidate. Any other failure stops the command and shows the error. Each target expands its own `credential_pool` in declaration order before the route moves to the next target, so a route can cross runtimes. The daemon refuses to start when this field is missing, when a name is not in `targets:`, or when a name repeats. Pass `--target` one or more times to replace the configured route for one invocation.

The earlier scalar `ask.target` is refused. Replace it with a one-name route, `targets: [claude-high]`.

| Field | Description |
|-------|-------------|
| `targets` | Ordered non-empty list of unique names from `targets:`. |

***

## Credentials

```yaml
credentials:
  claude:
    max-0x:
      kind: subscription
      env: CLAUDE_CODE_OAUTH_TOKEN
    work:
      kind: subscription
      path: ${HOME}/.bento/provider-credentials/claude/work/.credentials.json
    claude-api:
      kind: api_key
      env: ANTHROPIC_API_KEY_WORK
  codex:
    oai-personal:
      kind: subscription
      path: ${HOME}/.codex/auth.json
    openai-api:
      kind: api_key
      env: OPENAI_API_KEY_WORK
```

Defines named model-provider accounts referenced by targets. Credential names are unique across runtimes. Secrets remain in environment variables or auth files. Configuration stores only their source names or paths.

### Configure subscription credentials

To add separate work and personal Codex subscriptions, run these commands from the project directory:

```sh
bento setup codex work
bento setup codex personal
```

Complete each provider login. Bento stores the files under `~/.bento/provider-credentials/` and adds these entries to `.bento/daemon.yaml`:

```yaml
credentials:
  codex:
    work:
      kind: subscription
      path: ${HOME}/.bento/provider-credentials/codex/work/auth.json
    personal:
      kind: subscription
      path: ${HOME}/.bento/provider-credentials/codex/personal/auth.json
```

To prefer work and use personal only after an authentication or quota failure, add the accounts to a target in that order:

```yaml
targets:
  codex-high:
    runtime: codex
    model: gpt-5.6-sol
    credential_pool: [work, personal]
```

The setup command rejects an existing name before it opens the browser. To replace a named subscription, run `bento setup PROVIDER NAME --reauth`. Bento writes `${HOME}` in the configuration, so the configuration does not contain your home directory name. Restart the daemon after you add or replace a credential.

| Runtime / kind | Source | Delivered as |
|----------------|--------|--------------|
| `claude` / `subscription` | `env` or `path` | `CLAUDE_CODE_OAUTH_TOKEN` or per-run `.credentials.json` |
| `claude` / `api_key` | `env` | `ANTHROPIC_API_KEY` |
| `codex` / `subscription` | `path` | Per-run `auth.json` in `CODEX_HOME` |
| `codex` / `api_key` | `env` | `CODEX_API_KEY` for the `codex exec` invocation |
| `pi` / `api_key` | `env` | `OPENROUTER_API_KEY` |
| `pi-google` / `api_key` | `env` | `GEMINI_API_KEY` |
| `opencode` / `api_key` | `env` | `OPENROUTER_API_KEY` |

Every run receives only its selected provider credential. Bento removes every other native provider credential variable from that run's environment. Daytona receives path-backed Codex authentication through a per-run remote `CODEX_HOME`. The daemon does not mount the source file.

`api` remains accepted as an alias for existing configurations.

The registry is fixed at daemon startup. After editing it, run `bento config validate` and then `bento daemon restart`.

***

## Defaults

```yaml
defaults:
  guardrails:
    timeout: 900
  knowledge:
    method: none
    delivery: browse
    filter:
      tags: [conventions]
  sandbox:
    backend: docker
  setup:
    - run: ./scripts/index-symbols.sh
```

Global guardrails apply to every pipeline unless the pipeline overrides them. Fields merge by key. A pipeline's `guardrails:` block takes precedence where both set the same field. See [Pipeline guardrails](/pipelines/config#guardrails) for the field reference.

`defaults.knowledge` works the same way for knowledge injection (`filter` replaces wholesale rather than merging). See [Pipeline knowledge](/pipelines/config#knowledge) for the `method`/`delivery` fields.

`defaults.sandbox` and `defaults.setup` supply shareable defaults for pipelines on the same daemon. Terminal delivery is declared per pipeline with `result:` and `outputs:`; the legacy `output:` field is rejected. See [sandbox](/pipelines/config#sandbox), [setup](/pipelines/config#setup), and [terminal projections](/pipelines/config#terminal-projections) for the field references.

`defaults.sandbox` is also where a daemon-wide [dependency cache](/pipelines/config#dependency-cache) belongs. The merge is per-key, so a pipeline that declares its own `cache` replaces the default list rather than adding to it.

***

## Queue

```yaml
queue:
  host:
    concurrency: 2
  remote:
    concurrency: 8
  jitter: 10m
  retry:
    attempts: 3
  circuit_breaker:
    failure_threshold: 5
    stall_after: 15m
    cooldown: 5m
    escalate:
      webhook: https://ops.example.com/bento-alerts
      slack: { bot: alertBot, channel: C0123456789 }
```

Each queue lane has its own worker pool and concurrency limit:

* `host.concurrency` limits runs on local backends, including Docker, Podman, and just-bash.
* `remote.concurrency` limits Daytona and Cloudflare runs.

Both pools share queue persistence and workspace serialization. A job does not wait for a worker slot on another lane. The circuit breaker pauses new runs after `failure_threshold` consecutive failures. Five outcomes do not count toward it:

* An agent timeout.
* A run drained on shutdown.
* A checkout that lost its GitHub connection (`workspace_network_failed`).
* A Cloudflare sandbox bridge request refused while it redeploys itself, or a running sandbox interrupted while the platform replaces its runtime (`workspace_network_failed`). The daemon grants the run one extra attempt before either counts as a failure.
* A run that gave up waiting for a free snapshot slot (`snapshot_full`).

Run `bento queue resume` to clear a trip by hand at any time.

A tripped breaker also resumes itself after `cooldown` passes. The default `cooldown` is `5m`. Set it to a positive duration, such as `30s`, `5m`, `2h`, or `1d`. Set it to `never` to keep the breaker manual-only. A value the daemon cannot parse causes a startup error.

The job the resumed queue claims first decides what happens next. A failure trips the breaker again at once, without waiting for `failure_threshold`. It also doubles `cooldown` for the next automatic resume, up to a cap of one hour. A success resets `cooldown` to the configured value, and so does a manual `bento queue resume`. The daemon sends an `escalate` message on every trip, including a re-trip. The message names when the queue resumes, or that it does not for `cooldown: never`.

The pause and a doubled cooldown are kept in memory. A daemon restart clears both.

`bento queue status` shows why a paused queue claims nothing. A circuit-breaker pause shows a countdown to its automatic resume, or `manual resume` for `cooldown: never`. It also shows the consecutive failure count and the most recent failure messages. A queue paused for another reason, such as a startup prerequisite the daemon has not satisfied yet, shows that reason instead. That reason never resumes on its own.

The daemon also restarts itself when a queue lane stops starting jobs. A lane is unhealthy when its worker runner ended, or when the lane is stalled. A lane is stalled when these conditions stay true for longer than `stall_after`: the queue state is `running`, jobs on the lane are ready to run, and no job on the lane is active. A job that waits behind an active job in the same named queue is not ready to run. While a job on a lane is active, that lane is not stalled.

The default `stall_after` is `15m`. Set it to a positive duration, such as `30s`, `5m`, `2h`, or `1d`. A value the daemon cannot parse causes a startup error.

When a lane is unhealthy, the daemon sends one message to the `escalate` targets. The message names each unhealthy lane and the reason. Then the daemon waits until no job is active on any lane, so that the restart stops no run. At that time, the daemon drains and exits with code 1. A service manager that restarts on failure then starts the daemon again, for example `Restart=on-failure` in `bento.service`. If a job is still active one `stall_after` after the message, the daemon restarts and stops that job.

The daemon does not restart while the queue is paused by the circuit breaker, or while the queue waits for its startup prerequisites.

The sandbox backend selects the pool. Each pool defaults to 2 workers. Pipelines do not override pool selection or queue order. Jobs have equal priority and start in ready-time order, subject to workspace serialization.

Remove `queue.fast` from daemon configuration and `lane` and `priority` from pipeline files before restart. Validation rejects these retired fields. At startup, the daemon moves pending fast jobs to host capacity and resets stored job priorities. It preserves job IDs, payloads, queue keys, attempts, and ready times, except for the effective lane. Historical traces retain their original lane labels.

`jitter` spreads a burst of dispatches across a time window. Each dispatch in a burst waits for a random portion of the window before it becomes runnable. This spreads out starts, for example when a schedule labels many pull requests at once. Every dispatch still runs. Jitter does not increase throughput; `concurrency` controls it.

A dispatch counts as part of a burst when the number of other dispatches enqueued on its lane in the previous 60 seconds reaches that lane's concurrency. This threshold uses `host.concurrency` or `remote.concurrency`. Below the threshold, the dispatch has no jitter delay.

Only dispatches that create work count towards a burst. A repeat that replaces a pending job adds no load. A pipeline with a `debounce` combines its burst into one run and does not add jitter to its delay.

Set `jitter` to a positive duration, such as `30s`, `5m`, `2h`, or `1d`. A value the daemon cannot parse causes a startup error.

`circuit_breaker.escalate` tells the daemon where to announce a trip. The daemon sends the same message to each configured target when the breaker opens. Each target is independent. One target that fails does not stop the others, and a notification failure does not prevent the breaker from opening.

`escalate.webhook` POSTs `{"message": "..."}` as JSON to the given URL. `escalate.slack` posts the message with `chat.postMessage`. Set both `bot` and `channel`. `bot` names an entry under [`channels.slack.bots`](#channels), which holds the token. `channel` is the id of the Slack channel to post into. A bot name on its own is refused when the config loads, because a bot entry holds no default channel.

`escalate.telegram` is not implemented. A configured value writes a warning to the log and sends nothing.

***

## Workspaces

```yaml
workspaces:
  checkoutRetentionDays: 30
  transcriptRetentionDays: 90
  closedRetentionDays: 14
```

Retention tiers for stored workspaces. Once a workspace is idle past `checkoutRetentionDays` (default 30), the daemon reaps its regenerable `checkout/`.

Run transcripts survive on the longer `transcriptRetentionDays` tier (default 90, clamped to at least `checkoutRetentionDays` so a transcript never expires before the checkout it describes).

Notes are stored in the database and remain queryable beyond workspace retention. Independent of idleness, once a workspace's PR closes (stamped by the `system/workspace-closed` pipeline), the workspace files are removed `closedRetentionDays` (default 14) after the close.

***

## Triggers

```yaml
triggers:
  retention:
    discardedDays: 14
    completedDays: 90
```

The `system/trigger-retention-sweep` pipeline removes expired trigger rows at 19:00 on weekdays, in the daemon's time zone:

* Discarded rows expire after `discardedDays` (default 14).
* Terminal rows (`done`, `error`, `timeout`, `superseded`) expire after `completedDays` (default 90).

Discarded rows include unmatched webhooks and dispatches refused because a retry cannot change the outcome. Examples include a missing head ref at setup (`head_unchanged`), a workload lost to a restart (`workload_gone`), and an undiscovered agent or skill (`unknown_agent_reference`, `unknown_skill_reference`).

Unknown agent and skill references indicate configuration drift that requires operator action. Increase `discardedDays` if you need more time to investigate them. These rows use the discarded retention window. To find them, use `--status discarded` or `--code <code>`. The `bento trigger list --status error` command does not list them.

The sweep never deletes a non-terminal row. Each retention value is a nonnegative number of days. An invalid value fails the sweep without deleting any rows. Rows retain their full payload until deletion, so a discarded trigger remains replayable with `bento trigger replay --ignore-filter` during its retention window.

***

## Tokens

```yaml
tokens:
  retention:
    revokedDays: 30
```

The bundled `system/revoked-token-sweep` action pipeline removes expired revoked token rows hourly. A revoked token keeps its row until the sweep removes it. The sweep prevents these rows from accumulating in the `tokens` table and `bento token list`.

Revoked rows remain available for investigation for `revokedDays` (default 30) after revocation. The sweep never deletes active rows. Set a nonnegative number of days. An invalid value fails the sweep without deleting any rows.

***

## Repos

```yaml
repos:
  my-project:
    url: acme/my-project
    branch: main
    pull_request_url: graphite
    sandbox:
      image: devcontainer
```

Repo entries used by schedule pipelines that require a checkout. Bento matches the `trigger.repo` field of a pipeline against the `url` of each entry (`owner/name`). The map key (`my-project`) is a label only, and bento never matches on it.

`issue_tracker` names which tracker the repo's issues live on, surfaced through the `GET /repos` HTTP API. `github` is the only supported value, and is also the default when unset.

`pull_request_url` sets where a message links this repo's pull requests, and overrides [`pull_requests.default`](#pull-requests). See that section for the values.

`sandbox.image` sets the image the agent runs in for this repo, overriding the daemon default (`agent:default`). A pipeline's own [`sandbox.image`](/pipelines/config#sandbox) still wins over it. The value takes one of three forms:

| Form | Example | Meaning |
|------|---------|---------|
| `devcontainer` | `devcontainer` | Use the repo's own `.devcontainer/devcontainer.json` (or root `.devcontainer.json`). The `image` field is used directly. `build.dockerfile` is built locally. Errors if the repo has none. |
| `./` path | `./infra/Dockerfile`, `./svc/.devcontainer/` | Build from a repo-relative Dockerfile (its directory is the build context), or apply devcontainer semantics rooted at the named `.devcontainer/` directory. |
| image reference | `ghcr.io/acme/dev:latest` | Use a prebuilt image as-is. |

Repo-derived forms (`devcontainer`, `./` paths) resolve after the clone: setup runs in the default image, then the agent runs in the resolved one. They require a local container backend, Docker or Podman. On a remote backend such as Daytona, use an image reference instead.

The image satisfies the agent-image contract (see `packages/images/agent-default/Dockerfile`). It ships the agent command-line interface the pipeline runs: `claude`, `codex`, `pi`, or `opencode`. Runs execute as `--user <host-uid>:0`, so it also declares an `ENV HOME` pointing at a group-0-writable directory (`chown -R 0:0 $HOME && chmod -R g=u $HOME`). Base custom images on `agent:default`, or replicate both in the Dockerfile.

`sandbox.snapshot` declares a managed snapshot for the repo, in place of `sandbox.image`. A repo cannot set both.

```yaml
repos:
  my-project:
    url: acme/my-project
    branch: main
    sandbox:
      backend: cloudflare
      snapshot:
        devcontainer: .devcontainer/agents.json
        env: [NPM_BUILD_CONFIG]
        runtimes: [claude, codex]
        refresh: 7d
        retain: 2
```

Set `sandbox.backend` to `cloudflare` or `daytona`. Give exactly one of `dockerfile` and `devcontainer`, as a path inside the repository. Devcontainer builds require `cloudflare`. `ref` names the branch, tag, or commit to build and defaults to `branch`. `env` accepts the same entries as pipeline `env` (see [Environment](/pipelines/config#env)): a host variable name, a computed `command`, or a stored `secret`. Values are never stored in the declaration. `runtimes` lists the runtimes whose command-line interface must start in the image. Without it, every configured target applies. `refresh` is an interval such as `1d` or `12h`, with a minimum of `900s` and a default of `1d`. `retain` keeps 2 to 20 versions and defaults to 2. The daemon validates the declaration at boot and reports each problem with its key. The backend must be configured under `sandboxes`, and a Cloudflare declaration needs `cloudflare.accountId`.

The `system/managed-snapshots` pipeline builds the snapshot on its next 15-minute pass, or now with `bento trigger schedule system/managed-snapshots`, under the alias `owner/repo:ref`, lower case, with `/` in `ref` replaced by `-` — the same alias a run derives automatically for that branch with no declaration, so both share one snapshot and its `max_concurrent` slots. See [Branch and commit tags](/cli/sandbox#branch-and-commit-tags) for that alias form, and for the one-release fallback to the pre-rename `owner/repo:repo-<label>` alias while a daemon has not yet built under the new one. It rebuilds when the declaration changes and refreshes on the interval. A declaration change that touches only `max_concurrent` on Cloudflare is the exception: the daemon resizes the registered limit in place and does not rebuild. A failed build keeps the last ready version. The daemon builds a failed declaration again after one hour, or after `refresh` if that is shorter; [`bento sandbox snapshot retry`](/cli/sandbox#retry-a-failed-build) builds it now. A pipeline on this repo runs on the declared snapshot when it sets no [`sandbox.image` or `sandbox.snapshot`](/pipelines/config#sandbox) and its backend matches. Those two settings override the declaration, and `sandbox.snapshot: none` runs the pipeline with no snapshot. When no version is ready, or the ready version was not verified for the pipeline's runtime, setup halts before the agent starts and the invocation records a `snapshot_not_ready` or `snapshot_runtime_unverified` diagnostic. An alias always pins its newest ready version regardless of who built it, so a snapshot created by hand under this alias is not disabled or ignored: it wins admission until the declaration's next build lands. A Daytona declaration builds in the default compute class, and a pipeline that selects another class does not inherit it. The daemon reads declarations at boot, so a change needs a restart.

***

## Pull requests

```yaml
pull_requests:
  default: graphite
```

Where a message links a pull request. A message names a pull request as `owner/repo#number` and links it under the style its own repo resolves to: `repos.<name>.pull_request_url` where that repo declares one, else `pull_requests.default`, else `github`.

A style is a target bento knows, or a URL template:

| Value | Links to |
|-------|----------|
| `github` | `https://github.com/{owner}/{repo}/pull/{number}` |
| `graphite` | `https://app.graphite.com/github/pr/{owner}/{repo}/{number}` |
| a URL template | the template, with `{owner}`, `{repo}`, and `{number}` replaced |

A template must contain `{number}`, or every pull request links to one page. A value that is neither a known target nor a URL fails configuration validation, because bento would otherwise link to the misspelled word.

***

## Lists

```yaml
lists:
  watch-repos:
    - vercel/eve
    - vercel/next.js
```

Named reusable lists of strings. Reference one from a pipeline's [`instructions`](/pipelines/config#instructions) as `{{lists.<name>}}`. It expands to the items joined by newlines. Lists are operator config, so the substituted text is trusted — not wrapped in `<untrusted>`. A reference to a name with no matching list is an error. Lists work on every trigger except MCP, which delivers a final prompt and skips template substitution entirely.

***

## Lifecycle

The request lifecycle has three operator-hook phases — `validate` → built-in `retrieve` → `augment` → spawn agent → `observe`. Hooks shell out to an external command and share a JSON state contract on stdin/stdout.

```yaml
lifecycle:
  validate:
    - type: command
      name: schema-check
      run: /usr/local/bin/bento-validate
      timeout: 10s
      match:
        equals: reviewer
  observe:
    - type: command
      name: notify-ops
      run: /usr/local/bin/notify-ops
      timeout: 5s
```

The built-in `retrieve` phase runs for pipelines with a retrieving [method](/knowledge-base/modes) (`bm25`/`vector`/`hybrid`). Its engine settings live under [`knowledge.retrieval`](#knowledge), not here.

### `validate`, `augment`, `observe`

Each is a list of `command` hooks. A `validate` failure vetoes the run. `augment` mutates state into the spawn. `observe` runs after the spawn and never vetoes.

| Field | Description |
|-------|-------------|
| `type` | Must be `command`. (`prompt` and `http` are reserved in the schema but not yet implemented. Configuring them errors the step.) |
| `name` | Step name. Surfaces in trace output. |
| `run` | Absolute path to the executable. Relative paths are rejected at startup. |
| `timeout` | Optional duration string (`5s`, `30s`, `2m`). |
| `match.equals` | Optional — only run when the agent or skill name equals this value. |

***

## Sandbox

```yaml
sandboxes:
  backend: docker
```

Override the sandbox backend: `docker`, `podman`, `daytona`, or `cloudflare`. Omit this block to let the daemon auto-detect (`podman`, then `docker`). See [Pipeline config](/pipelines/config#sandbox) to override it per pipeline.

Environment injection is per-pipeline. Each pipeline lists the variables its runs receive in its own [`env:`](/pipelines/config#env) manifest.

`backend: daytona` requires a `daytona:` block alongside it (the API key itself comes from the `DAYTONA_API_KEY` environment variable):

```yaml
sandboxes:
  backend: daytona
  daytona:
    target: us            # optional region / target identifier
    apiUrl: https://...   # optional API base URL override
    organizationId: org_x # optional, for multi-org accounts
    image: ./Dockerfile.agent
    snapshot: bento-agent-default # optional, pre-built snapshot name
    debug: false           # optional, retain sandboxes for inspection
    autoStopInterval: 30   # optional, minutes before Daytona stops a debug sandbox
    autoDeleteInterval: 120 # optional, minutes after stop before Daytona deletes it
```

| Field | Description |
|-------|-------------|
| `target` | Region / target identifier passed to the Daytona client. |
| `apiUrl` | Override the Daytona API base URL. |
| `organizationId` | Organization scoping for multi-org accounts. |
| `image` | A registry image reference that Daytona pulls, or a path to a Dockerfile — a file path makes Daytona build and cache a snapshot from it. Unset → the run's resolved image is passed through as-is. |
| `snapshot` | Name of a pre-built Daytona snapshot. When set, runs boot from it directly and `image` is ignored at runtime — building a multi-GB image per run times out. |
| `debug` | Retain a completed sandbox instead of deleting it right away, so an operator can inspect it. Defaults to `false`. |
| `autoStopInterval` | Minutes before Daytona stops a retained debug sandbox. Applies only when `debug` is `true`. Must be a positive integer. Defaults to 30. |
| `autoDeleteInterval` | Minutes after stopping before Daytona deletes a retained debug sandbox. Applies only when `debug` is `true`. Must be a positive integer. Defaults to 120. |

Publish the default compute class before you run pipelines that use it. Repeat after each Dockerfile change:

```bash
DAYTONA_API_KEY=... bun run --filter @bento/sandboxes publish-daytona-snapshot
```

The script reads this repository's `.bento/daemon.yaml` and publishes its default Daytona class. Use `--class large` for one class or `--all` for every class. To select another project, pass `--config /path/to/project/.bento/daemon.yaml`.

Every selected class uses `packages/images/agent-default/Dockerfile`. Override the build source with `--dockerfile`. The `image` setting does not select the publish source. For standalone publishing without configuration, use `--name bento-agent-default`.

Publishing validates the registry before deleting and recreating snapshots, one at a time. Each successful publication includes a lookup by snapshot name. A failure names the class and snapshot, then publishing continues with the remaining classes. The command exits with an error if any class fails. Successful replacements remain published, without rollback. Runs that select a snapshot during replacement can fail.

### Compute classes

A compute class is a named sandbox size. Each backend declares its own classes, because the two backend families set a size by different means. The daemon reads the registry at startup, validates it, and logs the class names. A pipeline picks a class with [`sandbox.class`](/pipelines/config#sandbox); a pipeline that names none gets the `default` class.

On Daytona a class names a pre-built snapshot. Daytona refuses per-sandbox resources when a run starts from a snapshot, and a snapshot keeps the resources it got at build time, so the size lives in the snapshot. The class picks the snapshot the sandbox is created from.

```yaml
sandboxes:
  backend: daytona
  daytona:
    snapshot: bento-agent-default
    classes:
      small: { snapshot: bento-agent-small, resources: { cpu: 1, memory: 1, disk: 3 } }
      large: { snapshot: bento-agent-large, resources: { cpu: 4, memory: 8, disk: 20 } }
    default: small
```

On Docker and Podman a class names `cpus` and `memory`. These backends allocate when they start a container rather than when they open the sandbox, so the daemon passes the class to the container runtime as `--cpus` and `--memory` on the agent's own run. The workspace clone that precedes it stays unsized: it costs the same whichever class the agent then runs under.

```yaml
sandboxes:
  backend: docker
  docker:
    classes:
      small: { cpus: 1, memory: 1g }
      large: { cpus: 4, memory: 8g }
    default: large
```

| Field | Description |
|-------|-------------|
| `classes` | Map of a class name to a sandbox size. A Daytona class contains `snapshot` and optional build `resources: { cpu, memory, disk }`. A Docker or Podman size is `{ cpus, memory }`; set one field or both. |
| `default` | Name of the class a run gets when it selects none. Required whenever `classes` is set. |

The daemon refuses to start, and `bento config validate` reports an error, when a class value has the wrong shape for its backend, when `classes` carries no `default`, or when `default` names a class the registry does not hold. A run that names a class the registry does not hold fails before its sandbox is created, rather than booting at the wrong size.

Daytona build resources use positive whole numbers: vCPUs for `cpu`, and GiB for `memory` and `disk`. If you omit `resources`, publishing uses 2 vCPUs, 4 GiB of memory, and 10 GiB of disk. These values apply at publication. Runs still receive the resources of their selected snapshot.

A `daytona:` block that sets `snapshot` and declares no `classes` is a registry of one class. That class takes the snapshot's own name and is the default, so an existing configuration keeps the size it has today. Declaring `classes` supersedes `snapshot` as the registry the daemon reads.

#### A task's own class

An orchestrator sizes one dispatched task apart from its siblings: the `class` field on a `task` or `parallel` payload names a class from the registry of the backend the run is on. A task that names none runs at the class its invocation runs at. A task naming a class the registry does not hold is refused as that task's own failed result, and the other tasks in the same batch return normally.

Docker and Podman honour a task's class. Every task of an orchestrated run already spawns its own container, so the class sets that container's `--cpus` and `--memory` alone.

Daytona does not. It allocates one remote sandbox per invocation and the sandbox takes its size from the snapshot it booted from, so a task that names a different class runs at the invocation's class and the daemon logs that it did.

### `cacheStore`

Bounds on the host store for Docker and Podman [dependency caches](/pipelines/config#dependency-cache). The daemon applies one policy to all host cache entries.

```yaml
sandboxes:
  cacheStore:
    maxSizeGb: 30        # LRU-evict whole entries once the store root exceeds this
    retentionDays: 30    # drop an entry untouched for this long
```

| Field | Description |
|-------|-------------|
| `maxSizeGb` | Least-recently-used entries are evicted whole once the store root exceeds this many gigabytes. Defaults to 30. Must be greater than 0. |
| `retentionDays` | An entry untouched for this many days is dropped. Defaults to 30, mirroring the [workspace](#workspaces) retention tiers. Must be greater than 0. |

The sweep runs hourly, as the `system/cache-store-sweep` pipeline. It runs neither at boot nor after a run finishes: weighing the root stats every file in every store, and neither startup nor a run's completion may wait behind that. An entry's use time comes from its state sidecar, falling back to the directory mtime. The two policies run in order rather than off one scan: retention condemns and deletes on a scan that reads a sidecar and an mtime, never a size, and only then does the size pass weigh what is left. A root too large to weigh inside the sweep's five-minute deadline therefore still loses idle entries, which is what brings it back under the cap. The deadline covers the whole pass, so a retention phase slow enough to reach it is cut short too — the sweep then reports what it evicted and what it left standing, and the next hour's pass sees the rest.

Retention alone does not bound the root's size. A store is keyed by lockfile content, so a repository whose lockfile changes daily accrues a new store each day and reaches steady state at `retentionDays` stores per repository per cache path, whatever they weigh. `maxSizeGb` is what bounds the disk, which is why it has a default. The daemon refuses a non-positive value for either field at startup: both apply on every sweep, so `0` would read as an instruction and do the reverse of one — an unbounded root for `maxSizeGb`, and an eviction of every idle store for `retentionDays`. To run effectively uncapped, set a bound larger than the disk.

A store is regenerable — losing an entry costs a cold install, not correctness — so eviction never blocks a run. A store a live run has mounted is never evicted, and an entry the sweep cannot read or lock is left for the next pass.

These settings do not apply to Daytona managed volumes. Daytona keeps the two newest complete archives for each exact cache key. Automatic retention for Daytona volumes is not yet available.

***

## Knowledge

```yaml
knowledge:
  retrieval:
    topK: 20
    minScore: 0.2
    rewrite:
      targets: [claude-haiku-low, codex-low]
      timeoutMs: 4000
```

Retrieval engine tuning — how the selected retrieval engine produces results for retrieving pipelines (`method: bm25`/`vector`/`hybrid`), `bento ask`, and `ask_knowledge`. See [Knowledge base config](/knowledge-base/config) for the field reference. How results reach a run is the per-pipeline [`knowledge:` block](/pipelines/config#knowledge) and [`defaults.knowledge`](#defaults).

***

## Tunnel

```yaml
tunnel:
  provider: cloudflare
  mode: quick
```

Start a public tunnel at daemon startup. See [Public access](/public-access) for the setup.

***

## Logging

```yaml
logging:
  level: info
```

Log verbosity: `error`, `warn`, `info`, `debug`.

***

## Observability

```yaml
observability:
  langfuse:
    baseUrl: https://cloud.langfuse.com
    publicKey: pk-lf-...
    secretKey: ${LANGFUSE_SECRET_KEY}
  axiom:
    dataset: bento
    token: ${AXIOM_TOKEN}
  sentry:
    dsn: ${SENTRY_DSN}
    environment: production
```

Tracing for spawned agent CLIs (post-hoc), plus structured log shipping.

**Spawned agent CLIs (Langfuse):** after each agent run the daemon projects the persisted lineage, artifact manifest, and transcript into typed Langfuse observations (agent, span, generation, tool) via the Langfuse v5 OTel SDK — works for all backends and runtimes (claude, codex, Daytona). Each trace carries `trigger_id` in its metadata for cross-reference. The in-process orchestrator `query()` loop is not traced.

**Daemon logs (Axiom):** every log line the daemon emits (at the configured `logging.level`) is also shipped to Axiom as a structured OTel log record over OTLP — message as body, log data as attributes, and bento IDs (`run_…`, `trg_…`) lifted into `bento.*` attributes for per-run querying. Console output is unchanged. Records batch. The daemon drains the last batch on shutdown.

**Error/warn lines (Sentry):** every `error` and `warn` log line is forwarded to Sentry's HTTP envelope endpoint — message, level, and component only, never the log line's `data` bag. Best-effort: a failed send is warned once and dropped.

Omit the block to disable tracing. Every sink runs at the same time.

| Field | Description |
|-------|-------------|
| `langfuse.baseUrl` | Langfuse instance base URL. |
| `langfuse.publicKey` | Project public key (`pk-lf-…`). |
| `langfuse.secretKey` | Project secret key (`sk-lf-…`). |
| `axiom.dataset` | Axiom dataset the logs land in. |
| `axiom.token` | Axiom API token (`xaat-…`). |
| `axiom.url` | API base URL override. Defaults to `https://api.axiom.co`. |
| `sentry.dsn` | **Required.** Project DSN (`https://<key>@<host>/<project>`). |
| `sentry.environment` | Environment tag attached to every event (e.g. `production`). |

***

## Channels

```yaml
channels:
  slack:
    bots:
      standup:
        token: ${SLACK_STANDUP_TOKEN}
      reviewer:
        token: ${SLACK_REVIEWER_TOKEN}
  telegram:
    default_agent: reviewer
```

Named messaging identities that a pipeline posts as. `channels.slack.bots.<name>` is a Slack app identity (one bot token). A pipeline names which bot to use in `outputs.<key>.config.bot`. See [Slack](/integrations/slack) for the projection fields.

### Evals (Slack)

Configuration for the [`eval:`](/pipelines/config#eval) pipeline block, which asks a person a closed question over Slack when a run completes.

```yaml
channels:
  slack:
    bots:
      reviewer:
        token: ${SLACK_REVIEWER_TOKEN}
        signing_secret: ${SLACK_REVIEWER_SIGNING_SECRET}
    eval_bot: reviewer
    max_open_evals_per_person: 3
```

Manage the people, agents, and services that Bento can address in the [member directory](/members). The member directory is a manually maintained `.bento/members.yaml` file for Self-Hosted deployments. Cloud-managed member directories are coming soon.

| Field | Description |
|-------|--------------|
| `channels.slack.eval_bot` | Which `channels.slack.bots` entry sends and answers eval requests. Unset → evals are never sent, and requests land `skipped`. |
| `bots.<name>.signing_secret` | The Slack app's **signing secret**, from **Basic Information → App Credentials**. A different credential from `token`: `token` authenticates outbound calls bento makes to Slack. `signing_secret` authenticates inbound requests Slack makes to bento. `eval_bot` needs both — a bot with a token but no signing secret sends questions that no one answers. |
| `channels.slack.max_open_evals_per_person` | Cap on the `requested` evals one person holds open at once, across all pipelines. Default `3`. |
For answers to arrive, the Slack app also needs an interactivity Request URL pointing at the daemon. Under **Interactivity & Shortcuts**, turn interactivity on and set the Request URL to `https://<daemon-host>/slack/interactivity`. Slack reaches the daemon the same way your git forge does — see [Tunnel](#tunnel) for a local daemon.

***

## Resources

```yaml
resources:
  wiki:
    paths: docs/wiki
    description: Engineering decisions and operating practices
  blueprints: ["docs/blueprints/**/*.md", "external/specs/**/*.md"]
  recipes: docs/recipes
  agents: ".bento/agents"
  skills: ".bento/skills"
```

Override where the daemon discovers each resource type. Each value is a glob (or list of globs) relative to the project root. It also takes a bare directory that expands against the type's default marker (`.md` for knowledge, `PERSONA.md`/`SKILL.md` for definitions). Use the object form when you want to add retrieval context. `description` is the canonical field, and `context` is an alias. Omit a key to keep its default:

| Type | Default glob |
|------|--------------|
| `wiki` | `wiki/**/*.md` |
| `blueprints` | `blueprints/**/*.md` |
| `recipes` | `recipes/**/*.md` |
| `agents` | `agents/*/PERSONA.md` |
| `skills` | `skills/*/SKILL.md` |

The daemon attaches a resource description to the QMD collection and includes it when Chroma seeds document embeddings. It also uses each document's frontmatter `description` and `tags` during indexing.

`knowledge.sources.{wiki,blueprints,recipes}` is a legacy alias for the three knowledge keys. `resources` wins when both are set.

***

## Auth

```yaml
auth:
  tokens:
    - id: cli-prod
      secret: ${BENTO_TOKEN_CLI}
      scopes: ["*"]
```

Static bearer tokens for authenticated access to the daemon, an alternative to CLI-issued tokens (`bento token issue`). Any token you set activates authentication, and every request then carries `Authorization: Bearer <secret>`. `scopes` is the policy surface: every route except `/` and `/health` requires a scope, and `*` grants all of them. See [Authentication](/authentication) for the scope list, full details, and remote topology.

***

## Shutdown

```yaml
shutdown:
  drainSeconds: 20      # cap on aborting in-flight agent runs
  forceExitSeconds: 30  # hard exit deadline for the whole shutdown
  notices: [ec2]        # extra termination sources; signals are always watched
```

Controls what happens when the platform says this instance is going away. Every notice — SIGTERM/SIGINT, an EC2 spot reclaim or ASG rotation, `POST /drain` — starts the same drain: `/health` flips to a 503 with status `draining`, the HTTP listener stops accepting new connections while the requests already in flight run to completion, the queue stops claiming jobs, the daemon aborts each in-flight agent run so its work stays re-dispatchable, and the process exits.

Draining aborts runs, it never waits for them. Agent runs take minutes and grace windows are tens of seconds.

When a notice carries a deadline (EC2 spot gives about 2 minutes), the budget becomes `deadline - now - 5s` — capped by the values above, so a deadline only ever tightens the window, never widens it.

`notices` is opt-in and empty by default. Listing `ec2` makes the daemon probe `/latest/meta-data/instance-life-cycle` once at startup and then poll the instance metadata service every 5 seconds over IMDSv2. Without it the daemon never contacts `169.254.169.254`. On an ASG, a [lifecycle hook](https://docs.aws.amazon.com/autoscaling/ec2/userguide/lifecycle-hooks.html) is what buys a real window — without one the rotation notice arrives with the signal.

`POST /drain` starts the same drain from any platform without EC2-specific config — a Kubernetes `preStop` hook, an ECS task lifecycle hook, or an operator. It requires a bearer token and is idempotent: a second call reports `{"draining": true, "alreadyDraining": true}` and changes nothing.

Edits to `shutdown` require validation and a daemon restart.

## Cloudflare deployment and secret storage

```yaml
cloudflare:
  accountId: ${CLOUDFLARE_ACCOUNT_ID}
secrets:
  instance: postgres
  company: sops
  sops:
    file: secrets.dev.yaml
sandboxes:
  cloudflare:
    apiUrl: https://bento-sandbox-bridge.YOUR_SUBDOMAIN.workers.dev
    apiKeySecret: sandbox/bento-sandbox-bridge-api-key
    deployment:
      workerName: bento-sandbox-bridge
```

The SOPS company slot is optional. See [secret sources](/cli/secrets#sops-files) for its prerequisites. Without a company slot, the instance source holds the bridge key.

Run [Cloudflare setup](/cli/sandbox#cloudflare-bridge) to generate the bridge key and write its reference and deployment identity. The account ID belongs only under top-level `cloudflare`. The `deployment` block identifies a Bento-managed Worker. An unmanaged endpoint omits it.

Configure exactly one of `apiKey` or `apiKeySecret` beside `apiUrl`. `apiKeySecret` names a stored bridge access key; `apiKey` supplies that key directly. Neither field is the Cloudflare deployment token. Set `BENTO_CLOUDFLARE_API_TOKEN` in the deployment and snapshot publisher environment.
