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
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.
Your personal defaults, the same role ~/.stella/settings.json plays.
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.
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; 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-runEvery 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_versionThis 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.scopeSelf-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.
[meta]
schema_version = 1
scope = "project" # "user" | "managed" | "project"What moved
These changed shape when TOML was added. Everything else kept its name.
| settings.json | stella.toml | Why |
|---|---|---|
enable_recap (bare root key) | [run].recap | TOML 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].allowed | Promoted 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_gitignoreSame name as JSON, and the one toggle here that defaults on. See The probe's gitignore filter.
Default on
run.auto_trust_projecttrue 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_pluginsWhich 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.
[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_dirsDirectories 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.
[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.
[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.allowedThe 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_capsPer-model output-token ceilings, merged per key (unlike allowed above). See
[models.output_caps]
for the full precedence chain.
[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.
[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.
[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.
[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.
[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.
[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:
[[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.
[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.
[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.
[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_weightThe size of a deterministic pass or fail — the unit the other prices below are measured against.
Default 1.0
reward.per_stepSubtracted per model call.
Default 0.02
reward.per_usdSubtracted per USD spent.
Default 0.5
reward.per_revisionSubtracted per verification round after the first.
Default 0.1
[reward]
per_usd = 0.05 # price a dollar of spend at a tenth of the defaultreward.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_occurrencesMatching invocations before a shell shape is worth proposing. 0 or 1 disable
detection.
Default 3
foundry.min_distinct_argumentsDistinct argument sets required — one set is an exact repeat, which is loop detection's territory.
Default 2
foundry.min_reuse_ratioUses 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_successWhether a cluster needs at least one successful invocation.
Default true
foundry.max_examplesCap 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.autonomyauto (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_failuresConsecutive failures that auto-disable a foundry tool. Must be at least 1.
Default 3
foundry.breaker_windowRecent invocations the failure-rate arm looks at. Must be at least 1.
Default 10
foundry.breaker_failure_rateThe failure share over that window that trips the breaker. Above 0, at most 1.
Default 0.5
foundry.network_allowlistFoundry-built tools allowed to reach the network, by name. Empty means every foundry tool spawns with network denied.
Default []
[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.enabledoff 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_stepsHow 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
[plan_review]
min_steps = 5 # only stop me for plans of five steps or morestella 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.commitFooter on every commit message the loop causes to be written.
Default created by stella*
self_driving.attribution.pull_requestFooter on every pull request description it opens.
Default created by stella*
self_driving.attribution.issueFooter on every issue it files.
Default created by stella*
self_driving.attribution.issue_commentFooter on every issue comment it posts.
Default created by stella*
self_driving.attribution.branch_prefixThe 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_prefixA 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.
[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_checksChecks 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_afterConsecutive 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.commandThe 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_secsSeconds 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.rearmRe-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.regressRe-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.metaRead 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_commitsHow many commits the base must gain before a dry ladder re-opens.
Default 50
self_driving.supply.rearm_daysHow 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_breakageWhat 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.contentionHow 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_escalatedWhether 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_secsSeconds 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_secsSeconds 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_afterTries 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.prioritiesThe urgency rungs, most urgent first. Position in the list is the rank.
Default ["P0", "P1", "P2", "P3", "P4"]
self_driving.triage.defect_kindsLabels meaning "this loop works this".
Default ["bug", "triage"]
self_driving.triage.excluded_kindsLabels 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_labelsLabels 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_gateWhether 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_watchWhether 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.
[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.providerThe 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.manifestPath 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
[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:
[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 modelsSecurity
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.
settings.json
Configure providers, agents, tools, context, and managed authority across project, org-managed, and user scopes.
Agent engine config
Configure the session agent's model, gateway, prompt, reasoning effort, and sampling parameters with agent_engine_config, and assign models to the roles installed plugins declare.