stella.toml

The TOML config format that keeps comments and tracks schema versions — where each scope's file lives, what moved from settings.json, what stays the same, and what isn't built yet.

stella.toml is stella's config file, written in TOML instead of JSON. It reads across the same three scopes as settings.json, merges by the same rules, and is gated by the same project trust boundary. The file format is different, but who is trusted is not. TOML adds what JSON can't hold: comments that survive a write, and a [meta] block that helps stella handle future versions safely instead of just guessing.

This page documents the TOML shape. For the full meaning of every section, including merge rules, the trust boundary, and worked examples, settings.json is the main reference — the two formats load into the same settings and behave identically once loaded. This page answers the questions specific to the file itself: where it lives, what its keys are called, and what changed when it was ported to TOML.

Where files live

project

At the repository root, not under .stella/. A committed, reviewed file belongs where Cargo.toml and pyproject.toml live: in a pull request diff, not three directories deep.

Preference
highest
Path
<repo>/stella.toml
user

Your personal defaults, the same role ~/.stella/settings.json plays.

Preference
lowest
Path
~/.stella/stella.toml
managed

Or the platform default: /Library/Application Support/stella/stella.toml on macOS, /etc/stella/stella.toml elsewhere. An administrator deploys this file, and its extension decides its format. There is never a second managed file competing with it.

Preference
middle, but a ceiling
Path
STELLA_MANAGED_SETTINGS

The scope hierarchy is unchanged from JSON: precedence, the trust boundary, and which fields merge per field, whole block, or by concatenating. See The scope hierarchy for the full rules; How settings scopes mergeorg-manageda ceiling — off stays offproject — .stella/settings.jsonbeats user, for keys it is trusted withuser — ~/.stella/settings.jsonyour defaults, always appliedeffective settingsmerged per keymost specific wins — except that an org “off” can be narrowed further, never re-opened shows the same shape either format reads through.

Migrating to TOML

stella migrate config          # write the TOML for every scope that has JSON
stella migrate config --dry-run

Every value it writes comes straight from the settings.json stella actually parsed — it's never retyped by hand — and the generated file is re-parsed and re-validated before it touches disk. The original JSON is never deleted; you remove it yourself once you've confirmed the TOML says what you meant. Full walkthrough: stella migrate.

TOML or JSON

If both files exist at a scope, stella reads the whole TOML file and ignores the JSON — it never combines them field by field. A combined file would make "which file configures this?" impossible to answer from either file alone. This rule guards against someone editing the JSON, seeing no effect, and having nothing to check.

! ~/.stella/stella.toml and ~/.stella/settings.json both exist — reading the TOML and
  IGNORING the JSON. Delete the JSON once you have confirmed the migration.

This notice prints once per launch, at every scope where it applies. A missing TOML file falls back to the JSON exactly as before; a missing file in either format is fine and is simply skipped.

The [meta] block

This is the one section with no JSON equivalent. settings.json has no version field at all, so a typo and a key from a future release look identical to the parser. [meta] is what tells them apart.

meta.schema_version

This build reads exactly one schema version. A file naming a version this build does not understand shows a named load error telling you to upgrade stella or pin the file back — never a silent partial read. A file with no [meta] block at all is version 1 by definition.

meta.scope

Self-declared as "user", "managed", or "project" — checked against the file's actual location, not the other way around. The path decides the scope; this field only has to agree with it. A project file declaring scope = "managed" is refused with a named error, because that mismatch is either a copy-paste mistake or an attempt to claim authority the file's location doesn't grant.

stella.toml
[meta]
schema_version = 1
scope          = "project"   # "user" | "managed" | "project"

What moved

These changed shape when TOML was added. Everything else kept its name.

settings.jsonstella.tomlWhy
enable_recap (bare root key)[run].recapTOML attaches a bare key to whatever [table] precedes it, so moving one line in the file can silently reassign it. Every scalar gets a table home; there are no bare keys at the document root.
agent_engine_config[agents]Shorter, and clearer — the JSON name described an internal structure rather than the thing it configures.
agent_engine_config.agents.<name>[agents.<name>]One nesting level removed. This is safe because the agent set is closed and holds one name — default is a fixed field, not a map key, so the per-agent table can never collide with a root field. A file still naming worker, verifier, triage, research or plan loads normally and reports the key.
agent_engine_config.allowed_models[models].allowedPromoted out of the agents block, since it's a statement about models, not about any one agent. Still replaces wholesale across scopes rather than concatenating.

trace_capture, create_worktrees, candidate_isolation and ignore_gitignore needed no such move: each shipped straight into its [run] table home, so none ever had a bare-root JSON form to retire.

Every section

The merge rules below are identical to their JSON counterparts. They're restated here so this page works as a complete reference on its own — see the linked section for the full explanation.

[run]

run.create_worktrees

"always", "ask", or "never" — whether a run does its work in a throwaway git worktree instead of your checkout. An empty string, null, or the key being absent all mean "ask"; anything else is a hard parse error.

Default ask

run.candidate_isolation

"worktree" or "copy-tree" — how a best-of-N fan-out isolates one candidate's tree from the next. "worktree" cuts a git worktree per candidate and promotes the winner as a patch, so a candidate can't land anything you couldn't have written yourself, and adopting the patch is refused if it no longer applies cleanly. "copy-tree" copies the whole directory instead, gitignored files included, and promotes by replacing the tree's contents with the winner's — right for a disposable benchmark container whose node_modules/ or .venv/ the task's own tests execute, and destructive anywhere else. Never selected for you. An empty string, null, or the key being absent all mean "worktree"; anything else is a hard parse error.

Default worktree

run.ignore_gitignore

Same name as JSON, and the one toggle here that defaults on. See The probe's gitignore filter.

Default on

run.auto_trust_project

true trusts every project this machine opens by default — the config-file alternative to typing STELLA_TRUST_PROJECT=1 on every launch. Only honored from the user or org-managed file. Set in a project's own stella.toml, it still parses, but is discarded before it can affect anything, with a stderr notice saying so: a project cannot vote on its own trust. An explicit STELLA_TRUST_PROJECT env var always overrides this, either direction, for one launch. See The project trust boundary.

Default false

run.active_plugins

Which installed plugins take part in every turn, in the order they run. Installing a plugin puts it on disk and leaves it inert; a name here is what makes it join a turn, and taking the name out puts the turn back as it was. --pipeline <id> still wins for one run — it means "run with exactly this". A name that is not installed, or that [plugins] has switched off, runs nothing and says so once at start-up. The order is written down rather than taken from the order you installed things, so two clones of a repository compose the same turn. A project's own stella.toml is honored here only when the project is trusted, because a name here makes stella start a program.

stella.toml
[run]
create_worktrees = "ask"
ignore_gitignore = "on"
auto_trust_project = false
active_plugins = ["stella-plan", "vera"]

run.recap and run.trace_capture do nothing. Setting either one is not a typo and is not reported as one — the config loader recognizes the key and tells you it's inactive.

[workspace]

This section holds facts about the tree this config belongs to, not how one run behaves. allowed_dirs states what the workspace is, which is why it gets a section of its own.

workspace.allowed_dirs

Directories outside the workspace root that the write tools may touch. Relative entries resolve against the workspace root, never the directory you launched from, so a committed entry means the same directory for every teammate. Blank entries are dropped instead of being read as a grant of the root itself, and a directory that does not exist yet is carried through unchanged.

This list and the --allow-dir flag (STELLA_ALLOW_DIR) are additive, not last-wins: a directory added on the command line widens the project's scope for that invocation rather than replacing what the file grants.

stella.toml
[workspace]
allowed_dirs = ["../shared-fixtures", "/opt/vendor-src"]

[providers]

This has the same shape and rules as JSON's providers map: a table keyed by provider id, merged per id and per field. See The providers map.

stella.toml
[providers.zai]
name          = "ZAI Provider"
base_url      = "https://api.z.ai/api/coding/paas/v4"
api_key_env   = "ZAI_API_KEY"
default_model = "n-5.2"

A literal api_key is refused at project scope. <repo>/stella.toml sits next to README.md and is committed and reviewed like source. A plaintext secret in it leaks into version control the moment someone runs git add . — a risk that .stella/settings.json was at least one gitignore line away from. This refusal is enforced on both the load path and stella migrate config's write path, from one check, so a key sitting in an untracked settings.json can never be silently copied into the committed file. Use api_key_env = "..." to name an environment variable instead, or stella auth set <provider> to store it in credentials.toml with owner-only permissions and never committed. User and managed scope keep accepting api_key unchanged.

[models]

models.allowed

The TOML name for agent_engine_config.allowed_models. Replaces wholesale across scopes — one list, so a project can narrow the user's list without restating it.

models.output_caps

Per-model output-token ceilings, merged per key (unlike allowed above). See [models.output_caps] for the full precedence chain.

stella.toml
[models]
allowed = ["anthropic/claude-fable-5", "zai/glm-5.2", "openrouter/openai/gpt-5.5"]

[models.output_caps]
"anthropic/claude-sonnet-5" = 64000
"deepseek-chat"             = 32000

[agents]

The flattened, closed-set engine config — see Agent engine config for the full schema, model precedence, auto modes, and generation parameters, all unchanged. Flattening [agents.default] (instead of the JSON nesting's [agents.agents.default]) is safe only because the set of agent names is closed and none of them can collide with a root value like default_model.

That closed set is exactly why plugin seats get a section of their own instead of joining this one: a seat name comes from whatever you installed, and user-chosen keys must not share a namespace with effort_auto.

stella.toml
[agents]
default_model = "zai/glm-5.2"
effort_auto   = "on"

[agents.default]
provider  = "openrouter"
model     = "openai/gpt-5.5"
effort    = "high"
reasoning = "on"

  [agents.default.params]
  temperature = 0.2
  max_tokens  = 4096

[seats]

Which model each plugin-declared role runs on. Keys are "<plugin-id>/<role>" and must be quoted — vera.verifier is TOML dotted-key syntax and would parse as a table vera containing verifier. A seat with no entry runs on the session's model.

stella.toml
[seats]
"vera/verifier" = "anthropic/claude-fable-5"

[plugins]

The per-plugin on/off switches — one name = "on" | "off" entry per installed plugin, the same shape JSON carries. An absent entry means the plugin runs; "off" turns it off without uninstalling it, which is what you want when a plugin is misbehaving mid-task and you want reinstalling it later to be a no-op.

Keys are plugin ids, so like [seats] this is an open map: it cannot join [agents], whose closed name set is what makes that table's flattening safe.

stella.toml
[plugins]
vera = "off"

[lanes]

A plugin can ship a lane: a place a turn runs, with its own set of the loop's optional seams. Its manifest asks for the seams it wants. This section is where you say what it may actually hold.

lanes.custom.<id> is keyed by the lane id the plugin's manifest declared. A lane you never name has no ceiling here, and holds whatever the rung you accepted when you installed the plugin allows. A lane you do name holds only the seams you list.

Scopes narrow, one after another: a project file can take a seam away, and can never hand one back. That is why this section is read from a repository you cloned at all — the only thing it can do there is leave a plugin lane holding less.

stella plugin doctor prints the seam names, what each lane asked for, what it holds, and every seam a lane answered for in neither direction.

stella.toml
[lanes.custom."acme.replay"]
capabilities = ["bus"]

[tools]

This is the same open map, deny-list, and name-over-group-over-"*" precedence as The tools section. The one TOML-specific difference: * isn't a valid bare TOML key, so the wildcard needs quotes.

stella.toml
[tools]
task_assign = "off"
mcp         = "off"
"*"         = "on"

[hooks]

This has the same schema and the same concatenate-across-scopes rule (the one block that isn't per-field or whole-block last-wins) as Lifecycle hooks, and the same project-trust gate. TOML expresses the event arrays as array-of-tables instead of JSON's bracketed arrays:

stella.toml
[[hooks.PreToolUse]]
matcher = "mcp__github__push_files"
  [[hooks.PreToolUse.hooks]]
  type      = "command"
  command   = "./scripts/guard.sh"
  timeoutMs = 5000

[mcp]

registry_url carries over unchanged — see The mcp section.

[mcp.servers] parses, but does nothing yet. MCP servers still load only from .stella/mcp.toml. A stella.toml declaring [mcp.servers.*] entries loads without error and prints a startup notice naming the count, so nothing is silently dropped — but those servers won't start yet. Keep server definitions in .stella/mcp.toml for now.

stella.toml
[mcp]
registry_url = "https://registry.example.internal/mcp"

[context] and [context_providers]

The shape and rules are unchanged: whole-block last-wins for context, per-entry last-wins for context_providers. See The context block and External context providers.

[ui]

Unchanged — see The ui section.

stella.toml
[ui]
theme = "stella-light"
mid_turn_prompt = "queue"   # queue | ask | spawn | steer

[voice]

Dictation from the composer: speak, and the transcript lands at the cursor. Hold Space through a warmup and release, or set mode = "tap" to tap Space on an empty prompt to start and tap again to stop. Pick tap when holding Space never starts anything, because your terminal reports no key releases and OS key repeat is off. /voice tap, /voice hold and /voice off write these fields for you. Whole-block last-wins, like [ui]. Off by default — recording sends microphone audio to the provider named below, a transfer you opt into by name. The provider id resolves through [providers] (or the built-in table) for its endpoint and API key, so this section holds no credential of its own; any provider with an OpenAI-compatible audio/transcriptions endpoint works, a local Whisper server included.

stella.toml
[voice]
enabled = true
mode = "hold"                # hold | tap
provider = "openai"          # any [providers] entry with a compatible base_url
model = "whisper-1"
language = "en"              # BCP 47 hint; omit to auto-detect

[reward]

What a finished turn's verdict is worth as a training label. Whole-block last-wins, like [ui]; it carries no credential or off-machine authority, so a project setting a weight is stating an opinion about its own evidence, not borrowing permission.

reward.deterministic_weight

The size of a deterministic pass or fail — the unit the other prices below are measured against.

Default 1.0

reward.per_step

Subtracted per model call.

Default 0.02

reward.per_usd

Subtracted per USD spent.

Default 0.5

reward.per_revision

Subtracted per verification round after the first.

Default 0.1

stella.toml
[reward]
per_usd = 0.05   # price a dollar of spend at a tenth of the default

reward.verifier_weight does nothing. A file that sets it loads normally and reports the key as unrecognized.

[foundry]

The tool foundry's gap-detection thresholds and its autonomy controls. Whole-block last-wins, like [reward] — the thresholds, the circuit breaker, and the autonomy mode are one policy. Lowering a threshold changes what gets proposed; what executes is still gated by the foundry ledger, the per-call re-digest, and the spawn-time network denial every foundry-built tool runs under.

foundry.min_occurrences

Matching invocations before a shell shape is worth proposing. 0 or 1 disable detection.

Default 3

foundry.min_distinct_arguments

Distinct argument sets required — one set is an exact repeat, which is loop detection's territory.

Default 2

foundry.min_reuse_ratio

Uses per distinct argument set required. At or below 1.0 disables the gate; non-finite values are rejected with a diagnostic.

Default 3.0

foundry.require_success

Whether a cluster needs at least one successful invocation.

Default true

foundry.max_examples

Cap on example command lines per proposal and example values per parameter. Must be at least 1 — the authored tool's witness input is built from these.

Default 3

foundry.autonomy

auto (detect → author → validate → adopt → enable, network denied), draft-only (author and validate, adopt nothing), or off (detect and ledger only). Autonomy also degrades to draft-only on a platform with no real network isolation.

Default auto

foundry.breaker_consecutive_failures

Consecutive failures that auto-disable a foundry tool. Must be at least 1.

Default 3

foundry.breaker_window

Recent invocations the failure-rate arm looks at. Must be at least 1.

Default 10

foundry.breaker_failure_rate

The failure share over that window that trips the breaker. Above 0, at most 1.

Default 0.5

foundry.network_allowlist

Foundry-built tools allowed to reach the network, by name. Empty means every foundry tool spawns with network denied.

Default []

stella.toml
[foundry]
min_reuse_ratio = 2.5          # propose earlier than the shipped 3.0x floor
autonomy = "draft-only"        # author staged pairs, adopt nothing

[plan_review]

The deck's plan gate: before the first step of a plan runs, the task board goes to whoever is driving, as a card they can approve or send back. Both parts of this gate are settings you can change here. Whole-block last-wins, like [ui] and [reward] — the switch and the threshold are one policy, so a scope that declares either one states both.

plan_review.enabled

off removes the gate, and no card is ever raised. This is different from an unattended run, which installs no gate anyway because nobody is there to answer one.

Default on

plan_review.min_steps

How many open board rows raise a card. 1 asks about every plan; 0 is refused, because an empty board has no open rows and enabled is how you say off.

Default 3

stella.toml
[plan_review]
min_steps = 5    # only stop me for plans of five steps or more

stella run --plan-mode overrides both for one invocation: the gate is installed and every plan is put to the driver, whatever the file says.

[self_driving]

What stella self-driving signs its work with, which checks can block a merge, how it proves a change when CI can't, how it decides things two operators would decide differently, and what vocabulary it places an issue in.

Read from the project file alone. The loop resolves <repo>/stella.toml directly instead of through the scope merge, so a [self_driving] block in ~/.stella/stella.toml parses without complaint and steers nothing. A project file the loop cannot parse prints a warning and the loop runs on its defaults — a typo in a section a run may never reach is not a reason to refuse to start. Everything here has a default, so a repository that writes none of it still gets a working loop.

[self_driving.attribution]

What the loop appends to what it writes, and how it names branches and pull requests. It travels with the repository and an installed plugin may rewrite it, so a downstream distribution can sign in its own name without forking.

self_driving.attribution.commit

Footer on every commit message the loop causes to be written.

Default created by stella*

self_driving.attribution.pull_request

Footer on every pull request description it opens.

Default created by stella*

self_driving.attribution.issue

Footer on every issue it files.

Default created by stella*

self_driving.attribution.issue_comment

Footer on every issue comment it posts.

Default created by stella*

self_driving.attribution.branch_prefix

The namespace for every branch it creates. A prefix that does not end in / is used exactly as written, not corrected — some teams namespace with -. An empty prefix is read as unset and falls back to the default: an unnamespaced branch is indistinguishable from a human's ref, and it is also what stella fleet gc would believe it owned.

Default stella/

self_driving.attribution.title_prefix

A prefix on the title of every pull request it opens, so an autonomous one stands out in the list where a maintainer actually triages. Not applied to commit messages: those follow Conventional Commits, and a prefix in front of fix(stella-cli): would break the scope. An already-prefixed title is left alone, so reopening a pull request for the same issue cannot stack it twice.

Default stella self-driving:

A footer lands after the body's last non-blank character, separated by a blank line and a --- rule; trailing whitespace on the body is normalized first, so two callers whose bodies differ only in how they ended produce identical output. Setting a surface's string to "" turns off its footer and leaves the body untouched. The separator itself is fixed.

stella.toml
[self_driving.attribution]
commit        = "created by the acme delivery bot"
pull_request  = "created by the acme delivery bot"
issue         = ""                                  # file issues unsigned
branch_prefix = "bot/"
title_prefix  = "auto:"

[self_driving.merge]

Which checks are allowed to block a merge. Filtering down to a repository's required contexts is necessary but not enough on its own: a required check fails the same way whether the code is wrong or the billing account is suspended, and only one of those problems can be fixed by a pull request.

self_driving.merge.ignore_checks

Checks that never block, whatever their history. The operator's escape hatch — automatic detection needs several commits of evidence, and somebody who already knows their deploy provider is suspended should not have to wait for the loop to infer it.

Default []

self_driving.merge.stuck_after

Consecutive failing base commits after which a check is treated as unwinnable. 0 disables the inference, leaving only ignore_checks.

Default 3

[self_driving.verify]

How to prove a change when CI can't. A repository whose remote checks can't go green — a suspended account, an exhausted quota — still has a test suite. Running it on this machine is a weaker signal than a clean CI run and a far stronger one than nothing.

self_driving.verify.command

The command that proves a change, run in the work worktree. Absent means detect it from the project's own tooling.

Default unset

self_driving.verify.timeout_secs

Seconds before the command is abandoned — long enough for a monorepo suite, short enough that a stuck process does not hold the loop overnight.

Default 1800

[self_driving.supply]

Where the loop looks for work once the ranked queue is empty. Every switch here is off, and that is the shipped default. The queue is the only supply that drains, so it is the only one a fresh install draws from. An endless supply of work is not an endless supply of useful work, so each of these is a choice somebody makes rather than one they inherit.

Nothing a sweep finds can be filed twice. Every filing goes through the same dedup set, keyed by a digest of the finding's own words, so re-passing a lens yields what the new code introduced and nothing else.

self_driving.supply.rearm

Re-open the lens ladder once the base branch has moved. A lens that found nothing at one commit says nothing about the tree two hundred commits later. Held shut while the loop's own ledger reports NOISY: a loop filing far more than it finds should narrow rather than open one more lens.

Default off

self_driving.supply.regress

Re-check the fixes this loop has already claimed. As it closes an issue the loop writes down what the closure cited and whether that change was on the base branch at the time. A change that was there and is gone is a fact rather than a claim needing triage, and it is filed as a fresh defect.

Default off

self_driving.supply.meta

Read the loop's own ledger for habits it should file against itself — a raised signal nobody has acted on, or a lens that has looked several times and found nothing at all.

Default off

self_driving.supply.rearm_commits

How many commits the base must gain before a dry ladder re-opens.

Default 50

self_driving.supply.rearm_days

How many days may pass instead, for a tree that merges rarely. Either bound alone is enough.

Default 30

[self_driving.doctrine]

How the loop decides things where two operators would decide differently. These are judgment calls about a team's norms rather than facts about the code, which is why they are declared instead of built in: the person who starts the loop is the person who decides how it decides, and that is only true if what it will do is clear before it does it.

self_driving.doctrine.foreign_breakage

What to do about breakage the loop did not cause. file_and_adopt files it and then fixes it at the top of the queue; file_and_wait leaves the fix to a human; ignore does neither. Adopting is the default because a broken base blocks this loop exactly as hard as it blocks its author, so a loop that files and waits has handed its progress to somebody else's response time.

Default file_and_adopt

self_driving.doctrine.contention

How much weight another actor's apparent work carries. defer treats any sign — a branch, a worktree, a held ledger claim — as reason to leave the issue alone; claims_only defers on a held claim and reads branches and worktrees as advisory; proceed claims regardless.

Default defer

self_driving.doctrine.abandon_escalated

Whether an escalated pull request is abandoned so the loop can carry on, rather than parking it. A pull request a human must look at is usually a sign the loop should stop making more of them.

Default false

[self_driving.escalation]

What happens to an issue the loop tried and could not finish. It gets the agent-escalated label and a record in its body saying how many tries there have been, why the last one stopped, and when. The label is what a person scans for; the record is what the next run reads. Nobody has to remove anything to put the issue back in the queue — the cooldown does that.

self_driving.escalation.environmental_cooldown_secs

Seconds to wait when the machine was what broke: a stale checkout returning the same bytes to every command, a provider outage, a failed install. These clear on their own in minutes, and the issue itself was never the problem.

Default 600

self_driving.escalation.beyond_loop_cooldown_secs

Seconds to wait when the turn ran and could not do the work. That needs something to change first — a comment, a different model, a blocker that lands — so retrying in minutes would buy the same failure at full price.

Default 21600

self_driving.escalation.park_after

Tries after which the issue is parked and the loop never takes it again. stella self-driving stats counts the parked ones apart from the escalated ones.

Default 3

[self_driving.triage]

The vocabulary the loop places issues in: which labels mean urgent, which mean "this is ours to work," and which mean "this is not." It lives here rather than in the tracker manifest because these are this operator's judgment calls about their own backlog, not how a tracker spells a concept every tracker has; two teams on one GitHub can want different ladders.

self_driving.triage.priorities

The urgency rungs, most urgent first. Position in the list is the rank.

Default ["P0", "P1", "P2", "P3", "P4"]

self_driving.triage.defect_kinds

Labels meaning "this loop works this".

Default ["bug", "triage"]

self_driving.triage.excluded_kinds

Labels meaning "this loop does not work this". Named explicitly rather than inferred from "not a defect kind": an issue labelled enhancement has been judged and excluded, while an issue labelled only P0 has been judged on one axis and not the other, and only the first may be dropped silently.

Default ["enhancement", "feature", "documentation", "question", "epic"]

self_driving.container_labels

Labels for a tracking issue — a checklist of other issues, not real work. drive --backlog skips these, whatever their Blocked by: lines say. Sits under [self_driving], not [self_driving.triage]: the backlog reader is a different queue and needs its own copy of this rule.

Default ["epic"]

self_driving.residue_gate

Whether the end-of-turn residue gate files a turn's stated leftover work as issues. Sits under [self_driving]. Off skips the scan and the filing; on is the default, so filing stated follow-up work needs no setting at all.

Default on

self_driving.deploy_watch

Whether the drive loop also watches the release workflow's latest run and files on red. Sits under [self_driving]. On unless an operator turns it off, so a red release is never missed just because nobody thought to ask.

Default on

Each list defaults on its own, and declaring one replaces it wholesale rather than adding to it — a partial override would leave you unable to remove a built-in you disagree with.

stella.toml
[self_driving.triage]
priorities   = ["Sev1", "Sev2", "Sev3"]
defect_kinds = ["defect"]

An issue carrying no rung is not ranked last. It is set aside as unassessed, and the loop spends a turn triaging it before claiming any work — so a Sev1 filed thirty seconds ago with no labels yet does not sort beneath a Sev3 from March. That turn judges the issue against the workspace's context records, which means the ladder is changed by publishing and retiring records rather than by editing code. A turn that cannot place an issue in the declared vocabulary labels it for a human instead of meeting it again on the next pass.

[issues]

Which tracker is active, and where its vocabulary lives. Two fields and no more: which tracker to use is a stella-level decision and belongs in stella.toml; how that tracker spells things is the tracker's own vocabulary and belongs in its manifest, which this points at. Combining them would leave a workspace spanning two trackers with nowhere to put the second one's words. Read from the project file alone, like [self_driving].

issues.provider

The provider id. github is the only one with a built-in vocabulary today; any other name warns and falls back to GitHub's until you declare the manifest.

Default github

issues.manifest

Path to the provider's manifest, relative to the workspace root. A missing file just means the built-in defaults for that provider, not an error, so a workspace with no configuration still works.

Default .stella/issues/<provider>.toml

stella.toml
[issues]
provider = "github"
manifest = ".stella/issues/github.toml"

The manifest fields, and how stella reads them, are covered in Tracker support.

[authority]

Only valid at managed scope. Unchanged from JSON — see Managed authority ceilings. Present in a user or project stella.toml, it is ignored; only the file at the managed-scope path grants it.

[enterprise_telemetry]

Only valid at managed scope. Unchanged from JSON — see Enterprise telemetry enrollment. It round-trips as an untyped table and is checked by its own adapter, which fails open, so its shape is not fixed by this schema.

Comments survive a write

This is the whole reason to migrate: stella.toml is edited through toml_edit, which rewrites only the keys a save actually touches and preserves everything else (comments, key order, blank lines) exactly as they were. /theme, the tool-switch editor, and the engine config panel all edit stella.toml in place once one exists for that scope. A settings.json re-render can't make this promise, since JSON has nothing to preserve besides keys — comment loss is the risk this whole format was built to avoid.

Not built yet

Several features are planned but not built yet: wrapper-stage toggles, per-agent tool scope over an agent set wider than the engine's, [models] pin / track_latest policy, provider fallback (provider_preference), and declarative [integrations.<id>] blocks for tools with interchangeable backends. The engine's own agent set is one role today ([agents.default]), and the open, user-chosen half of that problem is already answered for plugin-declared roles by [seats]. None of the planned keys above do anything if you write them today — see the design document for the full plan: docs/spec/config-system/DESIGN.md.

A complete example file

Everything above, in one project-scope file:

stella.toml
[meta]
schema_version = 1
scope          = "project"

[run]
create_worktrees = "ask"

[workspace]
allowed_dirs = ["../shared-fixtures"]

[providers.zai]
base_url      = "https://api.z.ai/api/coding/paas/v4"
api_key_env   = "ZAI_API_KEY"
default_model = "n-5.2"

[models]
allowed = ["zai/glm-5.2", "anthropic/claude-fable-5"]

[agents]
default_model = "zai/glm-5.2"
effort_auto   = "on"

[tools]
delegate  = "off"
task_list = "on"

[plugins]
vera = "off"

[[hooks.PreToolUse]]
matcher = "mcp__github__push_files"
  [[hooks.PreToolUse.hooks]]
  type      = "command"
  command   = "./scripts/guard.sh"
  timeoutMs = 5000

[ui]
theme = "stella-dark"

Check what actually resolved. These are the same two commands regardless of which format loaded it:

stella config
stella models

Security

This follows the same rule as settings.json: a stella.toml may carry an api_key in plaintext at user or managed scope, so treat it like any other secret file at those scopes. Project scope removes the temptation entirely — see [providers] above — which is one thing the JSON format could never enforce, because .stella/settings.json had no single fixed location to enforce it from.