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.