Course · OpenCode · Living in the TUI
Tab between plan and build
The dedupe layer is the part of feedmill - the course’s illustrative Go feed aggregator - that you trust least. Two feeds publish the same story under different GUIDs, a third re-syndicates it with a tweaked title, and the current code catches maybe half of them - so your reading queue fills with the same article three times. You want to restructure it. But this is exactly the kind of change where letting the agent start editing on its first instinct is how you end up with a clever rewrite of the wrong thing. You want a plan first - a real one, that you read and push back on - and then the execution, without losing the thread between them.
OpenCode keeps both in one thread and one keystroke apart, because planning and building aren’t two modes of one agent - they’re two agents you switch between with Tab.
Plan and build are agents, not a switch
Section titled “Plan and build are agents, not a switch”OpenCode ships two primary agents out of the box - the agents you talk to directly, as opposed to the subagents they can delegate to (as of October 2026; agents documentation):
build- the default. Full edit access; it can write files and run commands.plan- restricted by design. It reads, greps, and reasons freely, but its permission map denies the edit tools, so no edit-tool call lands in your code while it’s supposed to be thinking about the dedupe layer (it can still write its own plan files under.opencode/plans/). It does not gate bash unless you add a rule, so a shell command such asgofmt -wcan still change files (as of October 2026, v1.18.32, per theplandefinition inagent.ts; the agents documentation describes edits and bash as set to ask).
Tab cycles between them - the default binding for the agent_cycle keybind - and Shift+Tab cycles back (agent_cycle_reverse; keybinds documentation). This is the thing to internalize: you’re not flipping a “plan mode” flag on one agent. You’re handing the same conversation to a different agent with a different permission policy. That distinction is why edit-tool calls in plan are refused while edits in build go through.
Plan the restructure where edits are refused
Section titled “Plan the restructure where edits are refused”Tab into plan and describe the outcome, not the steps - the same way you would in build, except now no edit-tool call can change your code while you’re talking, unless your config allows it. A top-level "permission" rule for edit (or "*") in opencode.json is applied after plan’s built-in deny, and the last matching rule wins, so a global "edit": "ask" turns the refusal into a prompt and a global "allow" removes it; put a rule meant for one agent under "agent" instead. Bash is still allowed, so if shell commands must wait for you too, add "agent": { "plan": { "permission": { "bash": "ask" } } } to your opencode.json (as of October 2026; agent permissions merge over the defaults in agent.ts). An illustrative exchange:
plan ›> the dedupe layer misses re-syndicated and retitled duplicates. Read how dedupe works today and sketch how you'd restructure it to catch those - don't change anything yet, just the approach.
• Read internal/dedupe/hash.go, internal/adapter/normalise.go (2 files)
Today dedupe keys on an FNV-64a hash of the feed URL plus the entry ID, so a story re-syndicated into another feed - or under a new ID - reads as new. Here's how I'd restructure it:
1. Split dedupe into two passes: an exact pass (GUID/link) and a fuzzy pass (normalized title + published-date window). 2. Add a Fingerprint type in hash.go so both passes share one normalization path. 3. Add a small content-similarity check behind an interface, so the fuzzy pass is swappable later without touching the caller.
This edits hash.go and adds a new dedupe.go beside it. I have not changed any files.It read the real code before sketching anything - you can see which files - and came back with a plan you can actually argue with, not a vague intention. So argue with it, still in plan:
plan ›> a fuzzy title match scares me on a feed reader - false-merge a real second article and the reader never sees it. Make the fuzzy pass conservative and reversible.
Good constraint. I'll gate the fuzzy pass behind a high similarity threshold and have it record both items with a "merged-into" pointer rather than dropping one - so a wrong merge is recoverable, not silent. No code written.That second turn is the payoff of the restricted agent. You shaped the design - narrowed it away from a genuinely dangerous behavior for a feed reader - across two turns, and no edit tool was available to slip a change in while you were still reasoning. The plan agent’s permission policy denied the edit tools for the whole exchange.
Tab back to build and execute the same plan
Section titled “Tab back to build and execute the same plan”Now Tab. You’re in build - edit access, same conversation. You don’t re-explain anything; the plan you and plan just hammered out is sitting in the history this agent picks up from.
build ›> good - implement that, conservative fuzzy pass and all. Start with the Fingerprint refactor, then the two passes.
⏵ Edit internal/dedupe/hash.go + type Fingerprint struct { ... } ⏵ Create internal/dedupe/dedupe.go ⏵ Create internal/dedupe/dedupe_test.go
⏵ Run go test ./internal/dedupe/... • ok github.com/example/feedmill/internal/dedupe 0.21s
Done. dedupe now runs an exact pass then a conservative fuzzy pass; the fuzzy pass records a merged-into pointer instead of dropping items.That illustrative transcript runs on stock permissions, so the edits and the test run landed without a prompt. The permission policy is the difference between the two agents: build edits files and runs commands without asking by default, because most permissions default to allow, while plan denies file edits and leaves bash at allow (permissions documentation for the defaults and agent.ts for plan, as of October 2026). If your opencode.json (the project’s config file) gates build’s edits with "agent": { "build": { "permission": { "edit": "ask" } } }, as the first-change lesson does, each of those edits waits for your approval instead. Same thread, same context, different agent, different rules. That is the control principle, the part of the setup that decides what the agent may do without asking, and here it travels with the agent while the context travels with the conversation. You planned with one and built with the other, and the handoff cost you exactly one keystroke and zero re-explaining.
When two agents aren’t enough, make a third
Section titled “When two agents aren’t enough, make a third”plan and build are just the two primary agents that ship by default - the design isn’t limited to them. A custom agent can define its own permission profile and join the same Tab cycle: what puts an agent in the cycle is its mode field, which takes the values primary, subagent, or all (primary and all join the cycle, subagent does not; the default is all when unset; as of October 2026, agents documentation). On feedmill the obvious one is a review primary: restricted like plan, but with a prompt and a model tuned for auditing diffs rather than designing them, so after a build run you can Tab to review for a cold second read before you commit. You’ll write one in the custom agents lesson; for now the point is just that the plan/build toggle is the visible case of a general mechanism - primary agents you cycle through, each with its own leash.
The move you just learned - outline where the edit tools are denied, Tab, execute - is the one you’ll reach for on every change big enough to deserve a plan. The last TUI lesson changes the keys and the theme themselves. Next: make the TUI yours.