Skip to content

Configuration reference

Bento is configured by three TOML files, by convention:

FilePurposeRequired?
bento.tomlRepo-wide defaults: cache tiers, toolchain pins, plugin filters, container executionoptional (every field defaulted)
bentos/<name>.tomlNames a deployment grouping and lists its dishesat least one
<dish>/dish.tomlNames a dish, declares its language and tasksone per dish

A minimal workspace needs just one bentos/<name>.toml and one dish.toml. The repo-wide bento.toml is optional and every field has a working default.

This page documents every field. For the conceptual model (bentos vs dishes vs tasks) see the README.


Optional repo-wide defaults. Place at the repo root next to bentos/. Every field shown here matches the built-in default — you only need to write the file at all to override something.

# bento.toml — repo-wide defaults
[defaults]
# Max dishes to run in parallel within one level of the dep graph.
# Omit to auto-size to std::thread::available_parallelism().
parallelism = 4
# Abort at the next dep-graph level boundary on the first failed dish.
fail_fast = true
[cache]
# Local content-addressed cache at ~/.bento/cache.
local = true
# GitHub Actions cache tier. true | false | "auto" (= on inside a workflow).
gha = "auto"
# Remote cache — pick ONE of the two URL schemes below.
#
# 1. S3-compatible (any bucket: AWS, Cloudflare R2, MinIO, Backblaze B2):
remote = "s3://my-bucket/optional/prefix"
remote_region = "us-east-1"
# remote_endpoint = "https://<account>.r2.cloudflarestorage.com" # non-AWS only
# Credentials from the AWS env chain:
# AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN
#
# 2. Hosted bento cache (or any Bearer-auth HTTP server implementing the
# same wire protocol):
# remote = "bento://cache.bento.build"
# remote_token_env = "BENTO_CACHE_TOKEN" # env var holding the JWT
# Credential resolution: env var first, then the OS keychain entry
# written by `bento login`, then ~/.bento/credentials (0600) as a
# headless fallback. Run `bento login` once for interactive setup;
# use $BENTO_CACHE_TOKEN in CI.
[telemetry]
# Anonymous usage metrics. Set false to opt out.
enabled = true
[execution]
# Container execution mode. never | auto | always.
# - never: tasks run on the host (default).
# - always: every task is wrapped in `<runtime> run --rm ...`.
# - auto: containerise when an image is declared AND a runtime is on PATH.
container = "never"
# Container image ref to wrap tasks in. Required for container = "always".
image = "ghcr.io/your-org/runner:1"
[toolchain]
# Repo-wide tool version pins. Each `<tool> = "<version>"` writes the
# version into bento's content-cache key, so a toolchain bump invalidates
# every dish that uses it. Per-dish pins (in dish.toml) override these.
go = "1.22.3"
node = "22.1.0"
java = "21"
# When true, bento doesn't try to install pinned versions itself —
# it expects the system PATH to already have the right tool.
use_system = false
[plugins]
# Adapter ids that should never be loaded even if found on $PATH.
disable = ["zig"]
# If set, ONLY these adapter ids are loaded; everything else is skipped silently.
allowlist = ["erlang", "elixir"]
FieldTypeDefaultDescription
parallelismintavailable_parallelism()Max concurrent dishes per dep-graph level.
fail_fastbooltrueStop at the next dep-graph level boundary on first failure.
FieldTypeDefaultDescription
localbooltrueUse the local content-addressed cache at ~/.bento/cache.
ghabool | "auto""auto"Use the GitHub Actions cache tier (the composite action wraps ~/.bento/cache with actions/cache@v4). "auto" activates only when running inside a GHA workflow.
remotestringunsetRemote cache URL. Two schemes: s3://<bucket>/<optional/prefix> (any AWS-signed object store), or bento://<host>[/<prefix>] (JWT-auth’d HTTP cache — bento://cache.bento.build for the hosted service). See README’s “Caching” section.
remote_regionstring"us-east-1"AWS region for the bucket. S3 scheme only.
remote_endpointstringunsetCustom S3-compatible endpoint URL. Required for non-AWS services (Cloudflare R2, MinIO, Backblaze B2); omit for native AWS S3. S3 scheme only.
remote_token_envstring"BENTO_CACHE_TOKEN"Name of the env var holding the JWT, for the bento:// scheme. Resolver walks env var → OS keychain entry ("bento", "cache-token") (populated by bento login) → ~/.bento/credentials (0600 fallback). Bento never stores the token in repo state.
FieldTypeDefaultDescription
enabledbooltrueAnonymous usage metrics.
FieldTypeDefaultDescription
container"never" | "auto" | "always""never"Container execution mode.
imagestringunsetContainer image ref. Required for container = "always"; advisory for "auto".

When containerised, bento runs each task as <runtime> run --rm -u <uid>:<gid> -v <dish>:/work -w /work --env HOME=/work --env <name> <image> sh -c <run>. Runtime auto-detection order: dockerpodmannerdctl. UID is preserved so output files stay host-owned.

Default HOME=/work: the container’s $HOME defaults to the mounted workdir. --user <host-uid> leaves the image’s root $HOME (often /root) unwritable by the invoking UID, so without this default, tools that default their cache dir to $HOME/.cache/<tool> — Go (GOCACHE), Cargo (CARGO_HOME), pnpm, npm — would fail on first run with a permission error. Pointing HOME at the volume mount puts those caches under the dish’s writable scratch space and keeps them across invocations. If you genuinely need a different HOME, declare it in [tasks.<name>] env = ["HOME"]: the forwarded host value wins (docker --env applies last-write per variable).

A free-form table of <tool> = "<version>" pairs, plus the boolean use_system. The keys aren’t enumerated — bento accepts any <tool> name and includes <tool>:<version> in the content-cache key.

FieldTypeDefaultDescription
use_systemboolfalseIf true, bento expects the pinned tools to already be on $PATH and won’t try to install them itself.
<tool>stringunsetPin a tool to a specific version. Examples: go = "1.22.3", node = "22.1.0", python = "3.12", ruby = "3.2.2", java = "21".

Per-dish dish.toml [toolchain] overrides these.

Filters applied to subprocess plugin discovery (binaries on $PATH matching bento-adapter-<id>). See plugins.md for the full plugin protocol.

FieldTypeDefaultDescription
disablestring[][]Adapter ids to never load.
allowliststring[] | unsetunsetIf set, ONLY these adapter ids are loaded.

Built-in adapters always win on id collision regardless of [plugins] settings.

Named deploy environments with saved secret aliases for bento deploy --env <name> and bento doctor --env <name>. Each entry maps a declared env-var name (what integrations look for, e.g. RAILWAY_TOKEN) to a source env-var name (what the host shell / CI secret layer exports, e.g. RAILWAY_TOKEN_STAGING). Never holds secret values — only name-to-name aliases.

[environments.staging]
secrets.RAILWAY_TOKEN = "RAILWAY_TOKEN_STAGING"
secrets.VERCEL_TOKEN = "VERCEL_TOKEN_STAGING"
[environments.prod]
secrets.RAILWAY_TOKEN = "RAILWAY_TOKEN_PROD"
secrets.VERCEL_TOKEN = "VERCEL_TOKEN_PROD"

With that block in place, bento deploy --env staging reads $RAILWAY_TOKEN_STAGING from the host env and exposes it to the deploy task under the name RAILWAY_TOKEN (which is what the Railway integration declares as its required env). The same mapping works identically local and in CI — in a GHA workflow you set env: RAILWAY_TOKEN_STAGING: ${{ secrets.X }} at the step level and bento resolves through the alias.

FieldTypeDefaultDescription
secrets.<DECLARED>stringSource env-var name whose value should be exposed to tasks under the declared name. Declared must match what an integration / task’s required_env declares.

Ad-hoc alternative: bento deploy --secret-from DECLARED=SOURCE on the CLI. See deploying.md for the full workflow.


One file per bento. The file’s basename is the bento’s name in CLI references (e.g. bentos/release.tomlbento ci --bento release). The name field inside the file must match the basename.

A bento is whatever logical grouping makes sense to you — environment, release stage, logical layer, customer tier. Bento is unopinionated about why; only that the dishes listed here ship together.

# bentos/release.toml — every dish in this bento ships as a unit
name = "release"
dishes = [
"apps/api",
"apps/web",
"services/billing",
]
FieldTypeRequiredDescription
namestringyesBento name. Must match the file’s basename and be unique across bentos/. No / or platform path separators allowed.
dishesstring[]yesList of dish directory paths, relative to the workspace root, in any order. Forward-slashes regardless of host OS. Empty [] is valid (a freshly initialised workspace).

A repo can (and often does) have several bentos:

bentos/
├── backend.toml # api + billing + scheduler
├── frontend.toml # web + admin
└── release.toml # everything that goes out together

A dish can appear in multiple bentos. Its content-cache key is derived from the dish, not the bento, so the same api dish in both backend and release is built once and reused.


One file per dish, in the dish’s directory (which is also the working directory for its tasks). The name field is the dish’s CLI handle (bento build api).

apps/api/dish.toml
name = "api"
language = "go"
# Files outside any task's [inputs] that should still invalidate the
# cache when they change. Adapters add their own fingerprint files
# automatically (lockfiles, toolchain pin files, .tool-versions, ...).
inputs = ["openapi.yaml"]
# Build artefacts. Globs allowed. Used by `bento artifacts` and by the
# GHA action's `artifacts` output.
outputs = ["bin/api"]
# Other dishes this one depends on. Bento builds dependencies first,
# and any change to their content invalidates this dish's cache (the
# pessimistic cascade — opt out with force_independent below).
depends_on = ["lib-shared"]
# Skip the dep-cascade for this dish — its cache key is computed from
# its own inputs only. Useful for utility dishes that genuinely don't
# care about upstream changes.
force_independent = false
# Per-dish toolchain pin. Overrides bento.toml's [toolchain] for this
# dish only.
[toolchain]
go = "1.22.5"
# Tasks. Adapters supply default `build`, `test`, `lint` recipes per
# language (plus `check` for cargo + go — the fast type-check verb);
# declare a [tasks.<name>] block here to override or add.
[tasks.build]
run = "go build -o bin/api ./cmd/api"
inputs = ["**/*.go", "go.mod", "go.sum"]
outputs = ["bin/api"]
[tasks.test]
run = "go test ./..."
env = ["DATABASE_URL", "REDIS_URL"]
retry = 1 # 1 retry → up to 2 attempts
[tasks.lint]
run = "golangci-lint run"
# Optional: hot-reload command for `bento serve` / `bento dev`.
[serve]
run = "air"
FieldTypeDefaultDescription
namestringrequiredDish handle. Used by CLI flags (bento build <name>) and bentos’ dishes list. Must be unique across the workspace.
languagestringadapter-detectedAdapter id (go, cargo, python, python-uv, ruby, php, maven, gradle, node-npm, node-pnpm, node-yarn, bun, deno, or any plugin’s id). When omitted, bento auto-detects from the dish dir.
package_managerstringunsetReserved for future use; no behaviour today.
inputsstring[][]Glob patterns relative to the dish dir. Files matching are mixed into the cache key for every task in the dish. Adapters add their own fingerprint files automatically (lockfiles, toolchain pin files, .tool-versions).
outputsstring[][]Glob patterns of build artefacts. Listed by bento artifacts and the GHA artifacts output.
depends_onstring[][]Other dish names this dish depends on. Builds upstream first. Changes upstream invalidate this dish (unless force_independent).
force_independentboolfalseOpt out of the pessimistic cascade — only this dish’s own inputs go into its cache key.

Same shape as bento.toml’s [toolchain] table — <tool> = "<version>" pairs plus optional use_system. Per-dish pins override the repo-wide ones.

Tasks named build, test, lint (plus check on cargo + go) get default recipes from the adapter for the dish’s language. You only need a [tasks.<name>] block to:

  • Override the default command (e.g. add flags)
  • Declare a custom task name (e.g. migrate, seed, deploy-preview)
  • Add task-specific inputs / outputs / env / retry config

Custom-named tasks (anything outside the adapter’s lifecycle set) don’t get pulled into bento ci — they only run when explicitly invoked via bento run <dish> <task> -- <args>. That’s the escape hatch for ad-hoc CLIs, migrations, and one-off scripts: same dish-dir cwd + toolchain semantics as a cached task, but the run bypasses the content-hash cache so non-deterministic invocations stay correct.

FieldTypeDefaultDescription
runstringrequiredShell command. Runs from the dish dir, with the dish’s [toolchain] honoured.
inputsstring[]adapter defaultGlob patterns mixed into the cache key for this task only. Combined with the dish’s inputs. Omit to use the adapter’s default for the language.
outputsstring[]noneGlob patterns of artefacts produced by this task. Combined with the dish’s outputs for bento artifacts.
envstring[][]Names of env vars whose values should mix into the cache key. The names are visible (in bento why); the values are hashed only.
retryint0Additional attempts on failure. retry = 2 → up to 3 attempts. A task that succeeds on attempt > 1 is reported flaky: true in the execution report.

Optional. Declares the long-running command for bento serve <bento> (every dish in a bento) and bento dev <dish> (one dish).

FieldTypeDefaultDescription
runstringrequiredLong-running command. Bento spawns it, watches the dish’s inputs, and restarts on change.

Per-dish config for integrations — the second extension point alongside language adapters. Each integration interprets its own block; unknown keys are ignored at load time so fields can be added without bento-config changes. See deploying.md for the full deploy workflow.

[integrations.railway]
service = "backend" # one Railway service to deploy to
# services = ["frontend", "landing-page"] # OR a list — one deploy task per entry
root = ".." # cd here before `railway up` (monorepo root)
FieldTypeDefaultDescription
servicestringunsetRailway service name. Injected as --service <name> in railway up.
servicesstring[]unsetFan out to multiple Railway services that share the same source (e.g. frontend + landing-page with different VITE env vars). One deploy task per entry, named railway:deploy:<slug>. Mutually exclusive with service (plural wins when both are set).
rootstringunsetPath (relative to the dish dir) to cd to before running railway up. Required when your Railway service has rootDirectory configured dashboard-side — it needs the full monorepo uploaded. Typically ".." for top-level dishes.

Railway service identity is dashboard-side — Railway’s own railway.json schema has no name / service / slug field (verified against their schema JSON), so bento owns this mapping.

Currently read-only — the Vercel integration emits vercel:deploy + vercel:preview tasks without per-dish config. Future fields (team, project, scope) will land here.

Cloudflare Pages ([integrations.cloudflare_pages])

Section titled “Cloudflare Pages ([integrations.cloudflare_pages])”

Config-only opt-in — Pages projects rarely ship a wrangler.toml at the dish root (project settings live in the Cloudflare dashboard), so the integration only fires when the block is present.

[integrations.cloudflare_pages]
project = "my-pages-project" # required — the CF Pages project name
dist = "dist" # default "dist" — the build output dir to upload
branch = "main" # default "main" — branch label for the deploy
FieldTypeDefaultDescription
projectstringrequiredCloudflare Pages project name (the slug shown in the dashboard URL: dash.cloudflare.com/<account>/pages/view/<project>). Required for both deploys and bento secret put|list|delete.
diststring"dist"Build output directory (relative to the dish dir) that Wrangler uploads.
branchstring"main"Branch label attached to the deploy in the Pages dashboard.

Wrangler is invoked as wrangler pages deploy <dist> --project-name <project> --branch <branch> --commit-dirty=true. --commit-dirty=true is always on — bento rebuilds artefacts fresh per invocation, so Wrangler’s default git-state check is just noise on monorepos.

Cloudflare Workers ([integrations.cloudflare_worker])

Section titled “Cloudflare Workers ([integrations.cloudflare_worker])”

Detected via wrangler.toml or wrangler.jsonc at the dish root. Per-dish config is optional — the default environment in wrangler.toml covers the common case.

[integrations.cloudflare_worker]
env = "production" # optional — adds --env production to wrangler deploy
FieldTypeDefaultDescription
envstringunsetWrangler environment name. Maps to [env.<name>] blocks in your wrangler.toml; flows through to wrangler deploy --env <name> and to wrangler secret put|list|delete --env <name>. Omit for the default environment.

The integration ID is cloudflare_worker (singular, code style); the product brand is “Cloudflare Workers” (plural). Same convention as [dependencies.foo] vs “the foo crate” elsewhere.

Opt-in Notify-kind integration. Fires after every Deploy task in the dish; posts a templated message to a Slack Incoming Webhook.

[integrations.slack]
webhook_url_env = "SLACK_WEBHOOK_URL" # env var holding the https://hooks.slack.com/... URL
channel = "#deploys" # optional
username = "Bento" # optional
FieldTypeDefaultDescription
webhook_url_envstring"SLACK_WEBHOOK_URL"Host env-var name holding the webhook URL. Flows through [environments.<name>] secrets.* aliases.
channelstringunsetOptional channel override (Slack webhooks pin one at creation; this only takes effect for unpinned webhooks).
usernamestringunsetOptional sender display name.

Linear ([integrations.linear]) — garnish

Section titled “Linear ([integrations.linear]) — garnish”

Opt-in Notify-kind integration. On a successful deploy, scans the payload for [A-Z]{2,}-\d+ issue identifiers and transitions each to a target workflow state via Linear’s GraphQL API.

[integrations.linear]
api_key_env = "LINEAR_API_KEY"
target_state = "Deployed"
fallback_issue_id = "ENG-1234" # optional: comment here if no refs matched
team = "ENG" # optional: disambiguate state lookup across teams
FieldTypeDefaultDescription
api_key_envstring"LINEAR_API_KEY"Host env-var name holding the Personal API key.
target_statestring"Deployed"Workflow-state name to transition matched issues to on a successful deploy.
fallback_issue_idstringunsetFallback issue to comment on when no issue refs were discovered. Skipped if unset.
teamstringunsetTeam key (e.g. "ENG"). Required only when target_state is ambiguous across teams.

Failed deploys skip transitions entirely — only fallback_issue_id comments fire, so a broken release is never marked shipped.

Plugin integrations read whatever keys they recognise from their own [integrations.<id>] block. For custom post-deploy hooks without writing a full Integration implementation, use the [[garnishes]] block below.

Custom-script Notify-kind tasks declared inline — escape hatch for bespoke post-deploy hooks where writing a full Integration is overkill. Each entry becomes a Notify task that fans out after every Deploy in the dish; the script receives the GarnishPayload JSON on stdin (bento schema garnish-payload).

[[garnishes]]
name = "github-pr-comment"
run = "./scripts/notify-github.sh"
env = ["GITHUB_TOKEN"]
required_env = ["GITHUB_TOKEN"]
required_cli = ["gh: https://cli.github.com"]
FieldTypeRequiredDescription
namestringyesTask name in the ExecutionReport. Must be unique within the dish. User-declared [tasks.<name>] can override the run while keeping Notify semantics intact.
runstringyesShell command invoked once per Deploy trigger with the GarnishPayload on stdin.
envstring[]noEnv-var allowlist forwarded to the child (same shape as [tasks.<name>] env).
required_envstring[]noEnv vars that must be set at runtime — preflight fails the garnish with a clear message otherwise.
required_clistring[]noCLI binaries that must be on PATH. Entry form: "binary" or "binary: install hint".

Failures never fail the build — same rule as built-in garnishes (summary.notify_failures tracks them; exit code stays 0).


CLI flags > per-dish dish.toml > repo-wide bento.toml > built-in defaults.

For toolchains specifically, bento walks each adapter’s detection chain to discover an implicit version pin (e.g. go.mod’s go 1.22 directive, .nvmrc, .tool-versions, etc.). That implicit pin counts as the bottom of the override stack — dish.toml > implicit detection > nothing.

The fully resolved cache-key inputs for any one task are visible via bento why <hash>.


The simplest valid workspace. No bento.toml.

my-app/
├── bentos/
│ └── all.toml # name = "all" dishes = ["."]
└── dish.toml # name = "my-app" language = "go"

A dish (shared) that belongs to multiple bentos.

monorepo/
├── bento.toml # repo-wide cache + toolchain config
├── bentos/
│ ├── backend.toml # ["services/api", "services/billing", "lib/shared"]
│ ├── frontend.toml # ["apps/web", "lib/shared"]
│ └── release.toml # all of the above, in one bento
├── apps/
│ └── web/dish.toml
├── services/
│ ├── api/dish.toml
│ └── billing/dish.toml
└── lib/
└── shared/dish.toml

shared is built once when bento ci --bento release runs; its cache key is identical no matter which bento you ask for it via.

Two bentos modelling a deployment ordering: core ships first, extras depends on core. The dep cascade is enforced by dish.toml’s depends_on, not by the bento boundaries.

project/
├── bentos/
│ ├── core.toml # ["services/auth", "services/users"]
│ └── extras.toml # ["services/notifications", "services/billing"]
└── services/
├── auth/dish.toml # depends_on = []
├── users/dish.toml # depends_on = ["auth"]
├── notifications/dish.toml # depends_on = ["users", "auth"]
└── billing/dish.toml # depends_on = ["users"]

Run bento ci --bento extras and bento builds auth and users first (they’re upstream), then notifications and billing in parallel.