Collect nothing by default

Deciding what a cost agent may report is not a settings screen. It is an architecture.

A developer’s laptop holds the company’s AI accounts and their own. The company should see the first and never the second, and the only way to make that a fact rather than a promise is to decide it on the device.

The short version. An agent that measures AI coding spend has to run where the work happens. The tempting design is to capture everything and filter in the dashboard. We took the opposite position: the device holds an allowlist of accounts it may report, an enrollment that arrives without one is refused outright, and the filter runs in four places on the machine rather than once in the cloud. Adding an account is requested from the laptop and approved by an admin, so neither side can do it alone. Usage whose owner cannot be determined is never forwarded.

THE DEVELOPER’S MACCoding toolsClaude Code, Codex, Gemini CLI and the restCodeCost agentreads the session files they writesession filesAI providersAnthropic, OpenAI, GoogleCodeCost consoleby team, developer, repositoryprompts and code36 metadata fieldsThe agent sits beside the tools, never between them and the provider.
The agent reads what the tools write on the machine and reports metadata to the console. It adds nothing to the request path, so a coding session cannot be slowed or stopped by it.

A developer’s machine might hold multiple identities

The coding harnesses keep their session data on the developer’s machine and write it as the work happens, so that machine is where the picture is both complete and live. A developer can be signed into one provider’s harness with a work email and another with a personal one, which puts more than one identity in that same data. The rule is simple: a developer’s personal usage must never be collected as organization usage.

Why not collect everything and filter later

The obvious design is to capture everything the machine produces, ship it, and sort out in the console which accounts belong to the org. It is easier in every way: one code path on the device, no policy to distribute, and any mistake fixable later because the data is already there.

That last part is why we turned it down. A filter after collection is a promise; a filter before it is a property of the system. Can a manager see what someone does on their own account? If that rests on a dashboard setting, the answer is no: settings change, exports exist. The only answer that holds is that the data never left the machine, so the device has to decide.

The whole gating logic, end to end

Every decision below happens on the device, which is why the console never receives a row it then has to exclude. Each step says what it decides; select one for what it refuses.

policy: how an account becomes allowed

  • The developer opens a link. Nothing is typed.

    The link carries a one-time code. Opening it connects the app to the org.

    The first launch never asks for a token.

  • It receives the list of accounts it may report.

    The list arrives with the device credential and stays on the machine.

    An empty list is refused. The agent will not start without a policy, because one that starts permissively is the bug you cannot afford once.

  • The developer offers one. An admin approves.

    The request comes from the laptop, never from the console.

    Neither side can do it alone. A device that could claim an account could claim a colleague’s.

    One account belongs to one device. Moving it is an admin decision.

  • Every ten minutes.

    Approval is a human act, so the check is slow on purpose.

    A change takes effect on the next cycle. No restart, no re-enrolling.

    A failed read keeps the last list. Offline and revoked look the same from here.

capture: on the device, before anything is stored

  • Nothing is written down before it.

    On the list: carry on.

    A known account is dropped if the org does not cover it. Nothing is stored, so there is no pile of data waiting to be filtered.

    An account is kept but never sent if nothing can name it. The spend is known, the owner is not, and sending unowned usage is the leak the list exists to stop.

    An empty list denies everything.

  • Asked only once the account is settled.

    A session is queued to send if its folder is in scope. It is held if that is not settled yet.

    It is dropped if the folder sits outside every allowed one. A tool record follows its session if it has no folder of its own.

forward: what leaves the machine

  • Every row is checked again.

    Not set up yet: buffer, send nothing. No list yet: keep it local.

    Sessions and tool records are both checked.

    Rows are given up on if they are too old to retry. They stay on the machine.

  • 36 fields. Nothing else.

    Tokens, model, timestamps, latency, cost, a session id, a repository name. The list is published in full.

    Never prompts. Never code. Never keys. Never file paths.

    The developer’s own figures use the same filter.

revocation: turning it off

  • The off switch. One place only.

    Tokens are issued, rotated and revoked per machine.

    The token stops working in about a minute. The app says it needs re-enrolling.

    Capture keeps running on the last list. Nothing leaves.

    The developer is told telemetry is paused.

Eight steps, every one of them on the device. Select a step to see what it decides and what it refuses. The list arrives at enrollment, grows only when a developer asks and an admin agrees, and is re-read every ten minutes. A session is judged by account first and folder second, and a row that clears both is checked once more on its way out.

Every device carries a list of the accounts it may report

That list is the precondition for the agent doing anything at all. A device that enrolls and receives no list does not fall back to reporting everything. It fails to enroll:

tok.AllowedAccountEmails = identity.NormalizeEmails(tok.AllowedAccountEmails)
if len(tok.AllowedAccountEmails) == 0 {
	return Token{}, errors.New("enroll: response contained no allowed account emails")
}

That refusal is the point. An enrollment that succeeds with an empty policy and starts capturing is the bug you cannot afford once, so an agent whose safe state is off has to refuse to start rather than start permissively. The list itself never leaves the machine.

The allowlist is checked in four places, not one

A single check is a single line to get wrong, so the same list is enforced at capture, at forwarding, in the developer’s own menu bar, and at enrollment. The forwarding check is three lines, and its shape is the whole design:

func accountAllowed(email string, allowed map[string]bool) bool {
	return allowed[identity.NormalizeEmail(email)]
}

A map lookup with no default: an account nobody has approved is not on the map, and what is not on the map does not travel. Normalisation carries more weight than it looks. An address that differs by case or whitespace is the same account to a person and a different key to a map, so one rule normalises it everywhere it is compared, stored or sent.

Adding an account takes two people

Enrolling with a company email covers the company’s accounts and deliberately nothing else. The others are not always outside the org’s interest, though: a developer may be doing company work on a subscription that is not on a company seat, and that spend is real.

So an account can be added, and the sequence is the point. The developer offers it and cannot approve it; the admin approves and cannot originate it. A console that could attach a personal address without the person acting would be the feature this design exists to prevent, and a device that could bind an account by asserting it would let someone claim a colleague’s. An approval reaches the machine on the next ten-minute read, and capture begins without a restart.

Revocation is the other direction

An allowlist that can only grow is not a control. An admin can issue, rotate or revoke a device’s token in the console, and that is the off switch.

The care is needed on the machine, because two different situations look identical from inside a failed request: a revoked token, and a laptop on a plane. So the device refuses to draw conclusions from silence. A failed refresh keeps the credential it already holds:

emails, err := enroll.FetchAllowedAccounts(client, apiBaseURL, enrollment.Token)
if err != nil {
	return
}

An empty answer gets the same treatment, and so does an enrollment that arrives with no accounts. The device never switches itself off because of something it heard from the network.

What stops is the flow, and it stops at the collector. A few consecutive rejections flip the daemon’s reported state, capture keeps running locally, and the developer is told telemetry is paused rather than left to guess.

That asymmetry is deliberate. On what leaves the machine, the default is no. On whether the agent is working, the default is to say so loudly. Something quiet in both directions is something nobody can audit.

Leaving an account unlinked is a supported outcome

Surfacing an uncovered account creates a trap. If the app warns about every account it is not collecting, and the only way to clear the warning is to link it, the interface is pressing toward disclosure while appearing to respect the choice.

So dismissal is its own state, separate from linking. A developer can acknowledge an uncovered account and keep it uncovered. Not linking a personal account is an outcome the product supports, not a task left undone.

A third state, for usage with no owner

The hardest case is the one where the account cannot be determined at all. Some routes reach a model with no login behind them, so the device can see that spend happened without being able to say whose it was.

Treating unknown as excluded loses information the org needs. Forwarding it is worse:

normalized := identity.NormalizeEmail(email)
slot, ok := s.accountSlot[normalized]
if !ok {
	if normalized == "" && len(s.accountSlot) > 0 {
		return StateUnattributed, ""
	}
	return StateFiltered, ""
}
return allowedState, slot

So it stays on the device, counted separately from a decided exclusion and never sent: reporting usage with no established owner is exactly the leak the list exists to prevent. If you build one of these, design this state before you need it.

What this reads, and what it does not

Asking for folder access on someone’s work machine deserves a straight answer about what happens next. The agent reads session files for token counts and resolves a working directory to a repository name. It sends 36 metadata fields, among them tokens, model, timestamps, latency, cost, a session id and the repository name. It does not send prompts, responses, API keys, file paths or the developer’s email, and that list is published in full. It sits outside the request path, so it cannot slow down or stop a coding session, and it does not cap or block usage.

What default deny cost us

Collecting nothing by default has a cost, and the cost is silence.

We hit it on our own machines. An account was not on the list. Its sessions were dropped at capture, exactly as designed, and nothing said so. The numbers were not wrong, only short by a slice. The single symptom was an absence.

The fix is the account list in the app: every account it finds, and whether this enrollment covers it. An exclusion is now something you see, not something you work out. Detection is careful about its own limits too, so an empty result reads as “we could not detect any”, never “you have none”.

That is the trade. A permissive agent warns you by showing data you did not expect. A restrictive one says nothing, and saying nothing looks exactly like working. If you exclude by default, budget the same effort for making the exclusions visible.

What this generalizes to

None of this is specific to AI spend. Any tool an organization installs where personal and work identities share a machine meets the same question. The ones that got it wrong got it wrong the same way: collect first, scope later.

Three rules carried over. Put the scope on the device: a filter that runs before data leaves can be checked, while a server-side view can only be promised. Make the permissive path the one that fails, so an agent with no policy refuses to run instead of running unrestricted. Require two parties to widen scope, so neither the person nor the organization can extend the agent’s reach alone.

Then show what you are not collecting. Making the exclusions visible is what turns the claim into something anyone can check.

CodeCost measures AI coding spend across an engineering organization: by team, developer, repository and model. It never sends prompts, code or keys, and it is not on the request path, so it cannot slow or stop a coding session. Free in beta. codecost.ai