At 27 configured agent entries with different models, permissions, and routing behavior, managing OpenCode by hand stopped being practical. I started treating the setup like any other production configuration system: version-controlled inputs, explicit overlays, validation, and rollback when a generated config is invalid.
The important distinction is between the files that describe an agent and the generated configuration that actually runs. Prompt files, registries, and overlays are inputs; the resolved runtime config is the contract.
Agent packaging
The effective agent contract is split across two layers.
The first layer is the instruction package. Most locally defined agents use a Markdown file under agent/, with YAML frontmatter for descriptive metadata and a natural-language prompt body. Some runtime entries instead reference shared or runtime-specific instruction files, so there is not always a one-to-one mapping between an agent key and agent/<name>.md.
---
description: "Staff-engineer chief-of-staff router: policy + routing + delegation"
mode: primary
model: github-copilot/claude-sonnet-4.6
temperature: 0.05
---
The second layer is opencode.base.json, combined with shared registries and environment-specific overlays. It controls runtime behavior such as the active agent key, model, instruction paths, exposed tools, and permission rules.
{
"orchestrator": {
"mode": "primary",
"model": "github-copilot/claude-sonnet-4.6",
"instructions": "agent/orchestrator.md",
"tools": { "edit": false, "write": false, "task": true },
"permission": { "edit": "deny", "bash": { "git status*": "allow" } }
}
}
The generated ~/.config/opencode/opencode.json is authoritative. Frontmatter is useful documentation, but model or mode values can be overridden during the merge. That matters when debugging: reading a prompt file alone does not tell you what the runtime resolved.
Routing and delegation
The current base configuration contains five primary entry points and 22 specialist entries. Primary agents receive a request and either execute within their configured role or delegate through task().
The orchestrator is intentionally delegation-oriented. Independent work is sent to multiple specialists in parallel, while dependent work is serialized.
# independent work
@tester add coverage for the auth endpoints
@language-coder implement the profile API
@ui-coder build the profile settings page
# dependent work
@task-manager produce the implementation plan
@language-coder implement from the completed plan
The lower-cost routing layer is intended to keep straightforward work away from deeper models. Its exact model is merge-dependent: the prompt metadata and base configuration can differ from the shared model registry, so the final assignment must be read from the generated runtime config rather than inferred from one source file.
In the configuration captured on 2026-02-28, I used three routing classes:
| Class | Typical work | Representative model |
|---|---|---|
| Fast path | Small fixes, test authoring, formatting, and background tasks | gpt-4.1 |
| Standard | Multi-file implementation, decomposition, and documentation | gpt-5-mini |
| Deep | Architecture, security analysis, UI work, and high-risk review | claude-sonnet-4.6 |
These are historical assignments, not durable recommendations or measured traffic percentages. The routing rules evolve with model availability, cost, and observed quality. The later placement work described in From Model Routing to Workload Placement moves the durable contract up a level: callers request capabilities and policy selects an eligible runtime-and-model target.
Permission boundaries
Prompts describe intent, but configuration-level restrictions are what keep planners, reviewers, and routers from modifying files directly.
Full permission table (text)
| Runtime role | Edit access | Shell access | Delegation access |
|---|---|---|---|
orchestrator |
Denied (edit, write, patch) |
Narrow inspection commands | Allowed through task() |
explore |
Denied | Denied; read, grep, glob, and list only | Denied |
plan |
Denied | Limited inspection for planning | Denied |
task-manager |
Denied | Denied | Denied |
reviewer |
Denied | Narrow repository inspection | Denied |
security-auditor |
Denied | Constrained audit commands | Denied |
tester |
Tests only | Limited to test-related commands | Denied |
There is a naming wrinkle worth making explicit. The prompt concept is called scout, while the active OpenCode runtime key and user command are explore and /explore. Treating those as the same identifier hides a real configuration boundary, so I document the mapping rather than pretending it does not exist.
I describe these controls as explicit restrictions rather than a universal deny-by-default sandbox. Whether an unmatched command is denied, inherited, or prompts for approval depends on the final merged permission policy and OpenCode's runtime semantics. The generated config is the source to inspect.
Config pipeline and rollback
The merge pipeline composes the base config with shared model metadata and local overlays.
1. opencode.base.json
2. ~/.dotfiles/agents/registry/models.base.json
3. ~/.dotfiles/agents/registry/models.personal.json
4. opencode.personal.json
5. ~/.overlay/local/opencode.local.json
6. ~/.config/opencode/generated/*.json
-> validate syntax, models, and instruction references
-> write ~/.config/opencode/opencode.json
Before writing the new runtime file, the script snapshots the current version. A failed merge or validation step restores that snapshot.
# backup_runtime_config() saves the current runtime config
# an ERR trap restores it when merge or validation fails
# result: the last known-good opencode.json remains active
Environment selection follows three signals: AGENT_ENV, the local overlay marker, and the personal configuration as the fallback.
The rollback path is the most valuable part of the system. A malformed instruction path or invalid model name fails during generation instead of surfacing later when an agent is already handling a task.
Personal assistant modules and memory
The personal-assistant entry loads a small always-on context spine and adds request-specific modules only when their trigger conditions match.
| Module | Example triggers |
|---|---|
| Apple integration | Calendar, Reminders, iCloud, location |
| Daily briefing | Planning the day or week, prioritization |
| Reporting templates | Standups, Jira updates, status reports, postmortems |
| Engineering workflows | Git, pull requests, reviews, builds, and tests |
| Work-note linking | Incidents, runbooks, decisions, and project notes |
| Graph tracking | Open work and recent project context |
| Reliability bias | Architecture, failure modes, and operational risk |
| Learning preferences | Onboarding and preference updates |
| AI productivity | Delegation strategy and workflow friction |
The session-memory layer is exposed through MCP and backed by LeanCTX. It persists workflow state, preferences, project conventions, and interaction history so project context can be resumed without rebuilding it from scratch.
Commands and plugins
More than 65 slash commands live under command/*.md. Each command supplies routing metadata and can load context based on its arguments.
The repository also contains TypeScript plugins for notifications, compaction, output truncation, file-operation tracking, context injection, diagnostics, and related runtime behavior. Source presence is not the same as runtime activation: a plugin only enforces behavior when it appears in the effective plugin configuration and loads successfully.
For example, sensitive-file-protection.ts exists in the source tree, but it is not currently registered in the base plugin array. I therefore do not rely on it as an active security boundary. The effective runtime plugin list must be checked before claiming that a protection is enabled.
Deployment
The system is managed from dotfiles and linked into place with GNU Stow.
~/.dotfiles/
opencode/.config/opencode/
opencode.base.json
agent/*.md
command/*.md
scripts/merge-env-config.sh
scripts/validate-all-configs.cjs
plugins/*.ts
context/
modules/personal-assistant/
agents/registry/
agents/runtime-specific/
cd ~/.dotfiles
stow opencode
Agent count was never the interesting part. What changed is that prompts, models, permissions, overlays, validation, and rollback now behave as one configuration system instead of a pile of files that happen to work today. The implementation keeps evolving, so the generated runtime config is the one place to check. Diagrams, prompt headers, and this post can drift out of date. The generated config can't, because nothing runs without passing through it.
The harness I added, then retired
Not long after writing the above, I added a second harness, Pi, and pointed it at the same shared registries (agents/registry/models.base.json, agents/registry/subagents.base.json) so model and subagent policy didn't have to be redefined per harness. Pi's parallel subagents needed something OpenCode's sequential delegation didn't: a shared, append-only session diary, written through a lean-ctx HTTP tool, so agents running at the same time could see each other's findings. I designed it and wrote it up as a decision record.
It never shipped. Pi's subagent runtime was retired on 2026-06-28, superseded by Claude Code agents and a remote worker on LXC 248, before the diary pattern saw real use. What replaced it is plainer: a subagent writes down what it found, the orchestrator reads it back before starting the next one. No shared live diary, no extra dependency. Less clever, and it's the one that's actually running.
OpenCode never stopped. It's still one of the harnesses I use, still generating the config this post describes. What didn't survive was the assumption that I'd only ever need one.