Run OpenCode headless
You’ve been driving feedmill from the TUI all chapter, and that’s the right surface while you’re deciding things. But two jobs don’t want a human in front of them. The first is the suspect-feed fix you just worked out interactively — the parser that chokes on one site’s malformed <pubDate> — which you’d like to drop into a repair script so the next time a feed goes bad you run one command instead of reopening the whole session. The second is feedmill’s nightly digest: a regeneration job that should fire on a cron at 4am and leave you a fresh reading queue by morning, with nobody awake to approve anything.
Both are the same need — OpenCode without the terminal in the loop. That’s opencode run.
One prompt, no TUI
Section titled “One prompt, no TUI”opencode run "<prompt>" executes a single turn non-interactively. You hand it the prompt as an argument instead of typing it at a live prompt, it works the task, prints what it did, and exits. Same agent, same model resolution, same AGENTS.md — just no UI to sit in.
$ opencode run "the atom parser drops entries whose <updated> is missing a timezone. Make it fall back to UTC instead of skipping the entry, and add a test feed that reproduces it."
Read internal/parse/atom.go, internal/parse/atom_test.go (2 files)
Entries with a naive <updated> timestamp fail time.Parse with the RFC3339 layout and get dropped silently. I'll add a UTC fallback layout and a testdata feed that exercises it.
Edit internal/parse/atom.go Edit internal/parse/atom_test.go Run go test ./internal/parse/ ok feedmill/internal/parse 0.142s
Done. Naive timestamps now parse as UTC; the new testdata/naive-updated.xml feed pins the case that was previously dropped.If you watched the TUI loop in the earlier chapters, nothing here is new — read, propose, apply, verify, all of it ran. What’s gone is you. There was no approval pause, because in a headless run there’s no one to approve. That’s the part to be deliberate about, and it’s the next section.
Headless reuses your whole setup — including the parts that say “ask”
Section titled “Headless reuses your whole setup — including the parts that say “ask””The thing people get wrong on their first cron run is assuming headless mode is a different, more permissive OpenCode. It isn’t. opencode run reads the same opencode.json, agents, and AGENTS.md your TUI session does — OpenCode discovers config by starting in the working directory and walking up to the nearest Git root, and that discovery isn’t conditioned on whether a TUI is attached (opencode.ai/docs/config/). So the same agents and the same per-tool permission map are in force. The rules you wrote about never touching the money path — or in feedmill’s case, never rewriting testdata/ fixtures the parsers are pinned against — apply byte-for-byte to the headless run.
The wrinkle is permissions. Your TUI agent probably has edit: ask and bash: ask, because interactively the ask is a feature — it’s the leash. Headless, there’s nobody to answer the ask. So an ask rule with no human behind it stalls rather than proceeds — the run can hang waiting on an approval that will never come. The fix is to run headless under an agent whose permission map is built for an empty chair: explicit allow on the tools the job legitimately needs, explicit deny on the ones it must never reach, and no ask left dangling. (OpenCode also ships a documented headless escape hatch, --dangerously-skip-permissions, which auto-approves anything not explicitly denied — but reach for a purpose-built agent before you reach for that.) Pick that agent with --agent, the same way you’d Tab to it in the TUI:
$ opencode run --agent build "regenerate tonight's digest from the synced feeds"Treat the headless permission map as a contract you can read before you wire it into cron, not something you discover at 4am from a stuck job. Decide on purpose what an unattended feedmill run is allowed to do to your repo and your feed store.
—format json turns a run into a stream
Section titled “—format json turns a run into a stream”The default output is formatted for a human reading a terminal — fine for the repair script you run by hand. But the nightly cron job wants something a machine can act on: did the run succeed, what did it touch, did a feed fail to parse. For that, opencode run takes --format json, which emits the run as raw JSON events instead of prettified text. Pipe it straight into jq or a log shipper:
$ opencode run --format json "regenerate tonight's digest" \ | tee /var/log/feedmill/digest-$(date +%F).jsonl \ | jq -r 'select(.type == "error") | .message'Now the cron job leaves a per-event JSON line trail you can grep tomorrow, and the jq filter surfaces any error the run hit without you reading the whole transcript. Two things to keep straight:
--formattakes exactly two values:default(the formatted output) andjson(raw events). There is no separate--output-formatflag, and no schema-validation flag — the JSON is an event stream, not a fixed-shape contract you can pin a schema against. Parse it as a sequence of typed events.- The same
--format jsonworks onopencode session list, so a monitoring script can ask “what ran overnight” without scraping logs:opencode session list --format json | jq '.[] | {id, title}'(the json output is an array). Note the value sets differ per command:runtakesdefaultorjson,session listtakestableorjson(its formatted default is a table), andopencode dbtakesjsonortsv.
Wiring it into cron
Section titled “Wiring it into cron”With the agent and the output format settled, the cron entry itself is unremarkable — and that’s the goal. Headless OpenCode should be boring infrastructure, not a fragile script that only works when you babysit it:
# regenerate the feedmill reading queue every night at 04:000 4 * * * cd /srv/feedmill && opencode run --agent build --format json \ "regenerate the nightly digest from the synced feeds" \ >> /var/log/feedmill/digest.jsonl 2>&1Note the cd into the repo: OpenCode walks up from the working directory to find AGENTS.md and the project’s opencode.json, so a cron job that fires from / won’t see your rules. Run it from the repo root, or the unattended agent loses exactly the context you spent the chapter giving it.
That’s the whole move: opencode run for one-shot non-interactive work, an agent whose permissions are written for an empty chair, and --format json when something downstream needs to read the result. The repair script and the nightly cron are now the same agent you’ve been driving all along — just without you in the room.
There’s one job left that headless run doesn’t cover on its own: keeping a long-lived OpenCode process up so CI and a GitHub workflow can talk to it without paying cold-boot cost on every invocation. Next: the server, CI, and GitHub.