Skip to content

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.

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.

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:

  • --format takes exactly two values: default (the formatted output) and json (raw events). There is no separate --output-format flag, 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 json works on opencode 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: run takes default or json, session list takes table or json (its formatted default is a table), and opencode db takes json or tsv.

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:00
0 4 * * * cd /srv/feedmill && opencode run --agent build --format json \
"regenerate the nightly digest from the synced feeds" \
>> /var/log/feedmill/digest.jsonl 2>&1

Note 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.