The seventh agent in our 30 agents series is a Competitor Watch: give it a list of pages and a morning, and it tells you what moved. The feature is unremarkable. What it cost was not — because it is the first thing we have built that runs when there is nobody there, and three things we had quietly been relying on are only true when somebody is.
The scheduler was the smallest part
Our own build-order note for this agent said “we have no scheduler; this is the agent that justifies building one”, and I expected that to be the week. It was an afternoon, because the durable-run work from an earlier phase had already done the hard half. A run on this platform has been a Firestore document rather than an HTTP request for months, with the token stream written beside it as it goes. The comment at the top of that file says why:
…which is what lets a second client attach, a refreshed page replay,
and (later) a scheduled run happen with no client at all.That “later” was written by someone who did not yet know what it would take. It turned out to be one route and one Cloud Scheduler job — one, for the whole platform, not one per user schedule. The per-schedule version is the tempting shape and it is wrong on every axis: it needs scheduler-admin rights held by a running service, it is a second source of truth that drifts from the database the moment either write fails, and quota is a project-wide ceiling shared between every user who has ever booked anything. Schedules are rows. A user with a hundred of them costs a hundred rows.
The one detail in that part worth stealing: the claim advances the clock before the run, not after.
tx.update(doc.ref, {
leaseUntil: nowMs + LEASE_MS,
nextRunAt: nextOccurrence(schedule.cadence, schedule.timeZone, nowMs),
});If a tick dies mid-run — an instance recycled, a deploy, an unhandled throw — the schedule has already moved on. Advance it afterwards instead and a crash leaves a row that still looks due, so the next tick picks it up, dies the same way, and bills for it every five minutes until somebody notices. A missed week is a mild disappointment. The other thing is an incident.
Three things that are only true when someone is there
1. The approval dialog
Our agents ask before they do something effectful. A file write, a Linear issue, a shell command — the run parks, the person clicks, the work happens. It is the mechanism we are proudest of and it is completely useless at 3am, because the question goes into a log nobody is reading and the run sleeps until it times out.
There were three options and only one of them is honest. Refuse every effectful tool, and an agent whose job is to write a report cannot do its job. Ask and wait, which is the incoherent one — a question nobody will answer is not a safeguard, it is a hang. Or notice that the permission does not have to be given at the moment it is used.
Setting up a schedule is the approval. A person was present, they said what should happen and how often, and they clicked. That covers the runs it produces, because those runs execute the instruction that click approved.
That reasoning is only defensible if the setup itself is gated, so the tool that books a schedule is declared as a write — which is what makes a run in manual mode stop and ask before it books anything. The gate did not disappear. It moved, once, to the front.
2. A scheduled run must not be able to schedule
The first version let any run call the scheduling tool, which is a loop with a billing address. A prompt saying “make sure this keeps happening” produces one schedule per run, each producing one more, and the only thing bounding it is the per-user cap. There is no legitimate case for it either — a schedule that needs changing is one the user is present for. So it is refused outright rather than rate limited:
if (ctx.scheduleId) {
return {
success: false,
error:
"This run was itself started by a schedule, and a scheduled run " +
"cannot create another. Ask the user to set up any new schedule " +
"while they are here.",
};
}The same context field does two other jobs. Client-executed tools are not bound at all for a scheduled run, because binding one parks the call on a browser that does not exist. And the schedule inherits the session and the agent from the run that created it rather than taking them as arguments — a model that could name a session could aim recurring work at a workspace it is not in.
3. The credential
Every run so far has borrowed the caller’s own token: the request carries an Authorization header and the loop replays it when it calls back into our own API. Fine, when there is a caller.
The obvious fix is to mint a Firebase custom token for the user and exchange it for an ID token. We did not, and the reason is worth stating plainly: that produces a full user session. It is valid against payments, against account deletion, against everything. For a job whose entire ambition is to fetch six web pages. A credential that turns up in a log line should not be an account takeover.
So a scheduled run carries a grant instead — the same shape we built for our media service last week. HMAC-SHA256 over a payload naming one user, one schedule and an expiry. Deliberately not a JWT: a JWT carries the algorithm in a header the verifier is expected to read, and there is no interop requirement here, so the format has no algorithm field to lie about.
The part that surprised me: billing needed no code
A background feature has a money question attached to it, and we were ready to build something for it. We did not have to. The API-key layer has carried a purchasedCreditsOnly restriction since it was written, and the comment above it — written long before there was a scheduler — argues this exact case:
For keys handed to an always-on integration — a client that speaks a
notification after every build, say. Left unrestricted, that quietly
converts idle subscription allowance into real upstream spend on every
session, and the subscription tiers are sold below cost on the assumption
that unused capacity is *not* spent. An elective background feature is the
wrong thing to fund out of that pool.A scheduler is the clearest instance of an always-on integration there is. So the grant attaches the restriction, and the enforcement that already existed does the rest. Background runs spend credit you bought; they never quietly drain a subscription allowance while you sleep.
This is the second time in two weeks that the useful thing was a comment somebody wrote about a case that had not happened yet. It is cheap to write down the general argument when you are solving the specific one.
What the agent itself has to get right
The platform work is the interesting half for us. For anyone using it, the whole product is whether the Monday email is worth opening, and that comes down to two things that have nothing to do with schedulers.
The first run has nothing to compare against. A change detector with an empty baseline reports every page as new — which on a weekly schedule is indistinguishable from a genuinely eventful week, and it is the run most likely to be watched, because it is the one you just set up. So run one takes snapshots and reports no changes, and says that is what it did.
Most differences are not changes. A deploy hash, a cache-busting query string, a testimonial that rotates on every load: these differ between two fetches ten seconds apart. Report them and the feed cries wolf every week until nobody opens it. The agent is told to say once that a page is like that, and then stop mentioning it.
The workspace it ships with is deliberately broken in five ways, which is the rule we have followed since the first agent: a clean input lets an agent look competent while proving nothing. There is a row listed twice, a row with no URL at all, a page that changes every time you fetch it, a vague brief pointed at a homepage with no prices on it, and an instruction to email the report — which it has no tool for and has to say so, once, rather than let you wait for a message that is never coming.
What it cannot do
It reads what the server sends. A page that renders entirely in the browser arrives nearly empty, and the honest behaviour is to say so rather than report an empty page as unchanged forever. It cannot see anything behind a login. And it cannot send you anything — the report is a file in your workspace, which is a real limitation and not one we are going to paper over with a nicer word.
Competitor Watch is live now, and so is the scheduling underneath it — which every agent on the platform can now use, not just this one. That is the pattern the series keeps producing: each agent is picked because it is the first thing that would exercise some path end to end, and what it leaves behind is worth more than the agent.