Model profiles and per-harness routing
A profile is a named bundle of model routing overrides. You need one when you work across repositories that warrant different routing from the same harness: define work and personal once in your user config, then let each repository pick one.
- Define profiles in your user config under
profiles. - Select one per repository with a top-level
profilekey in that repository’s project config. - Check what is active with
systematic config show.
Defining Profiles
Section titled “Defining Profiles”Profiles live in your user config. Each entry under profiles carries agents and categories overlays limited to routing fields; the top-level profile key names your default.
{ "$schema": "https://fro.bot/systematic/schemas/v3/systematic-config.schema.json", "profile": "personal", // your default when nothing else selects one "profiles": { "work": { "agents": { "correctness-reviewer": { "model": "openai/gpt-6-astra", "opencode": { "variant": "high" }, "pi": { "thinking": "high" } } } }, "personal": { "categories": { "review": { "model": "anthropic/claude-opus-5" } } } }}A repository’s own project config may also define profiles, but only if you’ve opted in with allow_project_profiles: true in your user (or $OPENCODE_CONFIG_DIR) config — see Letting a repository suggest its own profiles below. Without the opt-in, a project-defined profiles map is ignored with a warning, same as always.
Selecting a Profile per Project
Section titled “Selecting a Profile per Project”A repository selects a profile in its project config. Project config can only select a profile you already defined; a profiles map there is ignored with a warning, unless you’ve opted in (below).
{ "profile": "work" }To make a repository ignore your default profile and run on base configuration, set profile to null:
{ "profile": null }The strongest source that sets profile wins: $OPENCODE_CONFIG_DIR config, then project config, then user config. The full resolution and merge rules are in the configuration reference.
A repository choosing which of your profiles it runs under is the feature working as intended, not a gap — that’s what per-project selection is for. If you disagree with a repository’s choice for your own session, set SYSTEMATIC_PROFILE in your shell:
SYSTEMATIC_PROFILE=personal opencodeSYSTEMATIC_PROFILE outranks every config source’s profile value, including $OPENCODE_CONFIG_DIR. It selects only — it can never supply a profile’s content, so it introduces no new trust surface. An empty or whitespace-only value is treated as unset. A name that matches nothing follows the same missing-name fallback as a bad selector from any other source (see Troubleshooting), with a warning naming SYSTEMATIC_PROFILE as the source. systematic config show reports environment as profileSelectorSource when it wins, so an override from your shell is never mistaken for something a config file set.
Letting a Repository Suggest Its Own Profiles
Section titled “Letting a Repository Suggest Its Own Profiles”Set allow_project_profiles: true in your user (or $OPENCODE_CONFIG_DIR) config to let a repository’s own project systematic.json define a profiles map and have it actually consulted:
{ "allow_project_profiles": true }{ "profile": "ci-cheap", "profiles": { "ci-cheap": { "agents": { "correctness-reviewer": { "model": "anthropic/claude-haiku" } } } }}Three properties hold regardless of anything a project config writes:
- The opt-in is user-owned only.
allow_project_profilesbehaves exactly likeprofilesandworkflow_guard: a project config can never set it. A project setting it (even alongside its ownprofilesmap, in the same file) has both stripped before the file is even parsed — self-authorization doesn’t work, it’s simply never reached. - A project-defined bundle is advisory. It supplies routing only where your own config is silent for that specific field, and can never override a value you set — not even partially, not even one field of a block where you set a sibling field. If you already set
agents.correctness-reviewer.modelyourself, a project bundle that also sets it changes nothing for that agent; the project’s value never applies and never appears in the resolved config. A repository whose entire suggested profile is already covered by your own config is a normal, silent no-op — see Checking Active Routing for how to see that for certain rather than assume it. - A project-defined name can never shadow one you already defined. Bundle lookup checks custom, then user, then project last. If your
personalprofile and a repository’spersonalprofile both exist, yours always wins. This does NOT protect a name you have only referenced — if your ownprofiledefault names a bundle you never actually defined (or typoed), an opted-in repository’sprofilesentry of that same name is still consulted and can supply its content, silently, with no “not defined” warning. - A malformed project bundle can hard-fail your config load even if you never select it. Every bundle in every source that may define one — including, once opted in, a project’s
profilesmap — is validated up front, selected or not. A typo in a bundle nobody uses still failsloadConfig(and therefore plugin init) for everyone. This is a deliberate design choice (matching how user- and custom-defined bundles already behave), but it means enabling the opt-in makes your config load depend on repository content you did not write.
The repository still picks which profile — its own, or yours — is active for itself via profile, same as above; SYSTEMATIC_PROFILE still overrides that selection for your own session. Full mechanics (chain position, field-additive merge) are in the configuration reference.
Per-Harness Overrides
Section titled “Per-Harness Overrides”Any agents.<name> or categories.<category> overlay, in a profile or in base config, accepts opencode and pi blocks. The flat model stays the harness-neutral default; each block adds that harness’s qualifier.
{ "agents": { "correctness-reviewer": { "model": "anthropic/claude-sonnet-5", "opencode": { "variant": "high" }, "pi": { "thinking": "high" } } }}opencode.variantfollows the layer that suppliesmodel. When a more specific layer (an agent) overrides the model, avariantset at a less specific layer (its category) is dropped rather than inherited.pi.thinkingis independent ofmodel. It applies to whatever model the Pi delegate runs, including one inherited from the parent session, sothinkingwith no model anywhere is valid.
The configuration reference has the precedence order and the model: null rules.
Migrating Pi Subagents Thinking
Section titled “Migrating Pi Subagents Thinking”pi_subagents.<name>.thinking is deprecated in favour of agents.<name>.pi.thinking (and the category form). Move the value into the pi block:
{ "agents": { "repo-research-analyst": { "pi": { "thinking": "medium" } } }}{ "pi_subagents": { "agents": { "repo-research-analyst": { "thinking": "medium" } } }}A legacy field still works and prints one deprecation warning naming the exact path you wrote. When both are set, the pi block wins.
Checking Active Routing
Section titled “Checking Active Routing”If the systematic CLI is on your PATH, config show prints the config files it found, the active profile, which source selected it, which file actually defined its content (Defined in:), any fallback, and the resolved routing for every overlaid agent — including, per field, which source contributed the value (origin, below). --json gives the same data as one object. Plain config show also prints the full contents of the user and project config files, while --json reports only their paths — keep that in mind before pasting either into an issue:
systematic config showWith an opted-in project-defined ci-cheap profile selected by a repository, and nothing in your own config touching that agent:
Resolved configuration: Active profile: ci-cheap Selected by: project Defined in: /path/to/repo/.opencode/systematic.json
Routing: correctness-reviewer (review): opencode: model=anthropic/claude-haiku (agent/flat, project-profile), variant=(none) ((n/a)) pi: model=anthropic/claude-haiku (agent/flat, project-profile), thinking=(none) ((n/a))Defined in: names the file that actually defined the active profile’s content — not just which source selected it (Selected by:). It’s absent (not printed empty) when no profile is active. The routing table’s trailing tag after level/form (project-profile above) is the same per-field attribution --json carries under source.model.origin/source.qualifier.origin: user, custom, user-profile, custom-profile, or project-profile — user/custom mean your own base config set that specific field directly; *-profile means the active profile bundle set it, split by which file defined the bundle.
This is how you tell a project-defined profile that changed nothing apart from what it was allowed to from one that changed everything it could: if every field reads user/custom while a project profile is active, the bundle was fully absorbed by your own config (a normal, silent no-op — see Letting a Repository Suggest Its Own Profiles); if some fields read *-profile and others read user/custom on the same row, it partially applied; if every field reads *-profile, it applied in full. Without this, “the bundle did nothing” and “the bundle isn’t active” print identically.
{ "locations": { "user": "/home/you/.config/opencode/systematic.json", "project": "/path/to/repo/.opencode/systematic.json", "custom": null }, "activeProfile": "ci-cheap", "profileSelectorSource": "project", "activeProfileSourcePath": "/path/to/repo/.opencode/systematic.json", "profileFallback": null, "routing": [ { "target": { "agentKey": "correctness-reviewer", "category": "review" }, "opencode": { "model": "anthropic/claude-haiku", "source": { "model": { "level": "agent", "form": "flat", "origin": "project-profile" } } }, "pi": { "model": "anthropic/claude-haiku", "source": { "model": { "level": "agent", "form": "flat", "origin": "project-profile" } } } } ]}(qualifier and source.qualifier are omitted here, not null — neither harness has a qualifier set in this example, and JSON.stringify drops an undefined field entirely rather than serializing it.)
source tells you which layer supplied each value: level is agent or category, form is block, flat, or legacy-pi-subagents, and origin (new) is the file-provenance tag described above — absent when not known (a qualifier that never resolved, or a legacy pi_subagents-sourced value, which isn’t origin-tracked since a profile bundle can’t set it).
Both forms exit 1 when the configuration fails to load, and 0 otherwise, so a script can branch on the exit status rather than parsing the output. Plain config show reports the failure on stderr — naming the file and the reason it was rejected — so redirect with 2>&1 before copying the output into an issue. --json keeps its envelope on stdout with the same reason under error.
Troubleshooting
Section titled “Troubleshooting”- A selected profile name is not defined. Systematic warns once and uses your own default
profileinstead; with no usable default it runs on base configuration.config showreports the fallback underprofileFallback. - Config fails to load naming an agent and
opencode. An OpenCodevariantresolved with nomodelat any layer for that agent. Add a model at the same layer or a less specific one, or remove the variant. - Config fails to load naming
profiles.<name>.agents.<key>orprofiles.<name>.categories.<key>. A profile contains an agent or category key that is not a bundled name. Every profile is validated at load, selected or not, so the typo has to be fixed even in a profile you never use. - A profile changed routing but nothing else. By design: profile entries carry only
model,variant,temperature,top_p,opencode, andpi. Fields such asmode,permission,disable,hidden,steps, andcolorcome from your base config and survive any profile switch. - A project-defined profile is active but routing looks unchanged. Check
config show’s per-fieldorigin. If every field readsuser/custom, your own config already covers everything the bundle would have supplied — that’s the advisory guarantee working, not a bug. - A project’s
profilesmap is ignored even though I expected it to apply.allow_project_profilesmust be set in your user or$OPENCODE_CONFIG_DIRconfig, not the project’s — a project setting it has no effect (see Letting a Repository Suggest Its Own Profiles).