Soba Docs

Models

The ChatGPT plan channel

Zero-install onboarding for a user's existing ChatGPT plan: an OAuth popup instead of a paired machine, with no local filesystem or shell behind it.

There are two ways to reach a tier the user has already paid for. The main one moves the work to their machine. This one moves the credential instead: the user connects their ChatGPT plan through an OAuth popup, and the loop runs server-side, authenticating as them, so their existing plan pays.

What it buys, and what it does not#

It buys zero-install onboarding: a browser, a phone, no terminal. That is a real thing and it is the reason the channel exists.

It buys nothing else. There is no filesystem behind it and no shell, so a run on this channel gets no local tools at all, only the tools your app defines, which execute in your app as always.

For anything that needs to read a file, run a command or touch the user's own environment, the paired machine is the tier that can do it. That asymmetry is why the local tier stays the headline.

What the user sees#

The connect step grows a second door, above the one that pairs a machine:

  • Continue with ChatGPT opens an OAuth popup. They sign in to OpenAI, approve, and the window closes. Nothing is installed and nothing is typed.
  • Continue with Soba App is unchanged — Claude Code, Codex or a local model, on a machine they own, with their files and tools behind it.

The second one stays the emphasised button, because the two are not equivalent and the screen should not imply they are. A connected plan appears in Your compute beside any machines, with its own Disconnect.

The panel does not believe the popup. It polls for the credential to actually appear before it says connected — the same rule the pairing flow follows when it refuses to stop at "approved".

Turning it on#

Two environment variables, both required, neither with a default. Unset, the modal draws the one door it always drew, POST /v1/hosted/start answers 501, and dispatch never asks whether a credential exists.

Terminal
# An OAuth client id you are entitled to use. See the warning below.
SOBA_OPENAI_CLIENT_ID=

# 32 random bytes, base64. What encrypts the stored tokens.
#   node -e "console.log(require('node:crypto').randomBytes(32).toString('base64'))"
SOBA_CREDENTIAL_KEY=

Both halves of the deployment need the same key: the web app seals a token when the grant lands, the gateway opens it to serve a run. Rotating it disconnects everybody — old rows stop decrypting, which is logged and treated as "no credential", and people reconnect.

How the credential is held#

Every other credential in this product is stored as SHA-256 and compared, never read. An OAuth token cannot be: it has to reach the issuer intact. So it is encrypted at rest with AES-256-GCM under a key held in the environment rather than the database, and the access and refresh tokens are bound to distinct purposes so that neither opens in the other's column.

That is encryption against a stolen database, and it is stated as exactly that: a deployment's environment and its tables together still yield the tokens.

Disconnecting stops this deployment presenting the token and clears it from the row. It does not withdraw the grant at OpenAI — only the user can do that, in their own account settings, and the panel says so rather than implying otherwise.

An operator supplies their own OAuth client id#

There is no default one, on purpose.

The officially launched "Sign in with ChatGPT" is an identity product: a third party receives a name, an email, an avatar. Spending a user's plan on model calls is a different thing.

This is a decision to make deliberately

Register your own client id and satisfy yourself that using it this way is something you are entitled to do. Borrowing another application's in order to spend someone's subscription is not a default worth burying in a config file.

What it costs, and what it is counted as#

Nothing, to anybody. A run on this channel lands as cost class user-subscription with a cost of zero and a real avoided cost — the counterfactual the "inference you didn't pay for" figure is built from. It does not spend a plan's allowance, and an exhausted spending cap does not touch it: both of those bound what the app buys, and this buys nothing.

A run refused by the provider ends there. It is not quietly re-run on bought supply, because that would turn the user's choice to spend their own subscription into an invoice for the app.

Thin plans are allowed, and they stop early#

Nothing here asks what ChatGPT plan the user is on, and nothing should. Every plan includes Codex, so a connected account may be a small one — and a small one is expected to do part of the work and then stop. That is the tier behaving correctly, not a fault: the alternative is refusing the connection outright and giving those users nothing.

What matters is that stopping is legible, because the rule above means we will not finish the job on your money. A run that ends on this channel carries a reason alongside its message:

reason What happened What the user does
subscription_exhausted The plan's own limit is spent. retryAfterSeconds says when it rolls, when the provider told us Waits. Nothing is lost
subscription_disconnected The grant lapsed and could not be refreshed Reconnects
subscription_not_entitled The provider refused this account outright Connects a different account, or uses another tier
subscription_failed Something else went wrong Nothing specific — the message is the evidence

The first three arrive with retryable: false. Do not put subscription_exhausted on a retry timer. It is a billing state that clears on the provider's clock, the same category as a 402, and retrying it is hours of requests that cannot succeed against somebody's own account. subscription_failed is deliberately left unflagged: it is the ending we could not name, and asserting it will never succeed would be a guess.

If you would rather a thin plan never hit this wall mid-task, narrow the route so those users reach bought supply instead — that is the app's decision to make, and making it explicitly is the point.

© 2026 Soba resolved = machine grant ∩ broker request