Chapter 6 of 7 · 12 min read
An offline agent needs consent bound to actions, not a refresh token
On this page
Every control in the previous chapters assumes somebody is there. A session is verified because a person signed in, a resource bound is taken from a ticket a person opened, and a policy decision that returns "approval required" has somewhere to send that request. Offline access — an agent acting for a user who is not present, and typically not awake — removes all of that, and leaves the question of authority entirely to whatever was arranged in advance. This chapter is about what that arrangement has to contain to be worth anything, and about the credential most implementations reach for instead.
The feature is popular and the ask is reasonable: at 02:00 each night, look at tomorrow's calendar, find invitations the user has not responded to that collide with something already accepted, and decline them with a short note. It runs for 1,200 users, takes about forty minutes, and by the time anyone wakes up their day has fewer conflicts in it. Nobody is awake to approve anything, which removes the control that topic 10.7 leaned on.
The arrangement most implementations reach for is a refresh token. The user connects their calendar through an OAuth consent screen, the application stores the refresh token — a long-lived credential whose only purpose is to be exchanged for a fresh access token whenever one is needed, without the user being present — and each night the job makes that exchange and works. It takes an afternoon to build and it hands the job a credential that is wrong along three separate axes at once, which are the same three the four bounds of chapter one would have constrained.
The scope on the consent screen is https://www.googleapis.com/auth/calendar, because that is the granularity the provider offers and the finer read-only scope cannot write responses. Follow that constraint to its consequence: the job has to write, the only writing scope covers all writing, so the stored grant permits deleting events, editing events the user owns, changing sharing, and reading the full body of every private meeting. Nobody chose those; they arrived attached to the one scope that could send an RSVP.
The lifetime is indefinite, since refresh tokens for published applications persist until revoked, so a grant collected in March is live in September on a user who has forgotten they connected anything. And there is no binding to the job: the same stored token is a general-purpose key to that user's calendar, usable by any code path in the application that can reach the token store, at any hour, for any operation the scope permits.
Compare the surface. The nightly job needs to send an RSVP of exactly one kind on events where the user is an invitee, on one calendar, within a fourteen-day window. Count that the way chapter one counted the billing key, as operations multiplied by objects: the job's number is one operation over the invitations in a fortnight on one calendar, and the token's is every operation the calendar scope exposes over every event on every calendar the user has. Counting the operation-and-object combinations the provider's calendar API exposes against the ones the job uses, the stored refresh token is something on the order of three hundred times the reach of the job's actual need, held permanently, on behalf of someone asleep.
The grant is a document, not a token
What replaces it is a record the user approved, describing operations rather than APIs, which the job consults on every action and which the credential minting path reads to decide what a run's token may contain. The token is derived; the grant is the thing with meaning.
{
"grant_id": "grt_01HQ9F2K7X",
"subject": "u_8831",
"granted_at": "2026-03-04T09:12:41Z",
"expires_at": "2026-06-02T09:12:41Z",
"agent": "agent:calendar-reconciler",
"purpose": "Decline invitations that conflict with accepted meetings",
"operations": [
{
"op": "calendar.event.respond",
"response": "declined",
"conditions": {
"role": "attendee",
"not_organizer": true,
"conflicts_with_accepted": true,
"visibility_not": "private",
"window_days": 14,
"calendar": "primary"
}
}
],
"budget": {"actions_per_run": 20, "actions_per_week": 60},
"message_template": "Sorry — conflict with another meeting. Sent by my scheduling assistant.",
"on_out_of_grant": "queue_for_review",
"revocable_at": "https://app.example.com/settings/agents/grt_01HQ9F2K7X"
}Everything in that document is answerable by the user at consent time, which is the test of whether a grant is written at the right level. "Decline invitations that clash with meetings you have already accepted, in the next two weeks, on your main calendar, up to twenty a night" is a sentence a person can agree or disagree with. "Full access to your calendar" is not a decision; it is a formality with a button. The difference is that the first sentence names things the user knows about their own life and the second names a technical boundary they have no way to evaluate.
The refusals matter as much as the permissions, and writing them down explicitly is what keeps the grant from drifting. This job never accepts an invitation, because accepting commits the user's time on the basis of the model's reading of a meeting title. It never deletes or edits an event, and it never touches events the user organises, since declining your own meeting is a cancellation for everyone else. It sends no message other than the fixed template, so the model never composes text that goes to another human without review. It does not read event bodies for events marked private, which keeps the most sensitive content out of the model's context entirely.
Each of these is a case the model could handle plausibly most of the time, and each is one where being wrong 2 percent of the time produces a problem the user cannot easily undo. That pairing is the rule behind the whole list, and it is worth stating on its own because it decides the boundary of every grant of this kind: an operation belongs in a grant when the cost of the model's error is bounded and reversible, and belongs outside it when the error propagates to other people or destroys something. Declining an invitation is reversible by re-accepting. Cancelling a meeting is not, because the other attendees have already been told.
| Operation | In the grant? | Condition | Why |
|---|---|---|---|
| Decline an invitation | Yes | Conflicts with an accepted event, user is an attendee, next 14 days | Reversible by re-accepting; the conflict is checkable without judgement |
| Accept an invitation | No | — | Commits the user's time on a model's inference |
| Propose a new time | No | — | Sends text to other people |
| Delete or edit an event | No | — | Not reversible; not needed for the purpose |
| Cancel an event the user organises | No | — | Affects every other attendee |
| Read event metadata | Yes | Titles, times, attendees; 14-day window | The minimum needed to detect conflicts |
| Read event body | No | — | Highest-sensitivity content, unnecessary for the task |
Read the last column rather than the second, because the last column is the reasoning and the second is only its result. Two of the reasons are about reversibility, two are about other people, and one is about sensitivity of data rather than about the effect of the action at all. Those three tests are portable to a grant for some other product, and the yes-or-no column is not.
What a run does when it meets something outside the grant
The interesting engineering is at the boundary, because the cases that fall outside will be the ones the user most wants handled. An invitation from the user's manager that conflicts with a dentist appointment is exactly the judgement call the grant refuses to make, and dropping it silently trains people to distrust the feature.
Note that Queue returns to Evaluate and Halt does not, which is the whole difference between the two exits. The queue is where the human-in-the-loop control from topic 10.7 attaches, moved off the critical path: the run makes no decision, records the candidate with the reason it was excluded, and the morning digest presents each queued item with a one-click action. Ninety percent of nights, on the numbers from this feature, the digest contains between zero and three items, which is small enough that people read it — a digest that regularly ran to dozens would be skimmed, and a control nobody reads is not a control.
The budget halt behaves differently from the queue on purpose. Hitting twenty declines in one night means something is wrong — a shared calendar was imported, or a recurring series exploded — and the correct response is to stop and say so rather than to keep going with a bigger budget. The reasoning is that the queue handles candidates the grant did not anticipate one at a time, while the budget being exhausted is evidence about the whole run, and continuing would mean acting on a population the user never had in mind when they agreed to twenty a night.
Expiry is the quiet control. Ninety days is short enough that a grant reflects something the user still wants and long enough that re-consent is not an irritation, and the re-consent screen is the natural place to show what the grant actually did: 61 declines in ninety days, 4 items queued, 0 budget halts. A user who sees that and renews has consented in a meaningful sense. A user who sees it and cancels has learned something the application would otherwise never have told them.
The token the run mints per user follows from the grant rather than from a stored scope: audience the calendar service, scope calendar.events.respond, resource the specific event id being acted on, lifetime 120 seconds, and the sub/act pair from chapter three with grant_id added so the resource server can log which authorisation covered the write. That is the four bounds of chapter one derived from a document rather than from a prototype's convenience, with the resource bound taken from the event the broker has already fetched. Per-action evaluation from the previous chapter still runs, with the grant's conditions as inputs — the difference offline is that the policy engine has no live user to escalate to, so a RequireApproval outcome routes to the queue instead of to a prompt.
The provider's scopes are coarser than your grant
There is an awkward truth underneath all of this, which is that Google's calendar API will not enforce any of the conditions above. Its scope vocabulary offers read-only or read-write, and the job needs to write, so whatever the grant says, the credential your service holds against the provider is broad. The grant is enforced by you, in a component you control, and that component has to be the only path to the provider's credential — because a grant enforced in one of two paths to the same credential is enforced in neither.
In practice that is a calendar broker: a small service that holds the provider connection, exposes exactly the operations the grants can reference, and refuses everything else. The agent never sees a Google token. It calls broker.respond(event_id, "declined"), the broker loads the grant, checks the conditions against the event it fetches itself rather than against attributes the caller supplied, and only then touches the provider.
def respond(event_id: str, response: str, claims: dict) -> Result:
grant = grants.load(claims["grant_id"], subject=claims["sub"])
if grant.expired() or response not in grant.allowed_responses():
return Result.denied("outside grant")
event = provider.get_event(grant.subject, event_id) # broker's own credential
if event.organizer == grant.subject or event.visibility == "private":
return Result.queued("organizer or private event")
if not conflicts_with_accepted(event, grant.window_days):
return Result.queued("no detectable conflict")
if not budgets.consume(grant, cost=1):
return Result.halted("run budget exhausted")
provider.respond(event_id, response, comment=grant.message_template)
audit.write(grant, claims, event_id, decision="allow")
return Result.ok()The line doing the most work is provider.get_event, because the conditions are evaluated against the event as the provider reports it rather than against whatever the agent claimed about it. Trace what the caller actually supplies: an event id and a response, and nothing else. Every attribute the conditions consult — the organiser, the visibility, the times that decide whether there is a conflict — is fetched by the broker from the source of truth. An agent that passes an event id belonging to a meeting it organises gets a queued result, not a decline, and it gets the same result if it lies about the conflict, because it was never asked. Designs that check conditions against caller-supplied attributes have moved the enforcement point back into the component the model influences, which is the same defect as the JSON user_id from chapter three wearing different clothes.
Where this is the wrong shape
A grant of this kind suits work that is narrow, repetitive and checkable. It suits it badly when the task is genuinely open-ended, and the failure is that the grant grows conditions until nobody can tell what it permits. A grant with fourteen operations and thirty conditions is not a consent artifact; it is a policy file with a signature on it, and the honest move at that point is to run the work with a human present rather than to keep widening what the sleeping user agreed to. The test from the start of this chapter is the one to re-apply: if the grant can no longer be stated as a sentence the user could disagree with, it is no longer doing the job a grant does.
It is also not a substitute for an account the agent owns. Some nightly work has no user on whose behalf it acts — reindexing a search corpus, expiring stale invitations across a tenant — and dressing that up as delegation from whichever administrator happened to enable it makes the audit trail lie. Those jobs should run as their own principal with their own permissions, and the fact that no human's authority is being spent should be visible in the record.
The unsolved part is consent quality. People approve grants they have not read, and a well-written grant document is still text on a screen at the end of a setup flow. Narrow defaults, short expiry and an honest activity report at renewal move the odds, and none of them establish that the user understood what they agreed to. When the nightly run declines a meeting the user wanted, the argument that follows turns on a question the grant alone cannot answer — whether this was the standing consent being spent as agreed, or the model deciding something on its own — and answering it requires a record built for that question.
Agent identity and delegated authority — read the current version at https://daviddominguez.dev/learn/en/agent-identity-and-delegated-authority/an-offline-agent-needs-consent-bound-to-actions-not-a-refresh-token