Cape Cod, Massachusetts 41°39'20.7"N 70°09'53.0"W
Back to blog AI

Deterministic by design: scheduled routines with Claude Code

Deterministic by design: scheduled routines with Claude Code

There’s an old thought experiment that says if you put enough monkeys in front of enough typewriters for enough time, one of them will eventually produce the complete works of Shakespeare.

AI agents seem to operate on the opposite principle.

Ask the same AI agent the same question a dozen times, and you may get a dozen different answers. Sometimes the differences are minor. Sometimes they’re substantial. Occasionally, one response is brilliant while another completely misses the mark. Rather than converging on a single inevitable outcome, AI systems often produce a spectrum of possibilities, each shaped by probabilities hidden beneath the surface.

For organizations evaluating AI agents, this variability isn’t a bug. It’s a fundamental characteristic that must be understood, measured, and managed.

That variability can be useful when you want ideas, options, or creative exploration. But it becomes a liability when you need repeatable decisions, reliable workflows, or consistent outputs. The challenge is not simply learning how to use AI. It is learning how to make AI behave more deterministically.

The experiment

I wanted to experiment with implementing deterministic routines using Claude Code. I decided to tackle a problem I have as a developer, which was to update all of the things that I never update, my developer tooling!

We have a Mobile Device Management (MDM) provider that keeps all our apps and operating systems up to date. But packages installed using Homebrew, Composer, npm, etc all only get updated when things break. I decided to be more proactive about that.

I didn’t want to leave it to a coding agent to decide what to update, so I worked with an agent to build a bash script that would systematically update and report the same way every time it was run.

After the script is run via a scheduled task Claude Code reads the log, works out what actually happened, and writes me a report I can act on. When something’s risky, it hands me the exact command instead of running it.

The split: determinism vs. judgment

Two things are true about routine maintenance, and they pull in opposite directions:

  1. The actions should be deterministic. I want the same upgrades run the same way every time, with no model creatively deciding to rm -rf something. A bash script is perfect for this. It’s auditable, it’s diffable, and it does exactly what it says.
  2. The interpretation requires judgment. A raw log is 400 lines of progress bars and exit codes. Turning that into “GitHub rejected your auth token, here’s the one-liner to fix it” is genuinely hard, and it’s exactly what an LLM is good at, and I am terrible at!

So I split them. The script applies changes and emits a structured log. The model reads the log and reports.

Safe vs. risky tier

The script sorts every upgrade into one of two tiers:

  • Safe tier, applied automatically. Homebrew formulae, npm/pnpm/Composer/pipx globals. Low blast radius, easy to roll back.
  • Risky tier, reported only, never applied. GUI casks (my MDM may own them), language runtimes, DDEV, anything that’s a major-version bump. The script prints the exact command to apply each one and stops there.

That’s the entire safety model. An unattended agent can run every week and the worst it can do is upgrade a formula. Anything that could break my environment is surfaced as a copy-paste command for a human who’s awake and paying attention.

########## RISKY TIER (report only) ##########
--- DDEV ---
  Installed: ddev version v1.25.2
  Installed outside Homebrew. To upgrade:
    ddev poweroff
    curl -fsSL https://ddev.com/install.sh | bash

A note why DDEV is considered risky. It’s not DDEV at all! Updating DDEV is risky because it needs to be poweroff-ed, and I could have other agents running on routines working on DDEV projects. Powering off would temporarily clobber their work, and we don’t want that! But it will report, and tell me to update when I can ensure no instances are running.

The prompt explained

Stripped down, the instructions are:

  • Run the script.
  • Read the SUMMARY block, then scroll up to the relevant sections to find the cause of anything that failed or was skipped.
  • Report in a fixed structure: result line, what upgraded, failures with one-line fixes, skips and benign noise, and the risky tier with copy-paste commands.
  • If the script dies before producing a SUMMARY block, don’t invent a report. Surface the last section that ran and its error.
  • Keep it skimmable. Run no other commands.

A fixed output skeleton matters more than it sounds. Run over run, the reports are diffable: I can see at a glance that today’s failure is the same one as yesterday’s, or that a skip finally cleared.

Once I set this up in a routine, I had to run it a few times as you normally have to. Adding permissions, fine tuning the script, then the prompts, and that led me to the next realization.

Tune the script and the prompt together

The prompt is tightly coupled to the script’s log format. It greps for [FAIL], [SKIP], and --- <label> --- markers, and it knows the shape of the SUMMARY block. That coupling is a feature, not a smell. I ran into a pnpm issue running the routine that exposed a gap, I fixed it in both places: the script learned to classify a “nothing to upgrade” exit as benign rather than failed, and the prompt learned to surface skips distinctly from failures.

Treat the deterministic tool and the interpreting prompt as one unit. Every time the report misleads you, the fix is usually a small change to one or the other: sharper classification in the script, or sharper instructions in the prompt.

Takeaways

  • Deterministic actions, model-driven interpretation. Don’t ask the model to decide what to upgrade. Ask it to explain what the upgrade did.
  • Tier by blast radius. Apply the safe stuff; report the risky stuff with exact commands. An unattended agent should never be able to break your machine.
  • Cause over code. “Token expired, here’s the fix” beats “exited 100” every time.
  • Two modes are a feature. Cautious and report-only when running alone; a willing collaborator when you’re there to supervise.
  • Co-design the script and the prompt. They’re one system. Tune them together.

I shared the code at thejimbirch/local-dev-maintenance if you want to use it as an example for your local dev environment. Of course, if you don’t need the natural-language recap, a launchd plist firing the same script weekly is the most deterministic option and can run without AI.