Build a Scheduled Repository Reviewer
Introduction
Turn recurring review toil into an unattended job you control. Even a well-reviewed repository changes between passes, and the changes can carry issues no earlier review flagged. This recipe hands that job to an agent.
On a schedule you choose, the agent sweeps your repository and returns a verdict and findings that a program can read. Continuity between runs comes from resumed sessions, the Agent SDK feature at the center of this recipe. Resuming is one design choice for a scheduled reviewer. A resumed session carries the whole prior pass forward, with no separate store to build. Each run begins with the findings and everything the reviewer read already loaded rather than starting empty. The section When to resume and when to start fresh maps each choice to the jobs it fits.
The repository gets one full baseline review on the first run. Every later run resumes the previous run's session and starts from the findings the agent already reported. You read a short follow-up each cycle, and every follow-up proves the continuity by echoing the prior review's findings. This notebook runs the first two cycles by hand and ends by putting the same reviewer on a schedule.
What you'll learn
By the end of this recipe, you'll be able to:
- Run a bounded, read-only review agent unattended, under
permission_mode="dontAsk"withmax_turnsandmax_budget_usdset for a scheduler - Prove continuity across runs by resuming with
ClaudeAgentOptions(resume=...)and asserting theRESUME-LINKfields fromoutput_formatschema replies, with a fixed finding moving toresolvedand a newly planted bug caught - Put the reviewer on cron with
scheduled_review.py, greppableVERDICTand completion lines, and a narrowexcept ResultErrorpath that exits non-zero
When to use this recipe
Use this recipe for a recurring review that remembers its previous pass, such as a dependency audit or a docs-freshness sweep. You run the scheduler yourself, and Claude Code provides the read tools, permission rules, and resumable sessions.
If you don't want to run the scheduler or the infrastructure, one of these managed options may fit better:
- Claude Code routines(opens in new tab) fit solo developers who want their own GitHub repositories reviewed on a schedule. You configure a prompt, repositories, and connectors once, and Claude runs the routine on managed infrastructure. Routines are in research preview and run on Claude subscription plans.
- Managed Agents(opens in new tab) fits when you want managed hosting configured through the Claude API. It hosts the agent loop and its workspace, and a scheduled deployment(opens in new tab) starts a session on a cron cadence you set. Managed Agents is in beta, and the repository under review enters the hosted session workspace.
If you want to run the scheduler and the review environment yourself, use this recipe.
Prerequisites
Before following this guide, ensure you have:
Required Knowledge:
- Python fundamentals: comfortable with async/await, functions, and basic data structures
- Basic understanding of agentic patterns: we recommend reading Building effective agents(opens in new tab)
- Basic familiarity with the Agent SDK's
query()function: see the one-liner research agent(opens in new tab) if the SDK is new to you
Required Tools:
- Python 3.11 or later
- claude-agent-sdk 0.2.140 or later, the release that adds the typed
ResultErrorthis recipe catches on its failure path - An Anthropic API key (get one here(opens in new tab))
Run this notebook from its own directory, so the files it writes land beside it.
Cost note: a full pass of this notebook's live runs typically costs about a dime in API usage, and the companion script's runs are similar. Each run prints its own cost in its summary line.
Setup
First, install the required dependencies:
Note: Ensure your .env file contains:
Load your environment variables:
Create a sample repository to review
Create a repository with known defects so that you can check what the reviewer reports. The code below plants a small service in demo_repo/ for the reviews in this notebook to work against. Each file's source appears as a string constant that the code writes to disk. The files are never imported, so the bugs stay inert, and the Clean up section at the end removes demo_repo/ when you finish. The service's two application files each carry one real bug:
app/config.pybuilds its config by copying in the entire environment, then prints the result. Together, those two lines log every environment variable the process holds, secrets included.app/math_utils.pycomputes an average by dividing bylen(values). Hand it an empty list and it raisesZeroDivisionError.
sample repo: ['README.md', 'app/config.py', 'app/math_utils.py']
Define what a review returns
With the sample repository in place, the next piece is the review's answer contract. Every consumer of a scheduled review is a program, so the reviewer answers with a JSON object read by field. output_format takes a JSON schema, and the reply arrives on ResultMessage.structured_output already shaped to it. Each review carries a review id, a verdict from a closed set, and findings that each carry an id of their own.
The finding ids drive the continuity proof. The follow-up review names the previous run's ids, showing the reviewer carried those findings forward.
Extend the schema for the follow-up
The recipe runs two reviews, and the second needs a way to point back at the first. previous_review_id and previous_finding_ids point back at the first review, and resolved lists the previous finding ids that no longer apply. Merging the first schema keeps the shared half in one place, so the two schemas can't drift apart.
previous_review_id and previous_finding_ids carry the continuity assertion. When the reviewer carries the earlier review forward, the fields come back holding the first run's review id and finding ids. When the reviewer starts over, the fields say so, and you find out from that run's log line.
follow-up required fields: ['review_id', 'verdict', 'findings', 'previous_review_id', 'previous_finding_ids', 'resolved']
Configure the bounded, read-only reviewer
With both schemas defined, the agent configuration is next. These options fit an agent that runs unattended:
tools: decides which built-in tools exist for the agent. Restricting the list toRead,Glob, andGrepleaves the reviewer no write tool.allowed_tools: decides which tool calls run without a permission prompt. TheReadgrant is scoped to the repository path, and underdontAska read outside it is denied as a tool error.GlobandGrepenter as bare grants that approve the whole tool, and thedeny_reads_outside_repohook is what confines them. TheStructuredOutputentry in the run summaries comes fromoutput_formatrather than this list, and it only carries the reply.permission_mode="dontAsk": never prompts and denies anything not pre-approved. Theautopermission mode also runs without routine prompts, approving or denying each call with a model classifier at runtime. This reviewer usesdontAskto keep its tool surface fixed to the allow rules.model: pins the review to a named model. Without a pin the SDK uses the environment's default model, which can change over time. The pin keeps a scheduled job's cost and review behavior where you set them until you choose to change them.strict_mcp_config=True: limits the session to the MCP servers this configuration declares, which is none. Without it, MCP servers configured on the machine or account attach to the session, and their tool definitions enter every request.max_turnsandmax_budget_usd: cap the agentic loop and the spend. Exceeding either ends the run with a terminal error result,error_max_turnsorerror_max_budget_usd, which the SDK raises to your code as aResultError. The run stops once spend has already exceeded the budget, so a cycle can end over the cap.hooks: registersdeny_reads_outside_repoas aPreToolUse(opens in new tab) callback. The callback sees everyRead,Grep, andGlobcall and denies any whose path resolves outside the repository, and any Glob pattern that starts with/or~, carries a..segment, or contains a{brace.setting_sources=[]: keeps the run off the machine's filesystem settings. A scheduled review then behaves the same on your laptop and on the box that runs the cron job. The empty list also keeps the reviewed repository's own.claude/settings.jsonandCLAUDE.mdout of the session. The reviewer'scwdis that repository, and a repository you don't control should not configure its own reviewer.output_formatandresume:output_formatasks for a reply matching the schema you pass in, andresumecarries continuity between runs. PassNoneon a cold run and the previous run's session id on a scheduled follow-up.
The safeguards these options implement
The option set above implements several safeguards for running an agent unattended. The read-only tool grant, the MCP and settings isolation, and the turn and budget caps bound what a run can do. The scheduled session reset limits how long a session's context persists.
Reads are scoped twice. The allowed_tools grant confines Read to the repository path in both the notebook and the script, and reads still follow the job user's own file access. Run the deployment steps under a user whose access is limited to what the review needs.
Claude Code applies Read rules to Grep and Glob best-effort. deny_reads_outside_repo makes the read confinement hold without depending on that coverage.
The companion script installs the same hook, and the hook already denies reads of the service directory. The script also adds a disallowed_tools rule as an independent second layer, blocking the Read tool on that directory, where the env and session files live. The rule is set only when the service_dir parameter is passed, and the notebook's calls leave it unset.
The same safeguards protect against prompt injection, covered in Protect against prompt injection(opens in new tab). With tools restricted to Read, Glob, and Grep, the session has no shell and no network tool, and content the reviewer reads has nowhere to go but the reply itself. That is why the answer key checks replies in this notebook, and why review.log gets the same handling as the repository it reviews.
Read the reply with fallbacks
The reply arrives already validated against the review schema. The two readers below, verdict_of and finding_ids, repeat that check at the point of use for verdict and findings, the two fields the code acts on. Each reader matches the shape its field needs and falls back to a fixed default otherwise. The cell also defines RunOutcome, the container the runner fills, and string_items, the list reader the continuity checks use.
A missing or unrecognized verdict reads as concerns, the state that draws attention. A findings list that arrives in any other shape yields no ids rather than a partial guess. The code ends by exercising both defaults on malformed input, so the output below shows the fallbacks.
fallbacks: concerns []
Build the review runner
The schemas, options, and readers come together in the runner. The runner streams the messages, prints what the reviewer says, lists the tools the reviewer reaches for, and keeps the final ResultMessage's session_id and structured_output.
The report at the end of each run prints the verdict and then each finding on its own line, read through the reply readers. The per-block print is capped, with a visible marker on any cut line, and the cap keeps one long reply from flooding a scheduled log. A structured-output run usually emits little prose, and a run may narrate a line or two before its tool calls. In the recorded runs, both cycles stream only tool calls. Either way the findings in the log come from the reply's payload rather than from streamed text.
This same runner ships beside the notebook as scheduled_review.py(opens in new tab), the companion you deploy when you put the reviewer on a schedule.
A run that fails exits non-zero and never prints its completion line. Both failure classes end that way:
- Exceeding a bound:
max_turnsormax_budget_usdends the run with a terminal error result and a non-zero CLI exit. The SDK surfaces that result to your code as a raisedResultErrorcarrying the result's subtype. - Failing with no terminal result: a CLI process killed mid-run or a connection that never opens raises its own exception type, which escapes the typed catch, so no failure line prints.
Each review run catches ResultError around its run_review call, prints one REVIEW-CYCLE-INCOMPLETE line, and re-raises. The notebook stops on the failure instead of continuing to a success line. The induced run in Prove the failure path catches the ResultError without re-raising it. Triggering the failure is that cell's point.
Run the cold review
The two live runs start here as the schedule's first two cycles, with the interval collapsed. The first review runs cold. With no resume set, the run reads the whole repository and answers with the first-review schema. The run ends with a verdict, a set of finding ids, and the session_id the next run resumes.
Expected output: a RUN-1 summary line with subtype=success, then VERDICT: concerns with finding lines covering both planted defects under it. One defect can split into more than one finding, though the recorded run prints one per planted defect. The session id in the summary line is what run 2 resumes.
A non-zero denials count in the summary line is the read confinement working. The hook denies root-anchored Read attempts, such as /app/config.py, and the reviewer retries with in-repository paths. A Glob pattern using brace expansion, such as **/*.{py,md}, lands in the same count. Whether a run shows any denials depends on which paths and patterns the reviewer tries first.
Denials cost retries inside the turn budget. If your repository's reviews lean on brace patterns, adapt the hook to expand them and run the same checks on each expansion, instead of denying the pattern outright.
RUN-1 session=2d83a97b-27ff-403e-a067-2fb5b6474d9b subtype=success turns=10 denials=3 cost_usd=0.0278 tools_attempted=Glob,Read,StructuredOutput VERDICT: concerns F1 app/math_utils.py: divide() does not guard against denominator being zero, and average() calls divide(sum(values), len(values)) which raises ZeroDivisionError when values is an empty list. F2 app/config.py: load_config() copies the entire process environment (os.environ) into the config dict and prints it via print(), which can leak secrets/credentials (API keys, tokens) into logs.
Change the repository between cycles
In deployment, a change that lands between cycles either fixes a finding the reviewer reported or introduces a problem the reviewer has not seen. The code below makes one change of each kind before the follow-up runs.
The code fixes the zero guard in app/math_utils.py, the finding that should come back resolved. It plants a new file, app/retry.py, whose retry loop has no limit, the problem the follow-up should catch as new. The new problem sits in a file run 1 never read. Catching it proves the follow-up looked at the repository as it stands now instead of answering from the files it already knew.
repository now: ['README.md', 'app/config.py', 'app/math_utils.py', 'app/retry.py']
Run the resumed review
The next scheduled run passes the first run's session_id as resume and asks for the follow-up schema. The prompt's first instruction is to list the repository's files again. Without that step, a resumed agent can answer from the files it read in run 1 and never notice that app/retry.py is new. The code below checks the reply against run 1 and prints a RESUME-LINK line carrying the continuity proof in three fields:
same_session: the two runs share one session to show that the resume itself workedprior_review_id_echoed: the follow-up named the first review's idrecalled_findings: how many of the first run's finding ids the follow-up carried back
When all three hold, the reviewer answered from the review it already did. When one of the three fails, the code prints a RESUME-LINK-BROKEN marker on its own line. The reply also lists resolved, the previous finding ids that no longer apply.
Expected output:
- a
RESUME-LINKline withsame_session=True,prior_review_id_echoed=True, and a fullrecalled_findingscount - a
resolvedlist holding the zero-guard finding ids - findings that keep the config leak and add the new retry bug
- the
REVIEW-CYCLE-COMPLETEline to close the cycle
RUN-2 session=2d83a97b-27ff-403e-a067-2fb5b6474d9b subtype=success turns=7 denials=0 cost_usd=0.0339 tools_attempted=Glob,Read,StructuredOutput VERDICT: concerns F2 app/config.py: load_config() still copies the entire process environment (os.environ) into the config dict and prints it via print(), which can leak secrets/credentials into logs. F3 app/retry.py: fetch_with_retry() retries in an unbounded infinite loop with a fixed 1-second sleep and no max attempt limit or exponential backoff, risking indefinite hangs/resource exhaustion if the underlying fetch keeps raising ConnectionError; other exceptions are also not handled. RESUME-LINK run1_session=2d83a97b-27ff-403e-a067-2fb5b6474d9b run2_session=2d83a97b-27ff-403e-a067-2fb5b6474d9b same_session=True prior_review_id_echoed=True recalled_findings=2/2 resolved=['F1'] REVIEW-CYCLE-COMPLETE runs=2 verdicts=concerns,concerns
Check the findings against the planted changes
The repository's bugs and the between-cycle changes are known in advance, so they serve as an answer key for both runs.
Check run 1's report
Run 1 reports at least the two planted bugs:
app/config.py: the loader copies the whole environment into the config and prints itapp/math_utils.py:dividehas no zero guard, soaverage([])raisesZeroDivisionError
config["environment"] = dict(os.environ) is a single innocuous line. The leak exists because the print on the next line publishes it and environment variables are where deployments keep their secrets. Reporting it means the reviewer connected those three facts across the file. The zero guard tests only that the reviewer reads carefully.
Check the follow-up's delta
The follow-up's report splits the between-cycle changes into three parts:
- the config leak stays in
findingswith its earlier id - the fixed zero guard's id moves to
resolved app/retry.py's unbounded retry arrives as a new finding
prior_review_id_echoed=True means the resumed reviewer named the id it was given in run 1, and recalled_findings counts how many of run 1's finding ids came back.
Expect variation between runs
Model output varies between runs. The reviewer may:
- phrase the findings differently
- assign different ids
- split one planted bug into several findings (the zero guard can arrive as one finding or as separate
divideandaveragefindings) - report additional lower-severity observations
Ids in resolved follow what the change fixed, however the reviewer split them. However phrased, a correct first run reports both planted bugs, a correct follow-up reports the three-part delta, and the RESUME-LINK fields carry the continuity proof.
Change something else in demo_repo/ and run the follow-up again to watch the delta shift. The notebook pins its baseline to run 1, and a repeated follow-up reports prior_review_id_echoed=False and prints the RESUME-LINK-BROKEN marker even though the resume worked. The companion script saves a new baseline after every run, and a repeated follow-up there keeps its link intact.
Prove the failure path
The runner section describes what a failed run looks like. The cell below triggers one deliberately, running a fresh review against the same repository under max_turns=1, a cap one full review cannot fit. The run ends with a terminal error result, the SDK raises it as a ResultError, and the except block prints the failure line carrying the result's subtype and terminal_reason.
Expected output: a REVIEW-CYCLE-INCOMPLETE line with stage=induced subtype=error_max_turns reason=max_turns and the cost the failed run consumed, and no completion line.
REVIEW-CYCLE-INCOMPLETE stage=induced subtype=error_max_turns reason=max_turns cost_usd=0.0032
Put the reviewer on a schedule
The two cycles above ran by hand. On a schedule, each cycle is one invocation of scheduled_review.py(opens in new tab), the companion script beside this notebook. It runs from its own service directory, takes the repository to review as its argument, and carries the same schemas and reply readers.
The script's marker lines say REVIEW-RUN where the notebook's cycles say REVIEW-CYCLE, and the notebook's markers never appear in a scheduled log. The script reviews cold when its session file, .last_review_session, is absent from the service directory and resumes when the file is present. Its read_state function holds the persistence that notebook variables stood in for here.
Read the script once before you schedule it.
Run the reviewer on a persistent host. Give the job a user whose file access is limited to what the review needs, and run every step below as that user. Resuming depends on two local files surviving between cycles:
.last_review_session, the session file the script keeps beside itself in the service directory- the session transcript the SDK writes under
~/.claude/projects/, or under theprojects/directory inCLAUDE_CONFIG_DIRwhen you set that variable
Keep the service directory outside the repository you review. With that layout, a clean checkout or branch switch cannot delete the session file, and the file never shows up among the files the reviewer reads. A trial run from this recipe's own directory, python scheduled_review.py demo_repo, is refused at startup with a reason=repository-inside-service-directory usage line. A service directory inside the repository is refused the same way, with a reason=service-directory-inside-repository usage line.
Schedule the script
-
Create the service directory with its own environment. The notebook's
%pipinstall reaches only the notebook's own environment, and cron runs whatever interpreter the command names. The service directory carries its own virtual environment with the SDK installed:sudo mkdir -p /srv/reviewer && sudo chown $USER /srv/reviewer && cd /srv/reviewerpython3 -m venv .venv.venv/bin/pip install "claude-agent-sdk>=0.2.140"cp /path/to/scheduled_review.py .Use a
python3of 3.11 or later, the floor the prerequisites name, since an older interpreter fails at the script's first import. -
Create the environment file the cron job sources, at
/srv/reviewer/reviewer.env. The scheduler has to supply the environment, because cron starts with almost none. Useexportlines, since a plainKEY=valueassignment sets a shell variable that the Python process never inherits. Cron stripsPATHto a minimum. Set it to cover the directories holding the binaries the job needs:# /srv/reviewer/reviewer.env, sourced by the cron jobexport PATH=/usr/local/bin:$PATHexport ANTHROPIC_API_KEY=your_key_hereBefore the key goes in, create the file empty and restrict it with
touch /srv/reviewer/reviewer.env && chmod 600 /srv/reviewer/reviewer.env, then add the lines above. By default the script also denies itselfReadaccess to the service directory, keeping the key file out of any finding. The same denial would blind a review of any repository inside the service directory. The script refuses that layout outright. Keep the key out of the crontab line itself, because anyone who can list the crontab can read it. -
Run the script once by hand from the service directory, pointed at the repository you want reviewed:
cd /srv/reviewer && . ./reviewer.env && .venv/bin/python scheduled_review.py /path/to/your/repoThis first run reviews cold, prints
REVIEW-RUN-COMPLETE: cold, and creates the.last_review_sessionfile and the session transcript that every scheduled run resumes. If the run exits 1 witherror_max_budget_usd, raiseFIRST_RUN_BUDGET_USDbefore scheduling. A cold run that can't fit its budget never establishes a baseline, and every scheduled cycle then repeats the same failure. -
Add a crontab entry with
crontab -efor a nightly review at 02:00. Schedule it as the same user who ran step 3, because the session transcript lives under that user's home:0 2 * * * cd /srv/reviewer && . ./reviewer.env && flock -n -E 99 .review.lock .venv/bin/python scheduled_review.py /path/to/your/repo >> /srv/reviewer/review.log 2>&1 || echo "REVIEW-RUN-EXIT: $?" >> /srv/reviewer/review.logThe entry's tail turns a failing invocation's exit status into a log line. Both redirects name the log by absolute path, and a marker never lands in a file relative to the directory cron starts in.
flock -nskips an invocation while the previous one still holds the lock, and-E 99gives the skip its own exit code. A skipped run logsREVIEW-RUN-EXIT: 99and never masquerades as a failed one.flockcomes from util-linux on Linux hosts. macOS has no flock by default, and Homebrew's flock formula(opens in new tab) provides one.Findings quote repository content, so give
review.logthe same handling as the repository it reviews. Rotate the log the way you rotate your other service logs. The next section'sREVIEW-RUN-EXITrow shows how to read it.
Read the output lines
Your scheduler reads these lines:
| Line | Fires when | Exit code | Alert |
|---|---|---|---|
VERDICT: ok or VERDICT: concerns | A completed review reports the structured reply's verdict. A failed run never prints one | 0 | Alert on concerns |
RESUME-LINK ... | Every resumed run. same_session=True with a steady recalled_findings count is continuity working, and current_findings counts the follow-up's own findings | 0 | Threshold recalled_findings in your own alerting. A gradually collapsing count prints no marker and is the reset signal the Reset the session on a schedule section describes |
RESUME-LINK-BROKEN ... | The link breaks outright: the session was lost, the echo was lost, or recall hit zero. The marker leads its line, the form to grep | 0 | Alert. The run has already saved a new baseline for the next cycle |
REVIEW-RUN-COMPLETE: cold or : resumed | A review verifiably succeeded | 0 | None. Its presence means the cycle closed |
REVIEW-RUN-INCOMPLETE ... | The run ended on a terminal error result, most often an exceeded bound. subtype names what ended it; bounds and schema failures add reason. After a session crash the line can print cost_usd=0.0000, which means the cost is unknown rather than zero | 1 | Alert. A bounds failure keeps the session file, and its fix is a config change |
REVIEW-RUN-INCOMPLETE ... subtype=error_max_structured_output_retries | The reply repeatedly failed schema validation, or a model fallback(opens in new tab) retracted a completed reply with no retry left to replace it. Neither is a session problem | 1 | Alert. The session file is kept |
REVIEW-RUN-INCOMPLETE stage=usage ... | The invocation itself is wrong, such as a missing repository argument | 2 | Alert. Fix the crontab entry |
SESSION-FILE-CLEARED: next run reviews cold | A resumed run failed with subtype=error_during_execution, most often a saved session that no longer resumes; a transient connection failure lands here too, trading one cold rebuild for self-healing | 1 | None beyond the failure alert. The next cycle rebuilds the baseline cold |
SESSION-STATE-NOT-SAVED ... | The review completed but its state could not be written, most often a full or read-only disk. The findings above the marker are valid. A following SESSION-FILE-CLEARED means the next run rebuilds cold; SESSION-FILE-STALE means it will resume the previous session | 1 | Alert. Fix the disk; the review itself needs no rerun |
REVIEW-RUN-EXIT: <code> | The crontab entry's tail records a non-zero exit, including the 99 a skipped overlapping run leaves | n/a | Alert on any code other than 99 |
| No line at all | The run died before a terminal result, such as a killed process or a connection that never opens | non-zero | Alert on a missing completion line with no 99 skip explaining it |
An induced max_turns=1 run prints a line beginning REVIEW-RUN-INCOMPLETE stage=cold subtype=error_max_turns reason=max_turns, the prefix to grep, with the run's cost_usd completing the line. The Prove the failure path section triggered the same terminal result in the notebook.
A successful cycle leaves REVIEW-RUN-COMPLETE in the log and a failed cycle leaves a REVIEW-RUN-EXIT marker. Alert on any REVIEW-RUN-EXIT code other than 99, or on a cycle whose completion line is missing with no 99 skip explaining it. A skip is normal overlap, and repeated skips mean runs are outlasting the schedule. Anchor greps at the start of the line for every marker. Finding text is clipped onto single indented lines, so a marker only ever begins its own line. A stage=resumed failure followed by a SESSION-FILE-CLEARED line means the saved session no longer resumed, most often because its transcript under ~/.claude/projects/ is gone.
Customize the reviewer
- The prompts: point the follow-up prompt at what the review should track, such as new dependencies, breaking API changes, or TODO debt.
- The schema: add the fields your alerts need, such as a severity per finding. The reply comes back validated against whatever you declare. The script carries its own copy of the schemas and readers. Change both when you customize either.
- The bounds:
max_turnsandmax_budget_usdlive at the top of the script. Both bounds in this notebook's cells are sized for the three-file sample repository, with headroom above the recorded runs' owncost_usdfigures. The script's defaults leave headroom for a real repository. The budget is a runaway backstop, split like the turn caps. The baseline review runs underFIRST_RUN_BUDGET_USDand every follow-up underFOLLOW_UP_BUDGET_USD. The follow-up cap is the one a schedule multiplies. At the defaults, a nightly job's worst month is about 30 × 15 plus the occasional cold rebuild, against recorded runs that cost a few cents each. Watch thecost_usdfigure in the summary line across a few cycles to find the ceilings that fit. The figure is a client-side estimate rather than billing data. Track cost and usage(opens in new tab) covers the difference. Theeffortoption(opens in new tab) is one more cost lever. The docs recommend"low"for agents that only read files, and this reviewer is one. - The model: swap the
MODELalias to trade cost against capability. The pin keeps the change deliberate.
Reset the session on a schedule
A long-lived session gathers context by design. That context also grows cost and can anchor the reviewer on old conclusions. Delete the session file beside the script on a regular schedule, weekly or whenever the repository changes shape, and the next run reviews cold and rebuilds the baseline.
The follow-up's recalled_findings count is the operational signal. When the count drops off, the session has degraded and it is time to reset.
When to resume and when to start fresh
A scheduled review job resumes the previous run's session, starts fresh each cycle, or starts fresh with the prior findings fed in. The table below maps each choice to the jobs it fits:
| Reach for | When |
|---|---|
| A fresh session per invocation | Each unit of work gets judged on its own. A CI reviewer that gates pull requests needs this: no old opinions and no carried context steering the current verdict. |
| A fresh session with the prior findings fed in | The job needs only what the last run reported. A TODO-debt tracker that reports which of last week's items are still open fits this shape: the reply's finding ids carry everything the comparison needs, each cycle judges the code fresh, and no saved session can go stale or missing between cycles. |
| A resumed session | The job compares against everything the last pass saw. A nightly dependency audit that flags only packages added or changed since the last pass checks the full inventory the reviewer read, and most of that inventory never entered the reply. Feeding it forward yourself would mean serializing everything the reviewer observed, which is the work resume saves. |
Clean up
The notebook wrote four files into demo_repo/, the two-cycle fixture plus the between-cycle change. The code below removes them after checking that the directory holds the fixture. To run the delta experiment from the answer key first, hold off on this cleanup, and run the sample-repository code again later if you need the files back.
If you also ran the companion script, delete the .last_review_session file from its service directory too. The runs also wrote session transcripts under ~/.claude/projects/. Delete the matching project folder there to remove them.
removed demo_repo
What you learned
- Bounding an unattended agent:
toolsandallowed_toolsdecide what exists and what runs unprompted, andmax_turnswithmax_budget_usdcap the loop and the spend, so a scheduled run can only misbehave within limits you chose. - Proving continuity: an agent that resumes its previous session remembers what it already found. Make each run prove that by echoing the previous run's findings back in its reply. A broken resume then shows up in the log the moment it happens.
- Putting the reviewer on a schedule: a review that runs unattended reports to a program, so its output is exit codes and fixed lines a scheduler can grep. For this reviewer, that means a verdict line that answers for the repository, a completion line that prints only after a successful run, and a non-zero exit that marks every failure.
Learn more
- Get structured output from agents(opens in new tab): the full documentation of the
output_formatschemas andstructured_outputfield this recipe's replies used - Work with sessions(opens in new tab): the session lifecycle beyond the resume this recipe used, including forking a session down two paths
- Configure permissions(opens in new tab): the permission-mode model behind this recipe's
dontAsk, and the modes built for interactive use - Security(opens in new tab): Claude Code's security safeguards, including best practices for working with untrusted content
- Track cost and usage(opens in new tab): the cost surface behind
max_budget_usdand the summary lines' cost figures