Skip to content

Course · OpenCode · Daily workflow

Codify the chore you keep typing

Every feed aggregator slowly accumulates dead sources: a blog that stopped publishing, a podcast that moved hosts, a site that now 404s. On feedmill - the course’s illustrative scenario, a small self-hosted feed aggregator (a Go CLI plus an HTTP sync server) - the remedy is a chore that recurs: walk every registered feed, find the ones that haven’t published in a while, and open a tracking issue for each dead one so a flaky source doesn’t quietly rot the reading queue. It’s the same paragraph every time - “check every feed’s last-published timestamp, flag anything stale past N days, open an issue per stale feed with the source name and last-seen date.” Only the threshold changes: seven days as the regular sweep, fourteen after a long stretch away.

Re-typing that paragraph is the waste. The instructions are stable; you are just being the thing that remembers them. OpenCode has a primitive built exactly for this: a command - a markdown file you write once and then trigger as /staleness-sweep from the prompt, with the changing bit passed in as an argument. This lesson turns that paragraph into one slash verb.

A project command is a markdown file under .opencode/commands/, and the filename is the command name - staleness-sweep.md becomes /staleness-sweep (as of October 2026; commands documentation). Drop it in the repo and it travels with feedmill: once it’s committed, anyone who clones the project gets your chore for free. If you wanted the command on every repo you touch instead of just this one, the global path is ~/.config/opencode/commands/ - but this chore is feedmill-specific, so it goes in the project.

The body of the file is just the prompt you’d have typed. When you run /staleness-sweep, OpenCode reads the file, substitutes any arguments, and feeds the result to the agent as if you’d written it yourself. That’s the whole mechanism - a command is a saved prompt with a name and argument slots, not a new kind of execution.

Have OpenCode write the first version, describing the chore the same way you’d narrate it by hand. One note on the markers in the exchange. By default OpenCode lets the build agent (OpenCode’s default primary agent) edit files and run bash without asking. The exchanges on this page assume the repo’s opencode.json (OpenCode’s config file) sets both to ask, so each call arrives as an approval prompt instead of landing silently (permissions documentation, as of October 2026):

opencode.json
{
"permission": {
"edit": "ask",
"bash": "ask"
}
}

An illustrative exchange:

> create .opencode/commands/staleness-sweep.md - it's the staleness chore I keep
doing by hand. Walk every feed registered in feedmill, find the ones whose
last-published date is older than a threshold I pass in, and open one tracking
issue per stale feed with the source name and its last-seen date.
| Bash mkdir -p .opencode/commands/ → approved
| Write .opencode/commands/staleness-sweep.md
---
description: Flag feeds with no new items past a day threshold and open an issue per stale source
---
Check every feed registered in feedmill for staleness.
For each registered source, find the most recent item's published
timestamp. Treat a feed as STALE if nothing has published in the last
$ARGUMENTS days. For each stale feed, open a tracking issue titled
"Stale feed: <source name>" whose body names the source and its
last-seen date. List the stale feeds you found at the end.
Permission required
Allow once Allow always Reject

Read it before you approve, the same as any edit. Two things are worth noticing. The frontmatter description is what shows up next to /staleness-sweep in the slash menu, so write it as a reminder-to-future-you, not a title. And the body says $ARGUMENTS where the threshold goes - which is the part that makes this reusable instead of frozen at one number. The frontmatter can carry more than the description: agent pins which agent executes the command, model overrides the model it runs on, and subtask controls whether it triggers a subagent invocation - a separate agent run with its own context, not your main session (same doc, as of September 2026).

$ARGUMENTS is a placeholder. Whatever you type after the command name gets substituted in before the prompt reaches the agent. So the regular seven-day sweep is below. One assumption: feedmill’s items table in internal/feed/store.go holds only key, feed_url, title, and link, so this run assumes you have already added a published column to it, and that the GitHub CLI (gh) is installed and signed in for the repo.

> /staleness-sweep 7
| Read internal/feed/store.go (1 file)
Reading the items schema, then I'll query the newest item per feed
in feedmill.db, flag anything with no item newer than 7 days, and
open an issue per source.
| Bash sqlite3 feedmill.db "SELECT feed_url, MAX(published) FROM items GROUP BY feed_url" → approved
Three feeds are stale past 7 days: "Linux Weekly News" (last item
18d ago), "Old Photoblog" (41d), "Defunct Podcast" (96d). Opening
one tracking issue each.
| Bash gh issue create --title "Stale feed: Linux Weekly News" ... → approved
| Bash gh issue create --title "Stale feed: Old Photoblog" ... → approved
| Bash gh issue create --title "Stale feed: Defunct Podcast" ... → approved
Opened 3 issues. The reading queue's dead sources are now tracked.

The 7 landed wherever $ARGUMENTS sat in the file. Come back after a long stretch away and you run /staleness-sweep 14 - same command, different threshold, no editing the file.

$ARGUMENTS is the whole argument string. When a chore takes several distinct inputs you can split them positionally: $1, $2, $3 substitute the individual tokens. If you grew this into “sweep feeds older than N days but only in category C,” the body would reference $1 for the days and $2 for the category, and you’d call /staleness-sweep 14 tech. For one input, $ARGUMENTS is the simpler reach; for several, positional tokens keep them straight.

The same approval model still applies inside a command. The body is a prompt, not a bypass - every bash call and every edit it drives still goes through whatever permission the current agent has. Under the default allow, that means no prompt at all; with the ask config above, you approve each call. A command saves you the typing; it doesn’t make the agent trusted to run unwatched. Running the chore with no human at the keyboard - say, on a cron before anyone’s awake - is a separate decision with its own tradeoffs: Run OpenCode headless covers opencode run for unattended jobs and the permission posture an empty chair needs, and the server, CI, and GitHub wires those runs into CI. Unattended approval is a deliberate choice you make there, not something a command grants on its own.

It’s worth being precise about this, because OpenCode has a neighboring primitive that’s easy to confuse with commands. A skill is a SKILL.md package whose description the agent sees, so the agent reaches for it on its own when your request matches; the skills chapter covers them in full. A command is the opposite trigger. Nothing fires a command until you type its slash name. That’s the distinction in one line:

  • A skill is model-triggered - the model decides to load it from its description.
  • A command is user-triggered - it’s the slash surface you drive deliberately.

So the test for which one to write is: do you want the agent to reach for this routine when it spots the need, or do you want to pull the trigger yourself? “Onboard a new feed format” suits a skill - the agent can reach for it when a new source shows up mid-conversation. “Run my staleness sweep” suits a command - it’s a thing you decide to do, at a moment you choose, with a threshold you name. Same repo, two routines, two different primitives because they have two different triggers.

Once a chore is one keystroke, you’ll run it more - and a few commands like this can become a real slice of your model spend, if they run on a premium model. OpenCode shows you where that spend lands by model, not by command: opencode stats reports token usage and cost across your sessions, --days limits it to a recent window, --models breaks the spend down by model, --tools shows the most-used tools, and --project filters to one project (as of October 2026; CLI documentation).

That’s not a tangent from codifying chores - it’s the feedback loop that tells you which chores are worth codifying. stats doesn’t name the slash command behind the spend, so you connect the two yourself. If a premium model carries more of the bill than your hard work explains, a command you run constantly on that model is a likely cause, and you’d then pin it to a cheaper model in the command’s frontmatter. Codify the chore, then look at what it costs.

Your staleness paragraph is now /staleness-sweep 7, living in the repo, owned by the tool. The next reflex to build isn’t a file at all - it’s how you talk to the agent and move around the TUI (terminal user interface) fast enough that even the un-codified work flows. Next: prompting and keyboard reflexes.