Soba Docs

Concepts

Cost classes

A cost class says who pays for a run. Every runtime declares one, because "free" has more than one reason and only some of them are free to the user.

TypeScript
type CostClass =
  | "user-hardware"
  | "user-subscription"
  | "user-api-key"
  | "open-weights"
  | "frontier";
Class Whose wallet Metered by Soba
user-hardware Nobody's, at the moment of the run. Their own silicon no
user-subscription Theirs, already spent. A plan they pay for anyway (Claude Pro/Max, ChatGPT Plus/Pro) no
user-api-key Theirs, per token. Their own provider key, on their own machine no
open-weights Yours. An open model on GPU, bought wholesale yes
frontier Yours. A frontier provider API yes

A single "is this free" flag would not do, because three of those are free to you and only two are free to the user. Routing has to tell them apart, so the class travels with the run and ends up on the usage record.

user-api-key is the one that is free to us and not free to them: Anthropic or OpenAI invoices the person whose key it is. It used to be reported as frontier, which put their spend in the same column as ours, and made the two impossible to rank against each other in a route. They are different wallets, so they are different classes.

TypeScript
METERED_CLASSES = ["user-key", "app"]

Soba sells no inference and holds no provider key, so none of these is billed by Soba. What Soba meters is cost, stamped on every run. What it bills is your plan, at your price, through your own Stripe account.

Runs your app serves#

app is the one class Soba never runs. When routing lands on it, Soba counts the run against the user's plan, applies any 402, and hands the run back to your server, which serves it with the client and the key it already uses. Your key never reaches Soba.

It exists so that a user with nothing connected, or with a laptop that is asleep, still gets an answer, and it is the only class that spends your money. The SDK's fallback serves and reports these runs for you; over the endpoint, the handback is a 409 serve_in_app. The usage you report is what keeps allowances, conversion data and the dashboard complete, and what makes these runs checkable against your provider bill.

app cannot be declared on a user's machine. Nothing a machine's owner writes can spend your money.

The machine asks its class rather than assuming it#

Before advertising a runtime, the worker checks locally whether the CLI is signed in and whether it is signed in against a subscription or against an API key — including whether a metered key is sitting in the machine's environment, which is what the CLI would reach for first. It costs no tokens and contacts nothing. A machine that would spend a metered key reports frontier, so the meter and the wallet agree.

Guessing here is the failure the field exists to prevent.

The environment itself is never edited. An earlier version of this deleted the key before spawning, which is a tidier-looking fix and the wrong one: a CLI's own authentication methods are not ours to remove, and Anthropic's terms for running Claude Code inside another product say so explicitly. So the check changes what the machine claims, not what its owner's tools are allowed to read — and a machine that would have to spend a key its owner has not allowed is withheld rather than quietly rewritten.

Two consequences, both deliberate#

A signed-out runtime is withheld#

Finding the binary is not the same as being able to run it. A signed-out CLI is kept out of what the machine advertises, and reported to its owner with the fix, rather than being routed to and failing at spawn on every run.

Only a positive finding withholds. A check that cannot tell cheaply advertises the runtime anyway, because a probe that fails must not hide a runtime that works.

A metered key in the environment changes what is advertised#

The worker inherits the owner's shell, and that shell may hold ANTHROPIC_API_KEY. The CLI reaches for it ahead of the login, so a run advertised as user-subscription would quietly bill their API account while Soba, reading a free cost class, charges nothing. Both sides lose and neither is told.

So the key is read at detection and answered there:

allowMeteredKeys What the machine does
true Advertises those runtimes as user-api-key — real money, and the owner's
false (default) Withholds them, and tells the owner which variable and both ways out

The environment is handed to the CLI exactly as the owner left it either way. That flag is grant-only: there is no way for an app to ask for it, because an app must not be able to spend someone else's money.

The tier is announced before the output#

The class that won is on the opening status event, before any text, and is stamped onto the run's usage at the end.

JSON
{"type":"status","message":"Starting","tier":"user-subscription"}

A cheaper tier covering for a sleeping laptop will visibly underperform the one the user expected. Silent degradation is worse than a visible downgrade, so the app is told which tier it got before the answer arrives, not after.

Self-describing usage#

TypeScript
interface RunUsage {
  costClass?: CostClass;  // who pays for this run
  costMicros?: number;    // absent when nothing was spent
  model: string;
  provider?: string;
  inputTokens: number;
  outputTokens: number;
  cacheReadTokens: number;
  cacheWriteTokens: number;
  webSearches: number;
}

costClass is on the usage record so a meter never has to remember what it routed to in order to know whose money was spent.

It is also why user-key and app are separate classes. Both spend real money by the token; the class says whose. A run on the machine owner's own provider key is honestly user-key: real money is being spent, and allowMeteredKeys is how they consented to spend it. It still costs you nothing, because the key was never yours. costMicros says how much was spent, whoever spent it, and is absent when nothing was.

Usage is absent altogether when the runtime cannot report it. A subscription-backed CLI often can't, which is exactly why those runs are free.

Costs are integer micros#

Throughout. No floats, no currency strings. route.maxCostMicros stops a run that reaches its ceiling, and that check happens between turns, because nobody knows a turn's cost before it happens.

The guarantee is "stops as soon as it knows", not "never exceeds".

© 2026 Soba resolved = machine grant ∩ broker request