Skip to content

An Agentic Software Workflow ​

This is a workflow for building software with Claude Code, where agents do the work and a person sets the direction. Feature branches, pull requests and release trains were built around people typing slowly and reviewing each other's work. An agent writes the code in minutes, so the slow parts are knowing the code is right, getting it to the people who use it, and any change that has to wait for a person to start it.

The workflow is 8 rules, and the first 2 matter the most. It is a living document, and Where this stands lists which parts are in place and which are planned. For the Claude setup underneath it (syncing config across machines, hooks, handing work to a new session), see My Claude Code Setup.

The loopThree areas show where each step runs. On my Mac, or on a GitHub Actions runner when a trigger started the agent: Claude edits the code and the tests, the pre-commit hook runs the tests, a second agent reviews the diff, and a failure or a finding goes back to the edit. Once both pass, the change lands: rebase, run the pre-push checks and push. On GitHub: CI runs on main on Ubuntu and macOS, a release happens only with my OK, and triggers (an issue, failing CI, a schedule) start an agent on a runner. On users' machines: autoupdate pulls the new version on the next start, and a bug a user reports goes back to GitHub as an issue.Where the agent works: my Mac, or a GitHub Actions runnerGitHubUsers' machinesEditfrom my promptor a triggerTestpre-commit hookruns the testsReviewa second agentreads the diffLandrebase, run thechecks, pushTriggersan issue, failingCI, a scheduleReleaseonly with my OKCI on mainUbuntu, macOSUsersstudents andcolleaguesAutoupdateon the next starta failure or a finding goes backpushpassnew versionreport a bugstart an agentThe loopThree areas show where each step runs. On my Mac, or on a GitHub Actions runner when a trigger started the agent: Claude edits the code and the tests, the pre-commit hook runs the tests, a second agent reviews the diff, and a failure or a finding goes back to the edit. Once both pass, the change lands: rebase, run the pre-push checks and push. On GitHub: CI runs on main on Ubuntu and macOS, a release happens only with my OK, and triggers (an issue, failing CI, a schedule) start an agent on a runner. On users' machines: autoupdate pulls the new version on the next start, and a bug a user reports goes back to GitHub as an issue.My Mac, or an Actions runnerGitHubUsersEditfrom a prompt or triggerTestpre-commit hookReviewa second agentLandrebase, check, pushCI on mainUbuntu and macOSReleasewith my OKTriggersstart agentsAutoupdateon the next startUsersstudents, colleaguesfailpush
The loop every change goes around, laid out by where each step runs.

Every change goes around the same loop. Something starts it: a prompt from me, an issue, a failing build. An agent edits the code and the tests, a git hook runs the tests, and a second agent reviews the diff. A failure or a finding goes back to the edit. Once both pass, the change lands on main, continuous integration (CI) runs on Ubuntu and macOS, and autoupdate gets it to every install. Whatever users hit next comes back around as the start of the next change.

What I do and what the agents do ​

The agents do the work: writing code and tests, running the checks, reviewing, landing and shipping. I keep 3 jobs:

  1. Decide what to build, and say it clearly enough that an agent can do it without me.
  2. Own the tests. They are the spec, and they decide what "done" means.
  3. Approve releases. Tagging a release always needs my go-ahead, and so does anything destructive.

Everything else on this page is about making that split safe. An agent that works while I am not watching needs checks that do not depend on me watching.

1. Testing is the most important part ​

Tests are what let me step out of the loop. An agent can write more code in an afternoon than I can read in a week, so I do not try to read all of it. I read the tests. They say what the code is supposed to do, and if a behavior matters it gets a test, because a test is the only thing that keeps checking it after I stop looking.

The checks run on their own. Every repo has git hooks: pre-commit runs the fast checks and pre-push runs the full suite, and CI runs the same things again on a clean machine. The setup page lists every hook, where it comes from and how it gets turned on. Done means the hooks and CI pass, not that an agent says it is done.

The tests have to check the thing users actually do. My plugin marketplace, claude-plugins, has two scripts:

  • scripts/check.sh validates the marketplace and fails if any plugin is not an https URL. The pre-commit hook runs it.
  • scripts/test-install.sh adds the checkout as a marketplace and installs every plugin into a throwaway config with ssh turned off. The pre-push hook runs both.

Turning ssh off matters. A plugin listed as a github source clones over ssh, and that fails on any machine without a GitHub key, which is most student laptops. Only a test that installs the plugin the way a student would catches it. CI runs both scripts on Ubuntu and macOS on every push, and once a week, since the plugins live in other repos and can break without a change in this one.

2. Autoupdate from day 1 ​

Anything that gets installed on someone else's machine (a plugin, a command line tool, a desktop app) has to update itself, and that has to be in the first version. Pushing a fix to main does not do much good if people are still running the version from last month. And autoupdate is the one feature you cannot add later: every copy installed before it exists is stuck on that version until someone updates it by hand.

Autoupdate fan-outThree areas. On my Mac or a runner, git push runs the pre-push hook, which runs check.sh and test-install.sh. On GitHub, CI runs the same checks on Ubuntu and macOS, and a failure stops it there. Once they pass, the claude-plugins marketplace serves the new version. On users' machines (mine, student laptops and colleagues), each one with autoupdate on picks it up on its next start.Mac or runnerGitHubUsersgit pushpre-push runscheck.sh andtest-install.shCIUbuntu andmacOSMarketplaceclaude-pluginsrepoMy machinesStudentsColleaguesa failurestops it hereeach updates onits next startAutoupdate fan-outThree areas. On my Mac or a runner, git push runs the pre-push hook, which runs check.sh and test-install.sh. On GitHub, CI runs the same checks on Ubuntu and macOS, and a failure stops it there. Once they pass, the claude-plugins marketplace serves the new version. On users' machines (mine, student laptops and colleagues), each one with autoupdate on picks it up on its next start.Mac or runnerGitHubUsers' machinesgit pushpre-push runscheck.sh, test-install.shCIUbuntu and macOSMarketplaceclaude-pluginsMy machinesStudentsColleagueseach updates on its next startpass
A push to main reaches every machine with autoupdate on.

For my plugins, autoupdate means one setting. Claude Code only updates a third-party marketplace on its own when you ask it to, so the install instructions add this to ~/.claude/settings.json:

json
"extraKnownMarketplaces": {
  "shanep": {
    "source": { "source": "github", "repo": "shanep/claude-plugins" },
    "autoUpdate": true
  }
}

A push to main goes live on every machine with autoupdate on the next time Claude Code starts, including student machines. That is exactly why rule 1 has to hold. Nobody reviews a release before it goes out, so the checks are the review.

Autoupdate brings a few other things with it on day 1:

  • A version number in every build, so I can tell what someone is running when they report a bug.
  • Migrations for saved state. If the tool keeps files on the user's machine, the new version has to read whatever the old version left behind.
  • A way to roll back. Rolling back is a revert pushed to main, and it reaches everyone the same way the bad change did.

3. Agents start the work, not just me ​

A change that waits for a person to type a prompt waits on the slowest step in the loop. A failing build waits until someone notices it, and a bug report waits until someone reads it. So the common triggers start an agent on their own.

Who starts the work, and where it runsTwo areas. On my Mac, a prompt from me starts an agent session, either the live one or one in its own worktree. On GitHub, three triggers start an agent session on a fresh GitHub Actions runner: CI failing on main, an issue labeled for an agent, and a schedule. The labeled issue is built but has not had a real run yet, so it is drawn dotted. Both kinds of session test, review and land their change on main.My MacGitHubA prompt from meAgent sessionlive, or in a worktreeCI fails on mainA labeled issueA scheduleAgent sessionon a fresh runnermainwhere work landsdotted border: built, not run for real yetpushWho starts the work, and where it runsTwo areas. On my Mac, a prompt from me starts an agent session, either the live one or one in its own worktree. On GitHub, three triggers start an agent session on a fresh GitHub Actions runner: CI failing on main, an issue labeled for an agent, and a schedule. The labeled issue is built but has not had a real run yet, so it is drawn dotted. Both kinds of session test, review and land their change on main.My MacGitHubA prompt from meAgent sessionlive, or in a worktreeCI fails on mainA labeled issueA scheduleAgent sessionon a fresh runnermainwhere work landsdotted border: built, not run for real yet
My prompts run on my Mac. Everything else starts an agent on a GitHub Actions runner.

There are 3 triggers, each a GitHub Actions workflow in the repo:

  • When CI fails on main, an agent reads the failing log, fixes the cause and lands the fix through the same checks as any other change.
  • When a user reports a bug and I label the issue agent, a session starts with the issue as its brief, lands the fix the same way and closes the issue.
  • Recurring jobs run on a schedule. This site updates its npm packages every Monday.

All three use one shared agent action, which lives in my plugin marketplace repo, so my repos run the same code. One Claude session makes the change and commits it. A second session reviews the commits, and then the repo's checks run. Neither session gets a GitHub token, so neither one can push. The script pushes after the review and the checks pass, and then it runs CI and waits for the result.

None of this runs on my own machines. Each agent is a GitHub Actions job on a fresh Ubuntu virtual machine that GitHub starts for the job and throws away when it ends. The job checks out the repo, installs Claude Code, runs both sessions and the checks, and pushes. My laptop can be asleep the whole time. I find out from the commit, or from an issue if the agent gave up, and the run's log and summary are in the repo's Actions tab.

The sessions log in with a token from claude setup-token, saved as a secret in each repo, so they count against my Claude subscription the same as a session I start by hand. The runner minutes are free on a public repo. A private repo uses the minutes that come with the GitHub plan, which is why my dotfiles repo runs its CI once a day instead of on every push.

The rules for these agents are the same as for one I start by hand, plus a few more since nobody is watching. They never tag a release. They get 2 tries, and then the agent stops and leaves a note on the issue (or opens an issue) instead of trying forever. An agent never tries to fix its own commit when it breaks CI, since that is how you get a loop. That one gets an issue for me.

4. One controller per repo, every worker in its own worktree ​

Each repo gets one live session, the one I am talking to, called the controller. It runs in the main checkout on main and does very little editing itself. Its job is to turn what I want into a plan, start a worker for each piece, answer the workers' questions, review what they did and land it. Every worker is a background session in its own git worktree on a worktree-<name> branch, so parallel sessions never share uncommitted changes, checkouts or build output, and cannot step on each other. An agent a trigger starts gets a fresh CI runner instead, which keeps it apart the same way. Only the controller ever touches the main checkout.

One controller per repoThree areas show where each session runs. On my Mac: I talk to the controller, the live session in the main checkout on main. It plans, answers and lands. It sends a brief to each local worker, a background session in its own worktree, and the workers report back with SendMessage. In Anthropic's cloud: the controller can send a brief to a cloud worker, which gets a fresh clone and none of my Mac's files, and cannot message back yet. Cloud workers are planned, so that box is drawn dotted. On GitHub: the cloud worker pushes a branch to origin, and the controller pushes main there, which runs CI.My MacAnthropic cloudGitHubMeControllermain checkoutplans, answers, landsLocal workersone worktree eachCloud workerfresh clone, no Mac filescan't message back yetoriginmain, worker branchesand CIdotted border: planned, not built yetbriefSendMessagebriefpush mainbranchOne controller per repoThree areas show where each session runs. On my Mac: I talk to the controller, the live session in the main checkout on main. It plans, answers and lands. It sends a brief to each local worker, a background session in its own worktree, and the workers report back with SendMessage. In Anthropic's cloud: the controller can send a brief to a cloud worker, which gets a fresh clone and none of my Mac's files, and cannot message back yet. Cloud workers are planned, so that box is drawn dotted. On GitHub: the cloud worker pushes a branch to origin, and the controller pushes main there, which runs CI.My MacAnthropic cloudGitHubMeControllermain checkout on mainplans, answers, landsLocal workersone worktree eachCloud workerfresh clone, no Mac filesoriginmain, branches and CIdotted border: planned, not built yetbriefSendMessagebranch
I talk to the controller. It briefs the workers, answers them and lands their work.

Where each one runs ​

WhoRuns onStarted byReports back by
ControllerMy Mac, in the main checkoutMe, with claudeI am talking to it
Local workerMy Mac, in its own worktreeThe controller, with claude --bg -wSendMessage to the controller
Cloud workerAnthropic's cloud, in a fresh cloneThe controller, with claude --cloudA branch on GitHub, plus its transcript
SubagentInside the controllerThe controller, for a search or to watch CIIts answer, inside the controller
Trigger agentA GitHub Actions runnerFailing CI, a labeled issue or a schedule (section 3)A commit, or an issue if it gave up

Workers run on my Mac unless I mark a task for the cloud. A cloud worker gets a fresh clone and nothing else, so it only fits a task that needs no local secrets or files (.env, my Canvas and Google Drive logins, the textbook PDFs), no work network and no Mac-only tool. Anything that runs edutools stays local. A cloud session cannot message another session yet, so the controller checks on it instead of waiting to hear back.

The plan file ​

The controller writes the plan into the repo as .claude/plans/<date>-<slug>.md and commits it, so every worker can read it. The top of the plan is one table, with a row per task:

markdown
| id | task                    | model  | effort | where | after | status         |
|----|-------------------------|--------|--------|-------|-------|----------------|
| T1 | Add the hooks section   | sonnet | medium | local | -     | landed 3f2a91c |
| T2 | Draw the new diagram    | opus   | high   | local | -     | running        |
| T3 | Regenerate modules.json | haiku  | low    | cloud | T1    | todo           |

Below the table, each task gets its own section: the goal, the files to read first, the decisions already made and how to tell it is done. That section becomes the worker's brief. The controller keeps the status column up to date (todo, running, review, landed or blocked) and commits it each time.

Changing the plan is meant to be quick. I edit a cell, or tell the controller "move T3 to opus, high, local", and /dispatch T3 starts that row again. A blank model or effort cell takes the default from section 8.

The plan does not stay in the repo. The last landing commit deletes it, so the tree stays clean, and git history still has every version. Anything worth keeping, like a decision a later session needs, goes into CLAUDE.md or a commit message first. An old plan sitting in the repo would only mislead the next session that reads it.

Landing ​

There are no pull requests. I am the only developer on most of these repos, so a pull request would just be me approving my own work, and an agent should not have to wait on that. Workers commit on their branch and never land. The controller lands them one at a time, in plan order: it rebases the branch onto main, fast-forwards main to it and pushes, which runs the pre-push checks once. Then it removes the worktree and deletes the branch with git branch -d, not -D, so git refuses to delete work that never landed. In the repos that have it, npm run land does these steps and takes a lock, so two sessions never land at the same time.

Parallel workers landing on mainA timeline of the main branch running left to right. Worktree B, a worker, branches off main and makes three commits. Worktree A branches off a little later and makes two. The controller rebases A and lands it on main, then rebases B and lands it after A. Only the controller lands, so two branches never land at the same time.A landsB landsmain: only the controller lands hereworktree Aworktree B, another workerrebase, landrebase, landParallel workers landing on mainA timeline of the main branch running left to right. Worktree B, a worker, branches off main and makes three commits. Worktree A branches off a little later and makes two. The controller rebases A and lands it on main, then rebases B and lands it after A. Only the controller lands, so two branches never land at the same time.A landsB landsworktree Aworktree Bmainlandland
One trunk, with the controller landing each worker's branch one at a time.

The branches only live for an hour or two, so main is never far behind the work. /dispatch starts a worker for each ready row in the plan. /handoff, on the setup page, starts a single worker without a plan.

5. A second agent reviews before anything lands ​

The agent that wrote the code is not the one that reviews it. The controller reviews each worker's branch before it lands it. In the repos that have it, npm run land runs a review of the change before it pushes, and a blocking finding stops the landing. Then there are two choices: fix the finding and land again, or decide the finding is wrong and land with -- --accept-review. Either way it is a decision someone made, not something that slipped through.

6. Automate every step ​

If a feature needs data or a step, the code fetches it or runs it. I do not export a file, click through a web page or paste something in by hand. A manual step is the last resort, and when one is unavoidable the code or the docs say why. This matters even more for agents: a step that needs a person is a step an agent has to stop and wait on.

The best example is my courses. Each one lives in this website's repo as a folder of markdown, and my Canvas tool, edutools, turns it into the Canvas course.

One source, two outputsThree areas. On my Mac, the docs/cs425 folder holds the course as markdown pages, a canvas.toml file and rubric tables, and edutools push turns them into the Canvas course. A git push sends the same folder to GitHub, where GitHub Actions builds it with VitePress and GitHub Pages serves the course website. In Canvas, at Boise State, the push creates the pages, assignments, quizzes, modules, rubrics and due dates.GitHubMy MacCanvas, at Boise StateVitePress buildon every pushCourse websiteGitHub Pagesdocs/cs425/markdown, canvas.tomledutools pushdue dates, rubricsCanvas coursepages, quizzesand modulesgit pushCanvas APIOne source, two outputsThree areas. On my Mac, the docs/cs425 folder holds the course as markdown pages, a canvas.toml file and rubric tables, and edutools push turns them into the Canvas course. A git push sends the same folder to GitHub, where GitHub Actions builds it with VitePress and GitHub Pages serves the course website. In Canvas, at Boise State, the push creates the pages, assignments, quizzes, modules, rubrics and due dates.GitHubMy MacCanvasVitePressevery pushWebsiteGitHub Pagesdocs/cs425/markdownedutoolspush, due datesCanvaspages, quizzesgit push
A course is one folder of markdown, and code builds both places students see it.

One folder builds both places students see the course. VitePress builds the website, and edutools push builds the Canvas pages, assignments, quizzes and modules. Nothing gets typed into Canvas by hand:

  • Due dates come from a bold line at the top of each assignment (**Week 7 · 38 points**) and the term dates in canvas.toml.
  • A ## Rubric table becomes a Canvas rubric.
  • An ## Instructor Notes section gets stripped before anything is published.
  • edutools push --dry-run renders the whole course and writes nothing, so I can check it first.

The same rule goes for the agents themselves. An agent runs the steps instead of handing me a list of commands to paste. The only things it asks about first are releases, anything destructive, and anything that could look like an attack on a network I do not own.

7. Agent config is code ​

My CLAUDE.md, skills and hooks live in a git repo and get committed, synced and pushed like any other code. That config is how I tell an agent what to do when I am not there to tell it, so it gets the same care as the code. A rule that only exists in one session's context is gone when the session ends, and a rule in a file on one machine is not on the others. The setup page covers how the syncing works.

The same goes for the repos themselves: every file is either committed or listed in .gitignore. A file git does not know about is one git clean away from being lost.

8. Tune the model and effort from data ​

Which model to run, and at what effort level, should come from what the sessions actually did, not from a guess.

The tuning loopEverything here runs on my Mac. Every Claude Code session writes a transcript to ~/.claude/projects. claude-tune reads the transcripts and shows where the tokens and dollars went. It suggests fixes, asks before applying any of them, and writes the ones I approve to settings.json: the model, the effort level and other knobs. Those settings shape the next sessions, and the loop repeats.My MacSessionsClaude Code at workTranscripts~/.claude/projectsclaude-tunewhere the tokensand dollars wentMy OKit asks firstsettings.jsonmodel, effort andother knobseach session writes one/tune reads themfixesappliedshape the next onesThe tuning loopEverything here runs on my Mac. Every Claude Code session writes a transcript to ~/.claude/projects. claude-tune reads the transcripts and shows where the tokens and dollars went. It suggests fixes, asks before applying any of them, and writes the ones I approve to settings.json: the model, the effort level and other knobs. Those settings shape the next sessions, and the loop repeats.My MacSessionsTranscripts~/.claude/projectsclaude-tunewhere tokens wentMy OKsettings.jsonmodel and effort/tunefixes
Settings come from what the sessions actually did. All of it runs on my Mac.

claude-tune is my plugin for this. It reads the session transcripts in ~/.claude/projects, shows where the tokens and the dollars went, and offers fixes. A fix can set a key in settings.json, like the model or the effort level, and it asks before applying anything. Each fix it applies gets tracked, so the next run compares the sessions before and after. It is meant to be the source of truth for these settings, run on a schedule like the other triggers in section 3.

Until it has the data, these are the starting guesses for each kind of session. They live in one place, the /dispatch skill, and a row in a plan can override them.

SessionModelEffort
ControllerOpus 5.5medium
Worker on a hard task: design, debugging, a change across many filesOpus 5.5high
Worker on a routine task: following a pattern, course content, a small featureSonnet 5.5medium
Worker on a mechanical task the hooks would catch: regenerating a file, bumping dependenciesHaiku 5.5low
Worker writing in my voiceOpus 5.5medium
Subagents that search or watch CIHaiku 5.5-
Review before landing (/code-review high, in the controller)Opus 5.5high

To try it:

bash
claude plugin marketplace add shanep/claude-plugins
claude plugin install tune@shanep

Then run /tune in any session.

Where this stands ​

PieceStatus
Tests in git hooks and CIIn place
Autoupdate for my pluginsIn place
Background agents in their own worktreesIn place, started by hand
Controller dispatching from a plan filePlanned
Cloud workersPlanned
Workflow skills and agents packaged as one pluginPlanned
A second agent reviewing before landingIn place
Agents started by a CI failure or a scheduleIn place
Agents started by a labeled issueBuilt, no real run yet
claude-tune as the source of truth for model and effortPrototype

None of these rules works alone. Autoupdate means a push reaches everyone, so the tests have to catch problems before the push. The tests are what make it safe to push straight to main without pull requests, and to let an agent land a change while I am not watching. Worktrees, the review and one controller doing all the landing are what make it safe to run several agents at once. The triggers close the loop in the first figure, so it runs without waiting on me.

Released under the MIT License.