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). Run
# `bento login` once for interactive setup; use $BENTO_CACHE_TOKEN in CI.
#
# Local-tier size is bounded by hand, not by config:
# bento cache prune --max-size 5GiB --older-than 30d
[telemetry]
# Build reports — sent only to the `bento://` cache remote configured
# above. Wire shape: package, branch, sha, cache_hit_ratio, status,
# duration_ms (no PII, env values, or command lines). Self-hosters with
# no `bento://` remote: nothing is ever sent. Opt out via this flag,
# or per-machine via `BENTO_TELEMETRY=0` (also accepts `false` / `no`
# / `off`). `bento doctor` reports the resolved posture.
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). Bento never stores the token in repo state.

The local tier has no size cap in config — an unbounded budget is the right default for a content-addressed cache, and a wrong number in a committed file is worse than none. Bound it per-machine instead:

Terminal window
bento cache stats # entries, bytes, age range
bento cache prune --max-size 5GiB # evict oldest until under
bento cache prune --older-than 30d # evict anything staler
bento cache prune --max-size 5GiB --older-than 30d # both bounds hold after

Eviction is oldest-first by mtime, and each bundle’s bento why manifest goes with it. At least one bound is required; bento cache clear is the unbounded version. Sizes take binary suffixes (B/KB/MB/GB/TB, 1 KB = 1024 B); ages take s/m/h/d/w.

On Linux the 0600 credentials file is always written, keychain or not: the only keyring backend bento builds there is kernel keyutils, whose entries live in the login session and are gone after a logout or reboot. macOS Keychain and Windows Credential Manager are durable and remain the primary sink there.

FieldTypeDefaultDescription
enabledbooltrueSend a build report to the configured bento:// cache remote after bento ci and bento build. Wire shape: package, branch, sha, cache_hit_ratio, status, duration_ms — no PII, env values, or command lines. Best-effort POST: failures are logged at warn, never block the build. With no bento:// remote configured, nothing is ever sent regardless of this flag.

Opt-out paths (precedence: either says off → off; the env var cannot force telemetry on if the config disables it):

  • [telemetry] enabled = false in bento.toml — committed, team-wide.
  • BENTO_TELEMETRY=0 (or false / no / off) — per-machine override, useful in CI or local shells.

bento doctor reports the resolved posture as the telemetry.posture check (enabled / disabled by config / disabled by env / disabled by both).

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.

Without a [toolchain] block, each adapter still detects a pin from the project’s own version files and folds it into the cache key. Those files resolve from the dish dir upward, stopping at the repo root (a directory holding .git or bento.toml), so one .nvmrc / .tool-versions / rust-toolchain.toml / .python-version / .ruby-version at the top of a monorepo covers every dish beneath it. nvm’s floating aliases — lts/*, node, latest, stable — mean no pin; bento falls through to the next signal rather than keying on a version that changes underneath it. lts/<codename> resolves to its major.

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"
# Glob patterns mixed into the cache key for *custom* tasks only
# (e.g. `migrate`, `seed`). Lifecycle tasks (build/test/lint, plus
# check on cargo + go) ignore this field — they use the adapter's
# defaults. For those, restate the glob under each [tasks.<name>]
# block. Adapters add their own fingerprint files automatically
# (lockfiles, toolchain pin files, .tool-versions, ...).
inputs = []
# 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"
# Replaces the adapter default verbatim — restate every glob you want
# in this task's cache key. Omit to keep the adapter default.
inputs = ["**/*.go", "go.mod", "go.sum", "openapi.yaml"]
outputs = ["bin/api"]
[tasks.test]
run = "go test ./..."
inputs = ["**/*.go", "go.mod", "go.sum", "openapi.yaml", "testdata/**"]
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, unioned into every task’s cache key on top of the adapter’s defaults. Adapter defaults are pessimistic — most adapters hash the whole dish tree (**) minus derived dirs (node_modules/, target/, dist/, build/, .next/, vendor/, .venv/, …), so you rarely need this; it’s for a no-adapter dish, or to hash a file the adapter’s derived list excludes. 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 unless they set ci = true — otherwise 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. Replaces (does not merge with) the adapter’s default inputs; the dish-level inputs field is not folded in either. Omit to use the adapter’s default (usually ** minus derived dirs). A task’s own outputs and the universal noise dirs (.git, .bento, node_modules, target, dist, build, .next) are never hashed as inputs.
outputsstring[]noneGlob patterns of artefacts produced by this task, restored on cache hit. A trailing slash means the whole directory (dist/dist/**). Changing outputs changes the cache key.
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. Under bento deploy --env <name> / --secret-from, the aliased source value is what gets hashed.
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.
ciboolfalseInclude a custom-named task in bento ci / bento plan. Lifecycle names (build / check / test / lint) always run; every other name is bento run-only unless it opts in — so a dev script mirrored from package.json never runs in CI.
cachebooltruefalse = always run, never restore from cache (Turborepo’s cache: false). A custom task on a no-adapter dish with no inputs is treated as cache = false automatically — a task that hashes nothing would otherwise hit forever.

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.

The command runs with the dish’s pinned toolchain in front of PATH — same semantics as a cached task — in its own process group, so a restart takes down the whole tree the command spawned (a shell wrapper plus the real server) rather than leaking the previous process onto the port. Ctrl-C kills the served children before bento exits.

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.