Skip to content

Switch agents to switch policy

You’ve inherited feedmill — a Go CLI plus a sync server that pulls dozens of differently-shaped RSS, Atom and JSON feeds, dedupes them, and serves a reading queue. Before you change a line you want to understand it: trace how a malformed Atom feed slips past the parser, read the dedupe key logic, walk the cron path. That’s a read-heavy hour, and the last thing you want during it is the agent “helpfully” rewriting a parser you were only trying to read. You want a posture: eyes on, hands off.

Then the audit ends and the work begins — fix the parser, add the test, wire the new source — and now the read-only leash is just friction. You want the opposite posture: a free hand.

In a tool with named profiles you’d be editing a settings file to flip between those two stances. OpenCode puts the policy somewhere you can reach without leaving the prompt: on the primary agent itself. You don’t edit a permission map to go from auditing to building — you switch which agent you’re talking to.

OpenCode ships two primary agents, and the only interesting difference between them for this lesson is what they’re allowed to do:

  • plan — restricted. File edits and bash commands are gated behind approval: they’re set to ask, so the agent can read, grep, and reason freely, but any edit or shell command pauses for your explicit go-ahead instead of running on its own.
  • build — the default, with all tools enabled. Edits and bash run end to end, no checkpoint.

Switch between them with the Tab key, right at the prompt — no command, no file, no restart (the keybind is agent_cycle, default tab; shift+tab cycles back). The active agent is surfaced in the TUI so you can see which posture you’re in, which means your permission policy stays visible instead of buried in a config you have to remember the state of.

Start the audit in plan. Tab until the active agent shows plan, then ask the read-only questions:

plan ▸ how does the Atom parser decide an entry is a duplicate of one
we've already ingested? walk me through the dedupe key.
⏵ read internal/feed/atom.go
⏵ grep "dedupeKey" internal/feed
⏵ read internal/store/dedupe.go
The dedupe key is built in store.DedupeKey: it hashes the entry's
<id> if present, else falls back to the entry link. Atom entries
without an <id> and with a tracking-param'd link therefore hash to
different keys on each fetch — that's your duplicate leak.

That landed you a real finding, and reading never tripped a gate. Now suppose, mid-trace, you reflexively ask it to fix what it just found:

plan ▸ ok, fix it — fall back to a normalized link when <id> is missing.
⏸ edit needs approval (agent: plan)
internal/store/dedupe.go
[a]pprove [d]eny — or Tab to build to drop the prompt entirely

That checkpoint is the point. In plan, an edit doesn’t just happen — it stops and waits for you. The policy isn’t advice you’re trusting the model to honor; the agent is configured to pause every write and shell command for a human yes. On code you’ve owned for an hour, that pause is the difference between an audit and an accident. (If you want a wall that can’t be approved away rather than a checkpoint you might absent-mindedly wave through, see the custom agent below.)

Which posture an hour of work deserves is a call you can make before you touch Tab, and it rests on two properties of the work — never on how hard it is. Set them for the audit hour (inherited parsers you only mean to read), then for the fix that follows, and watch the recommended rung move; plan and build are OpenCode’s names for two of the rungs you’ll land on:

Two questions decide how much leash a task earns — neither of them is “how hard is it.” Set both for the work in front of you and read the rung it lands on.

If the agent’s worst single action went wrong, undoing it would take…
…and its consequences would reach
  1. Run free, no fencedisposable environments onlyNothing pauses it and nothing contains it. No combination on this dial lands here — it belongs only where the whole environment is disposable: a throwaway container, an already-isolated CI runner.
  2. Run free inside a fencethis taskNo prompts; the boundary does the protecting. The agent grinds end to end inside a sandbox, container, or scratch worktree, and you review the whole batch once at the end.
  3. Auto-apply edits, gate the rest
  4. Ask before acting
  5. Read & propose only

A mechanical rename across your own repo is the canonical case: the worst outcome is a git diff you throw away. Prompting on every one of twenty-four identical edits doesn’t add safety — it teaches you to stop reading prompts, which is where real risk starts. Let it run inside the fence and review the batch once.

The rung names are generic on purpose — every tool spells its own versions of them, and most let you set different rungs for different categories of action. The judgment underneath is the same two questions, asked per task, never answered once for all time.

Tab carries the thread across the policy line

Section titled “Tab carries the thread across the policy line”

Here’s what makes this better than two separate settings files: switching agents doesn’t switch sessions. The conversation comes with you. So the natural move is to do the whole audit in plan, let it propose an approach in words, and only then cross into build to execute — same thread, same context, gated-then-open policy:

plan ▸ outline the fix without writing anything yet.
Plan:
1. In store.DedupeKey, when <id> is absent, normalize the link
(strip utm_* and fbclid params, lowercase host) before hashing.
2. Add a table test in dedupe_test.go covering the tracking-param case.
(From plan I'd have to ask before applying this — Tab to build when ready.)
▸ [Tab] agent: build
build ▸ do it.
⏵ edit internal/store/dedupe.go
⏵ edit internal/store/dedupe_test.go
⏵ bash go test ./internal/store/
ok github.com/you/feedmill/internal/store 0.142s

You kept the dangerous part behind an approval gate and relaxed it for the safe part, and you never left the prompt to do it. The plan agent read and proposed; the build agent edited and ran the test — and because it’s one thread, build already knew exactly what the plan was.

When two policies aren’t enough, write a third

Section titled “When two policies aren’t enough, write a third”

plan gates everything behind approval and build is wide open, but your audit hour wanted something sharper than both: a hard read-only wall on edits — not a checkpoint you could approve away — and a narrow set of read-only shell commands allowed to run without prompting at all (git log, git blame, go vet). Neither shipped agent is exactly that.

This is where OpenCode’s answer to named profiles shows up. There are no saved “profiles” here in the Codex sense; a custom primary agent is the saved posture. You author one once, and from then on it’s just another agent on the Tab cycle. The fastest way is opencode agent create, which walks you through scope, description, and which permissions to grant, then writes the file for you. Or hand-write it — a primary agent is a markdown file in ~/.config/opencode/agents/ (global) or .opencode/agents/ (this repo only), frontmatter on top, system prompt in the body:

.opencode/agents/audit.md
---
description: Read-only auditor — reads code and runs read-only git/vet, never edits
mode: primary
permission:
edit: deny
bash:
"*": deny
"git log*": allow
"git blame*": allow
"go vet ./...": allow
---
You are auditing inherited code. Read, trace, and report findings.
Never propose edits unless asked; when asked, describe the change in
words and tell the user to switch to build to apply it.

The mode: primary line is what puts audit on the Tab cycle alongside build and plan. The permission block is the posture: edit: deny is a hard wall — unlike plan’s ask, there’s no approve button to fat-finger — and the bash map uses OpenCode’s last-match-wins evaluation, so the catch-all "*": deny comes first and the specific allowances after, letting git blame run while rm -rf never does.

Permission values are allow, ask, or deny, and they’re not limited to edit and bash — the documented keys include read, glob, grep, task, skill, lsp, question, webfetch, websearch, external_directory, and doom_loop. (There is no list permission — file listing falls under glob.) Per-agent permissions merge with your global config, with the agent’s rules taking precedence.

Now your audit hour is one keystroke away. Tab to audit to investigate with exactly the latitude you want, Tab to build when it’s time to fix, Tab to plan when you want the approval-gated middle ground. The policy isn’t something you configure each time you need it — it’s something you select, the same way you select which agent does the thinking.

That’s the coarse-grained version: a whole posture per agent, enforced at the agent boundary. But an agent that can’t edit is still an agent that can read every file on your disk and shell out to whatever its bash rules allow. When read-only isn’t a strong enough wall — auditing genuinely untrusted code, or letting a build agent run wide while you sleep — you stop relying on policy and put a real boundary around the whole process. Next: isolate by running in a container.