●MODEL — Claude Sonnet 5 is the default across all plans and Claude Code at $2/$10 per million tokens through August 31●FABLE — Access to Claude Fable 5 and Mythos 5 is being restored from July 1 after US export controls were lifted●SCIENCE — Claude Science, an AI workbench for researchers, is in beta for Pro, Max, Team, and Enterprise●GATEWAY — The Claude apps gateway for Amazon Bedrock and Google Cloud adds SSO, policy, role-based access, and per-user cost attribution●ENTERPRISE — Claude Enterprise adds richer admin analytics, model-level entitlements, and spend alerts●CODE — Claude Sonnet 5 is now the default model in Claude Code with a native 1M-token context window●MODEL — Claude Sonnet 5 is the default across all plans and Claude Code at $2/$10 per million tokens through August 31●FABLE — Access to Claude Fable 5 and Mythos 5 is being restored from July 1 after US export controls were lifted●SCIENCE — Claude Science, an AI workbench for researchers, is in beta for Pro, Max, Team, and Enterprise●GATEWAY — The Claude apps gateway for Amazon Bedrock and Google Cloud adds SSO, policy, role-based access, and per-user cost attribution●ENTERPRISE — Claude Enterprise adds richer admin analytics, model-level entitlements, and spend alerts●CODE — Claude Sonnet 5 is now the default model in Claude Code with a native 1M-token context window
Rolling Out Agent Behavior Changes Gradually with Feature Flags
When an unattended agent changes its prompt or behavior all at once, quiet regressions hide until morning. Here is a design for assigning new behavior to only a fraction of runs, keeping a control group, and adding deterministic bucketing, canary comparison, and automatic rollback.
I once rewrote the prompts for all four of my sites on the same night.
The intent was good. Align the phrasing of the intros, make the tone one notch warmer. I tried it on two or three drafts by hand, and the results did read better. So I pushed it into every site's generation job and went to sleep.
The next morning, all four logs wore the same expression: failed. One of the quality gates was catching a subtle structural break that the new phrasing produced. The problem never surfaced in the handful I tested by hand, but against real overnight data it reproduced cleanly. And because I had changed every site at once, there was no control group left anywhere. I had erased, with my own hands, the very thing I needed to answer the question "is the new wording actually the cause?"
The more autonomous the thing is, the less you should ship a change to all of it at once. This article is that lesson, turned into a design.
Why "apply everywhere" is especially dangerous for unattended runs
With an interactive tool, if the output looks wrong a human notices on the spot and stops. An unattended agent has no such eyes. A job that runs at two in the morning degrades with nobody watching until the log is opened the next day.
And failure does not always show up as an error. No exception is thrown, generation completes, and yet the density of the article has quietly dropped. That kind of silent regression flows straight to production the moment it slips past a mechanical gate.
As an indie developer I run four technical sites and a handful of apps that earn through AdMob. Each has unattended jobs firing at night, so this “degradation during the hours nobody is watching” is ground I have stepped on many times myself.
That is why, instead of applying a change to every run immediately, you want a design where only a fraction receives the new behavior while the rest is preserved as a control group. Think of it as applying a software canary release, but to behavior — a prompt, an agent's way of acting — rather than to code.
The smallest flag definition
First, consolidate every behavior branch into a single config file. Scatter if (newBehavior) through the code and nobody can tell what is running at what percentage. Keep one source of truth.
Field
Type
Role
key
string
Identifier for the branch, referenced from code
enabled
boolean
Master switch for whether this branch is evaluated at all
rollout
0–100
Fraction of runs that receive the new behavior
killed
boolean
Emergency stop: if true, pin to old behavior regardless of rollout
scope
string[]
Axis to narrow the target (e.g. site name). Empty means everything
owner
string
Who added it — a handle for later cleanup
createdAt
ISO date
When it was added, used for lifecycle management
// flags.json — the source of truth for behavior branches{ "gentler-intro-phrasing": { "enabled": true, "rollout": 25, "killed": false, "scope": ["claudelab"], "owner": "masaki", "createdAt": "2026-07-08" }}
rollout: 25 together with scope: ["claudelab"] means "try the new phrasing on one site first, and even there, on only a quarter of the runs." That is the decisive difference from the version of me who changed all four sites in a single night.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Deterministic bucketing: hashing the run key so the same job always lands in the same group, which is what makes the comparison trustworthy
✦Canary comparison and auto-rollback: comparing gate pass rates between control and canary, then closing the flag on the next run if it regresses
✦Flag lifecycle: retiring a branch once it is proven, so the codebase does not accumulate dead paths nobody can reason about
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
If you decide rollout with a plain random draw, every regeneration of the same article flips between old and new, and the observation turns to mud. The premise that makes the comparison valid is that the same job always falls into the same group.
So take a key that uniquely identifies the run (site name plus slug, or a job ID), hash it, and map it onto buckets 0–99. Mixing the flag key into the hash keeps multiple flags from repeatedly landing on the same set of runs and skewing everything.
import { createHash } from "node:crypto";// Derive a stable 0..99 bucket from run key + flag keyfunction bucketOf(runKey: string, flagKey: string): number { const h = createHash("sha256").update(`${flagKey}:${runKey}`).digest(); // Map the first two bytes onto 0..99 (distribution is close enough to uniform) return ((h[0] << 8) | h[1]) % 100;}type Flag = { enabled: boolean; rollout: number; killed: boolean; scope?: string[]; owner?: string; createdAt?: string;};// Should this run get the new behavior? Side-effect free and fully deterministicexport function isCanary( flag: Flag | undefined, runKey: string, flagKey: string, siteScope: string,): boolean { if (!flag || !flag.enabled) return false; // undefined/disabled -> old behavior if (flag.killed) return false; // emergency stop wins over everything if (flag.scope?.length && !flag.scope.includes(siteScope)) return false; return bucketOf(runKey, flagKey) < flag.rollout; // 25 -> buckets 0..24 are canary}
Evaluating killed before rollout is the crucial part. When something goes wrong at night, lowering the rollout number and snapping everything back to the old behavior are different in kind. The former is "adjust the fraction"; the latter is "retreat unconditionally." So the retreat switch sits ahead of every other condition, so there is nothing to hesitate over in an emergency.
Switch behavior at a single seam
Once the decision exists, the branch itself stays thin. Swap only the prompt variable and leave the downstream generation logic shared.
import Anthropic from "@anthropic-ai/sdk";const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });const INTRO_CONTROL = "Begin the article with a concrete description of the problem the reader is facing.";const INTRO_CANARY = "Begin the article from the moment the author first ran into the problem, in a quiet first person.";export async function generateArticle(opts: { runKey: string; // e.g. "claudelab/agent-feature-flag-rollout" site: string; // e.g. "claudelab" flags: Record<string, Flag>; topic: string;}) { const useCanary = isCanary( opts.flags["gentler-intro-phrasing"], opts.runKey, "gentler-intro-phrasing", opts.site, ); const variant = useCanary ? "canary" : "control"; const introRule = useCanary ? INTRO_CANARY : INTRO_CONTROL; const msg = await client.messages.create({ model: "claude-sonnet-5", max_tokens: 4096, system: `You are a technical writer.\n${introRule}`, messages: [{ role: "user", content: opts.topic }], }); // Always record which group generated this — the lifeline of the later comparison return { variant, text: msg.content, runKey: opts.runKey };}
Do not forget to return variant alongside the result. If you fail to record which group a run belonged to, you cannot line up control against canary afterward, and the whole point of the flag evaporates.
Compare canary against control
Whether the new behavior "seems good" is a judgment you make by placing the same metric from both groups side by side, not by impression. In my case, the first thing I look at is the quality gate pass rate. It carries no subjectivity and can be tallied the same day.
type RunLog = { runKey: string; variant: "control" | "canary"; gatePassed: boolean; // passed the quality gate on the first try charCount: number; // character count of the generated body ts: string;};// Compute each group's pass rate from recent logsfunction compare(logs: RunLog[]) { const rate = (v: string) => { const g = logs.filter((l) => l.variant === v); const pass = g.filter((l) => l.gatePassed).length; return { n: g.length, passRate: g.length ? pass / g.length : 1 }; }; return { control: rate("control"), canary: rate("canary") };}
For the observation window, if you generate only one article a day, watch it for about three days. That keeps you from reacting to noise while the canary sample is still tiny. In my own setup one article runs per night, so a 25% canary is actually drawn only once every few days. Given that, I have decided to hold judgment until at least three canary runs have accumulated.
When you detect a regression, close the flag on the next run
If the comparison shows the canary pass rate clearly below the control group, retreat without waiting for a human to wake up. Here again, set only killed and touch none of the other values, because I want to preserve when and at what numbers it was closed for the investigation later.
import { readFileSync, writeFileSync } from "node:fs";// Kill if canary is at least REGRESSION_PT below controlconst REGRESSION_PT = 0.05; // 5 pointsconst MIN_CANARY = 3; // minimum sample sizeexport function autoRollback(flagKey: string, logs: RunLog[], path: string) { const { control, canary } = compare(logs); if (canary.n < MIN_CANARY) return { acted: false, reason: "insufficient sample" }; const gap = control.passRate - canary.passRate; if (gap < REGRESSION_PT) return { acted: false, reason: "no regression" }; const flags = JSON.parse(readFileSync(path, "utf8")); flags[flagKey].killed = true; flags[flagKey].killedAt = new Date().toISOString(); flags[flagKey].killedReason = `canary passRate ${canary.passRate.toFixed(2)} < control ${control.passRate.toFixed(2)}`; writeFileSync(path, JSON.stringify(flags, null, 2)); return { acted: true, gap };}
The important thing is to run this function right after generation, before the next run begins. Slot the evaluation at the end of the nightly batch and the regression is contained to at most one night's worth. It never becomes that morning again — the one where four sites went down together and cost me a whole day.
Field notes the docs do not cover
Do not break bucket stability. Change how you build the run key midway and jobs that were control migrate to canary, severing the continuity of the comparison. Fix the key definition once you set it; if you must change it, re-cut it as a new flag.
Treat kill as distinct from adjusting the fraction.rollout: 0 and killed: true look similar in outcome but mean different things. The former is "not widening today"; the latter is "I have judged this behavior dangerous." During an audit, that difference decides how fast you trace the cause.
Flags have a lifespan. This is the one I overlooked most. Once a new behavior is proven good, delete the flag and promote the behavior to the default. Leave it and branches pile up until nobody can tell which path is alive. Once a month I sweep flags whose createdAt is more than thirty days old, and I set aside time to settle each one as promote, remove, or hold.
The smaller you start with a reversible change, the better, and I recommend it strongly.
Situation
Recommendation
Trying the same change across several sites or jobs
Limit to one scope first and start rollout small
Change is reversible (e.g. prompt wording)
Raise rollout in steps; 25 to 50 to 100 is easy to handle
Change is near-irreversible (e.g. external write behavior)
Before flags, shadow it: record results only and compare
Quality signal is subjective and hard to auto-tally
Skip auto-rollback; keep a human check in the loop
Closing
When you apply a change to a system that runs unattended, before making it "take effect everywhere," prepare a path to "confirm it on a fraction and retreat quietly if it turns out badly." A feature flag is a very plain tool for exactly that.
As a next step, try just one, in this order:
Pick one change you currently apply to every run immediately
Re-place it as a branch with a single scope and a 25% rollout
Once three canary runs accumulate, compare against control and, if good, widen to 50 then 100
The view the next morning will be a little calmer.
These are only humble operational notes, but if they make even one of your nightly batches a little quieter, I will be glad. Thank you for reading.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.