Skip to main content

The Harness I Added, Then Retired

OpenCode's config discipline hasn't changed since this was written: inputs, a generated contract, validation, rollback. The second harness I built alongside it for delegation didn't survive the year.

Lovell Felix 7 min read

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.

Instruction packages and runtime inputs merging into a validated OpenCode runtime configuration, with rollback on failure

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.

Permission boundaries by runtime role A capability matrix of seven OpenCode runtime roles across edit, shell, and delegation access. Every role is denied direct file edits and delegation, except the orchestrator, which may delegate through task(). Shell access is limited to narrow inspection for most roles and fully denied for task-manager and explore. Only the tester may edit, and only test files.

EDIT SHELL DELEGATE

orchestrator explore plan task-manager reviewer security-auditor tester

allowed limited denied

PERMISSION BOUNDARIES — enforced by config, not prompt; only the orchestrator delegates.
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.

About the author

Lovell Felix

Infrastructure and reliability engineer working on Linux platforms, configuration delivery, and deployment safety at fleet scale.

@lovellfelix

More notes