DevOps Playbook

Have Claude write your team's morning digest with a read-only agent

A PowerShell script, a read-only claude -p call, a cost cap and a scheduled task. About 15 minutes to try, plus the Windows-only traps that corrupt your commit messages or hide a failed run.

A dark office corridor with a single violet streak of light running down the floor to one lit doorway

It’s 08:55. You open Teams to find out what happened on main overnight, and you do what everyone does: scroll the merge notifications, open four pull requests, read two titles that say “fixes” and give up on a third. Nobody asked you to do this. It’s just the tax for being the person who owns the service.

A script on your Windows machine can pay it for you. Once a morning, Task Scheduler runs PowerShell, which collects the last 24 hours of commits and hands them to Claude Code in a run that can read files but can’t write, run commands or touch the network. The result lands in a file you read with your coffee. It costs pennies to low tens of pennies per run, and the worst case is a useless paragraph.

This piece builds that script, shows what it printed in a scratch repo on Windows 11, and then covers the parts that are different here: the PowerShell habits that silently break it, how to schedule it, and how to authenticate a job that runs when you aren’t looking. It is written for native Windows, not WSL.

The shape of an unattended run

A claude -p call is an ordinary process. Per the Claude Code docs, it exits 0 on success and non-zero when the run fails, it reads stdin, and --output-format json returns the answer in a result field alongside total_cost_usd and a session ID. That’s enough to treat it as one stage in a pipeline.

The design that follows is to do the deterministic work in the script: gather the data, decide whether there’s anything to do, write the output file. Give the agent only the judgement: read this, say what matters. The agent never needs to write anywhere, because your script does the writing. An agent that only reads can be given exactly one tool.

What you need

Claude Code runs natively on Windows 10 1809 or later, installed with irm https://claude.ai/install.ps1 | iex in PowerShell or winget install Anthropic.ClaudeCode. Git for Windows is optional. It provides Git Bash for Claude’s Bash tool, and without it Claude uses its PowerShell tool instead. This job only uses the Read tool, so neither matters here, but you do need git on your PATH for the script itself.

I’d run the script under PowerShell 7 (winget install Microsoft.PowerShell), though it also works in the Windows PowerShell 5.1 that ships with Windows. The two differ in ways that bite, and I’ll point at each one.

On my machine claude is C:\Users\<you>\.local\bin\claude.exe, which is already on the user PATH after the native install. claude auth status prints "loggedIn": true when you’re signed in.

The script

Save this as digest.ps1 in the repo you want to watch, or anywhere you like. It works from inside any repository, because it starts by moving to the repo root.

# Morning digest: what changed in the last day, written by Claude in a read-only run.
# Works in Windows PowerShell 5.1 and PowerShell 7.
param([string]$Since = "24 hours ago")
$ErrorActionPreference = "Stop"

# Native commands read and write bytes. Make both directions UTF-8 so non-ASCII commit messages survive.
$utf8 = [System.Text.UTF8Encoding]::new($false)
[Console]::OutputEncoding = $utf8
$OutputEncoding = $utf8

Set-Location (git rev-parse --show-toplevel)
$out = Join-Path "digests" ((Get-Date -Format "yyyy-MM-dd") + ".md")
New-Item -ItemType Directory -Force digests | Out-Null

$log = (git log --since="$Since" --stat --no-color) -join "`n"
if (-not $log) { Write-Output "nothing changed since $Since"; exit 0 }

$json = $log | claude -p `
  --tools "Read" `
  --permission-mode dontAsk `
  --max-turns 6 `
  --max-budget-usd 0.50 `
  --no-session-persistence `
  --output-format json `
  --append-system-prompt "You write a short morning digest for engineers. Plain prose, no headings. Name files. Flag anything that looks risky. Do not speculate beyond the log." `
  "The git log on stdin covers the last day. Summarise it. Read the changed source files if the log isn't enough."

# PowerShell does not stop on a failing native command. Check explicitly.
if ($LASTEXITCODE -ne 0) { Write-Error "claude exited with $LASTEXITCODE"; exit 1 }

$run = $json | ConvertFrom-Json
if ($run.is_error -or $run.permission_denials.Count -gt 0) {
  Write-Error "run reported an error or a permission denial; keeping yesterday's digest"
  exit 1
}

[System.IO.File]::WriteAllText((Join-Path (Get-Location) $out), $run.result, $utf8)
"cost_usd={0} turns={1}" -f $run.total_cost_usd, $run.num_turns
"wrote $out"

Run it from a PowerShell prompt with .\digest.ps1, or .\digest.ps1 "3 days ago" for a longer window. If PowerShell refuses with an execution policy error, run it as pwsh -ExecutionPolicy Bypass -File .\digest.ps1 for a first try, and decide separately whether you want to change the policy for your user.

Here’s what each choice does.

The early exit on an empty log matters more than it looks. A quiet day should cost nothing, not a model call that politely says nothing happened.

The log goes in on stdin, so the agent doesn’t need a shell to fetch it. The docs use the same trick for a diff-based typo linter, and note that piping means Claude doesn’t need Bash permission to read the input. Stdin is capped at 10MB, which is plenty for a day of commits and a hard limit on a monorepo with a month of history. If you hit it, narrow -Since or drop --stat.

--tools "Read" is the important line. It restricts which built-in tools the agent has at all, so Bash, PowerShell, Edit and Write aren’t merely unapproved, they’re absent.

--permission-mode dontAsk settles what happens if the agent reaches for anything else. The docs describe it as denying every call that would otherwise prompt, which is what you want when nobody is awake to answer.

--max-turns 6 and --max-budget-usd 0.50 are the brakes. The budget uses Claude Code’s client-side cost estimate, which the docs say can differ from your bill. I’ll show below why it’s a fuse and not a hard limit.

--no-session-persistence stops each morning’s run piling up as a resumable session on disk.

The script writes the file, not the agent. If the run fails, the exit checks stop before anything overwrites a good digest.

What it printed

I built a scratch repo on Windows 11 with two commits: a cart total function, then a second commit titled “Add café discount → cart total, add slugify” that added an optional discount argument and, unrelated, a slugify helper. Then I ran the script with PowerShell 7 and Claude Code 2.1.291, signed in with a subscription:

cost_usd=0.1134 turns=3
wrote digests\2026-10-08.md

The file began:

Two small commits landed on main in the last day, both touching only `src/cart.py` and `src/util.py`.

The first, "Add cart total" (3ba0739), added `total(items, discount=0)` to `src/cart.py`. [...]

The second (12c1a6c) is titled "Add café discount → cart total, add slugify". It changed `src/cart.py`
by 2 lines added and 2 removed, and added `slugify(s)` to `src/util.py`. [...]

Things worth a look:

- Discount is unchecked. `total` in `src/cart.py` doesn't validate `discount`. A value above 1
  gives a negative total, and a negative value raises the price. [...]
- Discount type is undefined. `discount` is a fraction, not a percentage. [...]
- Money is multiplied as floats. The code doesn't round or use `Decimal`. [...]
- `slugify` is minimal. It only handles spaces. Accents (the commit message mentions "café"),
  punctuation and repeated spaces pass through unchanged. [...]
- No tests. Neither commit touched any test files.

I trimmed sentences (marked [...]) and removed the bold markers the model put on the bullet labels. Three turns: one for the log, then reads of the two changed files. The unvalidated discount, the float arithmetic and the missing tests are real observations about my toy code, found by reading the source, not just the log. That’s the reason to give the agent Read and not only the diff.

The “café” and the arrow in the commit message came through intact. That’s not an accident, and it’s the first Windows trap.

Two honest caveats about the output. First, it got something wrong. It said the first commit added total(items, discount=0). That commit only had total(items); the discount argument came in the second. The agent read the files as they stand now and attributed the current signature to the older commit. A digest is a prompt to go and look, not a record to trust, so skim the real log alongside it. Second, the model produced a bulleted list despite “no headings”, so don’t build a parser on its format.

Cost: this run came to about 11 cents by the client-side estimate. On a quiet repo that’s mostly the fixed cost of the request. A busy day with more files to read will cost more, which is what the budget cap is for.

Three PowerShell traps

These are the reasons not to translate a bash script line by line.

Encoding turns commit messages into question marks

Windows PowerShell 5.1 defaults $OutputEncoding, the encoding it uses when piping text into a native program, to ASCII. I tested with the same commit message, piped from git log into a small program that prints exactly what it receives on stdin:

Windows PowerShell 5.1, no fix:           "Add caf? discount ? cart total, add slugify"
Windows PowerShell 5.1, with the 2 lines: "Add café discount → cart total, add slugify"
PowerShell 7, no fix:                     "Add café discount → cart total, add slugify"

In 5.1 the accent and the arrow became question marks before Claude ever saw them. The fix is the two lines near the top of the script that set [Console]::OutputEncoding and $OutputEncoding to UTF-8 without a byte-order mark. PowerShell 7 already defaults to UTF-8, so they’re harmless there. Keep them so the script behaves the same under either.

The same applies on the way out: write the digest with [System.IO.File]::WriteAllText and an explicit UTF-8 encoding. Set-Content -Encoding utf8 in 5.1 adds a byte-order mark that some tools show as a stray character.

Native command failures don’t stop the script

In bash, set -e aborts when a command fails. $ErrorActionPreference = "Stop" in PowerShell does not do that for native programs like claude and git: they set $LASTEXITCODE, and the script carries on. So the script checks $LASTEXITCODE after the call. Without it, a failed run would flow into ConvertFrom-Json and you’d find out when the digest was empty.

I hit this from the other direction while testing. With a deliberately tiny --max-budget-usd 0.001, the call returned exit code 1, a JSON object with is_error true and subtype of error_max_budget_usd, and no result property at all. Reading $run.result there would have written an empty file. The script’s two checks, exit code and is_error, turn that into a loud failure and leave yesterday’s digest alone.

An empty argument disappears in Windows PowerShell 5.1

If you want an agent with no tools at all, the docs say --tools "" disables the built-in ones. I checked how each PowerShell passes an empty string to a native program, using a tiny program that prints its arguments:

Windows PowerShell 5.1:  ["--tools","--next"]
PowerShell 7:            ["--tools","","--next"]

In 5.1 the empty string is dropped, so --tools would swallow the next argument as its value. If you must support 5.1, don’t rely on an empty argument. Use --tools "Read" or run under PowerShell 7.

Put it on a schedule

Task Scheduler is how Windows runs a job on a timetable. Its best feature for this job is one setting: -StartWhenAvailable runs a missed task as soon as the machine is back, so a laptop that was asleep at 08:30 still produces a digest when you open the lid.

$action = New-ScheduledTaskAction -Execute "pwsh.exe" `
  -Argument '-NoProfile -File "C:\src\payments-api\digest.ps1"' `
  -WorkingDirectory "C:\src\payments-api"
$trigger = New-ScheduledTaskTrigger -Weekly `
  -DaysOfWeek Monday,Tuesday,Wednesday,Thursday,Friday -At 8:30am
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable `
  -ExecutionTimeLimit (New-TimeSpan -Minutes 10)
Register-ScheduledTask -TaskName "Morning digest" `
  -Action $action -Trigger $trigger -Settings $settings

Registered without an explicit principal, the task should run as the current user, and you can confirm “Run only when user is logged on” in the Task Scheduler window. That setting means there’s no password to store. If you want it to run while you’re logged off, Task Scheduler will ask for credentials, and I’d weigh that against how much you need it. The 10-minute execution limit stops a hung run from sitting there all day.

I built those three objects and read the properties back, but I did not register a task or watch one fire on a schedule. Test it the first time by running it by hand and checking the result:

Start-ScheduledTask -TaskName "Morning digest"
Get-ScheduledTaskInfo -TaskName "Morning digest" | Select-Object LastRunTime, LastTaskResult

A LastTaskResult of 0 means the script exited 0. To see the output and any errors, have the script keep its own log. Add this line right after $ErrorActionPreference = "Stop":

Start-Transcript -Path "$env:USERPROFILE\.digest.log" -Append | Out-Null

I checked in a scratch script that a transcript started this way captures both normal output and Write-Error text when run with pwsh -NoProfile -File. Keep the cost_usd lines in that log: a week of them tells you whether the cap is sensible.

If you want the repo current before it summarises, add a git pull -q --ff-only line before the git log line.

Authentication for a job you aren’t watching

Because the task runs as you, it can use your normal Claude Code login. On Windows, per the docs, that login is stored in %USERPROFILE%\.claude\.credentials.json, inheriting the access controls of your profile folder. Claude Code warns three days before a login expires, and a session that outlives its login stops making progress until you sign in again. For a job nobody watches, that’s the failure mode to plan for.

The alternative is the long-lived token. Run claude setup-token, approve it in the browser, and copy the token it prints (it isn’t saved anywhere). Then set it for your user so scheduled tasks see it:

[Environment]::SetEnvironmentVariable("CLAUDE_CODE_OAUTH_TOKEN", "paste-the-token-here", "User")

The docs describe it as a one-year token for scripts, requiring a Pro, Max, Team or Enterprise plan, and limited to model requests. The tradeoff is where it lives: a user environment variable is stored in your registry hive, readable by anything running as you. That’s the same exposure as the credentials file, but it’s also visible to every process you start. Rotate it if the machine is shared. I did not test the token path here, since my runs used my existing login.

Make it yours

Three variations on the same skeleton.

A pre-push summary. Swap the log for git diff origin/main...HEAD and call the script from a pre-push hook. On Windows, Git for Windows runs hooks with its bundled sh, so the hook file is a tiny shell script that calls PowerShell. I tested the wiring with a hook containing:

#!/bin/sh
pwsh -NoProfile -File "C:/src/payments-api/prepush-summary.ps1"
exit 0

A git push to a local bare remote printed the hook’s output before pushing. That ends with exit 0 on purpose. Don’t let a hook block the push on a model’s opinion. An agent that fails closed on opinion trains everyone to use --no-verify.

A CI log explainer. Pipe the last 200 lines of a failed build log in, with a prompt asking for the likely root cause in two sentences and the file to look at. It needs no tools, so run it under PowerShell 7 and pass --tools "".

A weekly dependency read. Collect dotnet list package --outdated or npm outdated --json in the script, pipe it in, and ask which updates cross a major version and which are worth reading first. The agent ranks, it doesn’t install. Letting it open changelogs means network access, which is a separate permission decision, and not something to add by turning on Bash.

What goes wrong

The silent apology. Under dontAsk, reads inside the working directory still run, but anything that would need approval is denied, and that includes reading a file elsewhere on disk, such as a temp file your own script wrote. The agent can’t ask, so it may answer with an apology, and the job may still look like a success. I didn’t reproduce that on this machine, but the script guards against the outcome by checking is_error and permission_denials, which I confirmed are fields in the JSON the CLI returns here. It’s also why the log goes in on stdin, inside the process, and not as a file in %TEMP%.

A cap that isn’t a cap. The budget check works on an estimate, and a small cap can be exceeded before it trips. A one-word prompt cost me anywhere from about $0.006 to about $0.11 depending on whether the prompt cache was warm. With --max-budget-usd 0.001 the run still reported a cost of $0.1059 before failing with Reached maximum budget. So set the cap above the fixed cost of one request, treat it as a fuse, log cost_usd daily and glance at the log weekly.

Project config runs without asking. The docs say that without --bare, a -p session runs the hooks in a project’s .claude/settings.json and connects the servers in its .mcp.json, even in a folder you’ve never trusted, because -p shows no trust dialog. If the scheduled task runs in a repository you don’t control, anyone who can land a commit can add a hook that executes at 08:30 on your machine. For repos you own and review, that’s fine. For others use --bare, which skips hooks, MCP servers, skills, plugins, auto memory and CLAUDE.md. The catch is that --bare doesn’t read OAuth credentials, including the long-lived token, so you need an ANTHROPIC_API_KEY and API billing. I didn’t test the bare path here.

No operating-system sandbox. The docs say sandboxing isn’t supported on native Windows, and is supported on WSL 2. On native Windows, the restriction is therefore the tool list plus dontAsk, with no second wall behind it. For a read-only summariser that’s a reasonable position, and it’s one more reason not to give this job Bash or a write tool to be helpful.

Prompt injection through the data. The agent reads commit messages and source files that other people wrote, and a hostile commit message can contain instructions. One read-only tool and no network means the worst a successful injection does is make the digest wrong.

When not to bother. If your team is three people and main gets four commits a day, git log --oneline already is the digest. This earns its keep when the volume is high enough that you skip reading, and when the interesting part is what the code does and not what the commit message says.

First 15 minutes

  1. Open PowerShell in a repo you own that has recent history. Check git --version and run claude auth status.
  2. Save the script as digest.ps1 and run .\digest.ps1 "3 days ago".
  3. Read digests\<today>.md against the real git log. Look for anything it got wrong.
  4. If the digest tells you something you’d have missed, build the scheduled task above and fire it once with Start-ScheduledTask. If it just restates the commit messages, tighten the system prompt first: ask for risks and untested changes only, and drop the summary.