Drop-in React components for connecting compute, showing its state, picking a plan and taking payment, with a hook and a raw API underneath so you can replace any of them.
JavaScript
<SobaProvider userId={session.user.id}>
<SobaPlans /> <ConnectCompute /> <ComputeStatus />
</SobaProvider>
JavaScript
const { run, plan, compute, connect } = useSoba();
Three levels: component, hook, raw API. You can replace any one of them
without leaving Soba, which is the point of shipping all three.
<SobaProvider>#
Wraps the tree and carries the one thing everything else needs, the signed-in user's
id. That id is what attributes a run to a person and decides
whose machine it may reach.
<ConnectCompute />#
The connect step, and the component that carries the real friction. There are two
flows behind one button, and which one a user meets depends on their provider and
platform:
|
|
| ChatGPT |
An OAuth popup. No install |
| Claude |
Install Soba App, paste the connect link, approve the code in the browser, paired. It stays running as a login item |
Neither ever displays or asks for a pasted credential. See Pairing.
For someone who has already paired, this is not a connect step at all: the panel opens
on the machines they have and keeps Connect another machine one click away. Someone
arriving the second time came to check something, not to be sold the idea again. If
you would rather place the two apart, pass showStatus={false} and render
<ComputeStatus /> wherever you want it.
<ComputeStatus />#
The component with no equivalent in an auth product, and the one that matters most:
when the compute is the user's own machine, the state of that machine is
user-facing. Asleep, offline, signed out of its CLI, or serving a run right now are
all things the person needs to be able to see, because they are the only one who can
do anything about them.
Where to put these#
<ConnectCompute /> on a settings page is the obvious placement and the weakest
one. A settings page is where somebody goes to check on a machine they already
connected. It is not where they decide to connect one, because nobody visits it
wondering whether to.
The decision happens at three other moments, and all three are the same modal
opened from three places:
| Moment |
What goes there |
| A run just failed for a reason they can fix |
<RunLimit />, in the place the answer was going to be |
| They are weighing what to pay |
The connect row <SobaPlans /> draws from the plan's reward — as a line on every card, and as a button on <SobaCurrentPlan />, which is the half that knows whether anything is attached |
| The allowance is running low |
<ConnectNudge />, quietly, while they still have a choice |
<ConnectButton /> is outlined and marked by default. It hands somebody to
a flow that is not yours — nine steps, a download, a sheet with our name at the
foot — and drawn as a filled black control it was the loudest thing on whatever
settings page it landed on. variant="primary" is one prop away for a page
whose whole purpose is this. Pass children and it says what you told it to,
mark and all dropped: stamping a logo on your words would be the component
talking over you.
The flow behind all of them is nine steps long and involves a download, so none
of these tries to be the flow. A card is a trigger; the modal is the flow.
<SobaProvider> mounts exactly one modal and connect.open() raises it, so an
app with all three cannot put two pairings on screen at once.
<RunLimit />#
The two failures a person can do something about, and the offer that answers
each. Pass whatever your catch block caught:
JavaScript
<RunLimit error={error} onDismiss={() => setError(null)} />
It renders nothing unless the error is a 402, the plan's
allowance is spent, or a 409, nothing this run may use is
connected and awake. So it can sit permanently in your tree, and an unrelated
error still renders however your app renders errors.
variant="card" sits in the flow of the page, where the failed run is, drawn in
the grammar every agent surface already uses to ask "may I run this": a mark, the
question, the consequence, and the answers on the right with the affirmative
last. In a stream of messages a card with no mark reads as something the
assistant said; this is something the app is asking. variant="dialog"
covers the page with the same content. Same content either way: the container is the only
difference, which is why a card or a modal is one decision and not two
components.
It tells four situations apart, because they want four different sentences:
|
|
| Allowance spent, plan rewards connecting, nothing connected |
Connect, primary. Upgrading second |
| Allowance spent, already connected |
Upgrade only. Connecting again would change nothing, and offering it would be untrue |
| Nothing connected at all |
Connect |
| Connected but unreachable |
Names the machine. Open Soba App is the fix and takes the primary; pairing another one is the way round, and sits beside it |
A plan whose reward is credit is never offered as the way past a wall: a
credit is assessed at renewal, which is true and no use to somebody standing at
one right now.
Before a run, not only after#
Omit error, or pass predict alongside it, and the card watches the state as
well, appearing before a run is attempted:
JavaScript
<RunLimit error={error} predict onDismiss={() => setHidden(true)} />
This is the form a chat wants. A plan that can only reach compute the user owns,
with nothing connected, has nowhere to send a message, and saying so in the
stream above the composer beats saying it in the wreckage of a request.
A button wants the other one. <RunLimit error={error} /> with no predict
waits for a failure, because there the press is the attempt: a card saying
"nothing connected" under a button nobody has touched is an answer to a question
nobody asked. examples/inbox has both — the chat predicts, the triage button
does not, and the triage button raises its offer as a dialog because the person
is now waiting on nothing.
error={null} is not a request to predict. It is the value every app holds
before the first failure, and it renders nothing; only omitting the prop, or
passing predict, asks for the state form.
The state form is deliberately conservative. It reports only what the plan
declares about itself, its route_allow and its overage, and never
reimplements the dispatch rule. Predicting a refusal is how a browser starts
disagreeing with the server about whose turn it is to say no.
It reads whether a machine can serve now, not whether one was ever paired —
the same fact dispatch checks first. On a plan that can only reach compute the
user owns, a laptop that has stopped answering is a 409 waiting to happen, and
saying so before somebody types a paragraph is the whole point of the form.
useRunLimit()#
<RunLimit /> is a thin renderer over this, and this is the one to reach for
when your app has its own card grammar:
JavaScript
const limit = useRunLimit(error) // add { predict: true } to answer before a run too
if (!limit) return null
return (
<div className="your-card">
<strong>{limit.title}</strong>
<p>{limit.body}</p>
<button onClick={limit.connect}>Connect compute</button>
{limit.upgradeTo && <button onClick={limit.upgrade}>{limit.upgradeTo.name}</button>}
</div>
)
It returns reason, title, body, connectOffer (what connecting earns,
from the plan, or null), upgradeTo (the cheapest plan that would lift this),
connect() and upgrade(). The card is yours; the modal connect() raises is
ours. examples/inbox uses it without rendering anything from it: the triage
button asks what is already in the way, and raises the dialog instead of sending
a request it knows will be refused.
sobaErrorCode(caught) is exported alongside it. It reads the code out of the
error envelope, a Response, or an Error carrying either, so you never have to
match on message text that will change.
Your run route has to forward the status. The failure is only answerable if
the code survives the trip: return the platform's { error: { message, code } }
at the status it came with, rather than flattening it into a sentence.
<ConnectNudge />#
The same offer, earlier and much quieter:
2 messages left this month. Unlimited messages on your own AI.
[Connect]
A wall converts because the person has no choice, which is also what makes it a
poor introduction. This is the version that arrives while they still have one.
It says nothing at all unless the plan's reward is one felt as allowance
(unlimited or included), nothing is connected yet, and the allowance is
actually running out. A nudge that appears when it cannot help is an
advertisement. at sets how little has to be left, as a fraction of the
allowance, so it means the same on a plan of 20 and a plan of 2,000.
When the run, not the plan, is what needs the machine#
The rules above read the plan, which is the right source for an app that
posts a run and lets the plan decide where it may land. A surface that narrows
route.allow per run — because the agent has to have the user's own files in
front of it — is a different claim, and the plan is no evidence of it: on a paid,
unmetered plan nothing in plan says the run could only ever have gone to
hardware this person owns. Tell it:
JavaScript
<ConnectNudge
error={err}
allow={["user-hardware", "user-subscription"]}
fallback={<p className="error">{message}</p>}
/>
allow takes the same value you send on the run. It changes what the card
says, never where a run may go — the gateway owns that — so a value that
disagrees with the run costs a wrong sentence and nothing else. An explicit
null is no ceiling; undefined is "not asked" and falls back to the plan, so
passing a variable that happens to be empty cannot silence the card.
fallback is what to draw when the card has nothing to offer, and it is what
makes error={err} safe to put in place of your error line rather than
above it. This renders nothing for a failure connecting cannot fix, nothing
while the plan is still loading, and nothing when the machine came back between
the run failing and the card rendering. Hand it the line you would have drawn
and the failure is never swallowed: the card speaks when it can, your app speaks
when it cannot.
And one thing it says that is not an offer#
Andrea's MacBook can't be reached. The Soba app there has stopped
reporting in, so runs have nowhere to go until it is back.
[Open Soba App]
A machine that is paired and not answering — a shut lid, a dropped
connection, a worker still retrying — is the one state this component reports
rather than sells. So it ignores every rule above it: both integrations draw it,
whether or not anything has failed, and whatever the plan rewards. An offer
nobody asked for is an advertisement; this is the app telling somebody that
hardware they own has gone quiet, and it withdraws by itself on the poll after
the machine comes back.
The button opens the desktop app — soba://show, which asks for the window and
carries nothing else — and lands on the screen that says the same sentence with
Reconnect now on it. A browser cannot find out whether that worked, only
whether something took the foreground, so a press that appears to do nothing
says so and offers Connect another machine instead: the machine that went
quiet is very often not the computer this browser is on.
Making them look like your app#
Every component is styled from custom properties on .soba-connect, and they
are a supported surface rather than an implementation detail:
css
.soba-connect {
--sc-font: inherit; /* take the host page's typeface */
--sc-accent: #2d6cdf; /* the primary button and focus ring */
--sc-accent-fg: #ffffff;
--sc-ground: #ffffff;
--sc-sheet: #ffffff; /* cards and the modal sheet */
--sc-raised: #f6f6f7; /* the nudge, hovers, code blocks */
--sc-line: #e9e9eb;
--sc-line-2: #dcdce0; /* borders that carry a control */
--sc-text: #1a1a1c;
--sc-text-2: #3f3f46;
--sc-muted: #6b6b73;
--sc-ok: #22a06b; /* connected, and the lines that cost nobody */
--sc-warn: #b06f14;
--sc-r: 10px; /* the corner radius */
--sc-mono: "Berkeley Mono", ui-monospace, monospace;
}
--sc-font defaults to Inter, which is right for the hosted page and the
desktop window because those are ours. Set it to inherit for a component
sitting inside your own chat: insisting on its own typeface there is the
loudest way of announcing where it came from.
injectStyles={false} on the provider turns the stylesheet off entirely if you
would rather ship your own.
<SobaPlans /> and <SobaCheckout />#
Render the plans you configured, take the payment through your own
Stripe account, and grant the entitlement. Prices, allowances and the
connected reward all come from the plan objects, so a pricing change is not a deploy.
The reward is drawn as a row with a button, above the compute lines, because
it is the reason to read them and because a pricing page is exactly where
somebody decides to connect a machine. Sending them to a settings page to act on
a decision they have just made is how the decision gets lost.
The row appears for two reasons, and reading only the first is how the most
important card on a pricing page ends up with nothing to press:
- The plan rewards connecting. The sentence comes from the plan's
connectedReward, so a pricing change is not a deploy. A credit is shown
but never given a button: it is settled at renewal, so there is nothing to
press today.
- The plan requires it. A "Bring your own" plan, free and unlimited on
nothing but compute the user owns, carries
connectedReward: none and is
right to. There is no bonus for connecting, because connecting is the
plan. Judged on the reward alone such a card offers no way to attach a
machine, and everyone on it meets no_compute on their first run.
On the second, the control is primary when the plan is already theirs and
nothing is connected, because then it is the only thing on the card that does
anything. The requirement stops being mentioned once a machine is attached:
<ComputeStatus /> is where the state of that machine belongs.
That control is off by default, and showConnect turns it on. A card in a
price list answers one question — what is this plan, and how do I get on it —
and on the plan somebody already holds the answer is "Your plan". Put Connect compute in that slot instead and the one card that is not a choice carries
the loudest control on the page, having dropped the only label that said which
plan is theirs.
Connecting is a fact about the plan they hold, not about the catalogue, so the
offer belongs on <SobaCurrentPlan /> — and
<SobaBilling /> is that card above this grid, which is the
billing page you probably want:
JavaScript
<SobaBilling /> {/* the card, then the grid */}
<SobaPlans showConnect /> {/* a pricing page with no card on it */}
Pass showConnect only for the second: a page where no current-plan card
exists, and a Bring-your-own plan would otherwise state a requirement with no
way to meet it.
The connect line is unaffected either way. "10,000 runs a month when you
connect your own AI" is part of what the plan is, and somebody comparing plans
should read it on every card whether or not the button is there.
Three shapes, one set of facts#
variant decides the reading order, never the content — every layout draws the
same plan objects:
|
|
cards (default) |
A choice being made. Each plan whole and side by side, for a pricing page or an upgrade screen |
rows |
A choice being reviewed. One line per plan with the price and the control on the right, the way a settings page lists anything you can switch between — and the shape that survives a column too narrow for a grid |
table |
A choice being argued. Facts down and plans across, so two plans can be compared on the dimension that separates them, which here is the compute row far more often than the price |
JavaScript
<SobaPlans variant="table" featured="plan_pro" />
featured names the plan you are pushing, by id, and it is drawn with emphasis
in all three. A prop rather than something derived: nothing on a plan says
"recommended", and inventing the claim from the price would be this component
making a business decision on your behalf. A plan somebody is already on is
never featured — Current is the more useful of the two things to say.
The table scrolls sideways rather than wrapping when the column is too narrow,
because a wrapped comparison is not a comparison. Nothing here uses a media
query: these render inside your column, and the viewport is not your column.
<SobaCurrentPlan />#
The other half of a billing page: what they are on, and what happens next.
JavaScript
<SobaCurrentPlan />
The plan's name, a trial clock when there is one, and a usage panel beside it.
The arithmetic is the part apps get wrong, because it lives across two objects
and four fields that disagree about what zero means:
|
|
included_units: null |
No ceiling, so no bar — a progress bar against infinity implies a limit |
overage: "meter", past the allowance |
A bill, not a wall. Saying "spent" here costs you the sale |
overage: "stop", past the allowance |
A wall, and the only useful thing to say about one is the date it lifts |
connectedReward: included, machine attached |
The ceiling rises to connectedIncludedUnits, and the card says where the bigger number came from |
connectedReward: unlimited, machine attached |
The ceiling does not move; runs their own AI serves stop counting against it |
Those last two mirror allowanceFor rather than approximating it,
so the card and the run agree about what is left. A trial outranks all of them
in the status line: an allowance that resets on the 12th means nothing to
somebody whose trial ends on the 4th.
The connect offer, on the card that reports the state#
Beside the plan's name it draws the same connect line the pricing cards do, in
the same grammar — a tick for a reward, a warn mark for a requirement nothing
has met — plus the button, while connecting would still change something. This
is the one surface that can say whether the reward is in force, because it
is the only one that knows what is attached.
That makes it the home for the offer on a billing page, and the reason the grid
under it draws Your plan on the card that is
already theirs: the offer belongs beside the state it changes, and it should
appear once.
heading="" drops the section title for an app that has already titled the card
it is putting this in, and showUsage={false} leaves only the plan.
<SobaBilling />#
Both halves, in the order somebody reads a billing page: what they are on,
then what else there is.
JavaScript
<SobaBilling />
It is <SobaCurrentPlan /> over
<SobaPlans /> with one decision already made —
the connect offer sits on the card that reports the state it changes, and the
grid below keeps Your plan on the plan already theirs. Assembled by hand that
is the thing to get wrong: both components decide from the same rule, so both
draw the button, and nothing errors to say the page is asking twice.
The grid's heading follows the reader: Change plan for somebody on one,
Choose a plan for somebody on none. plansHeading overrides it, heading
retitles the card above, "" drops either, and showUsage={false} leaves only
the plan on the card. Everything else — variant, featured, showCompute,
onSelect, successUrl, cancelUrl — is passed through to the grid, because a
billing page's grid is the same grid:
JavaScript
<SobaBilling variant="rows" plansHeading="Switch plan" />
variant="rows" is the pairing rows were written for: the card names the plan,
and the list under it is the switch — the shape of a settings page rather than
of a pitch.
Headless#
Every component here is one rendering of a hook, and the hook is exported. The
moment of need lands inside your product — in the middle of your chat, your
settings page, your pricing page — and no stylesheet we ship will match the
cards already on that screen. So the rules are ours and the markup is yours if
you want it.
|
|
usePlans() |
The catalogue as decisions: price, allowance, compute, the connect row, and one take() per plan |
useCurrentPlan() |
The allowance arithmetic and the one sentence that is true of it |
useRunLimit() |
The two failures a person can act on, and the offer that answers each |
<SobaPlans />, <SobaCurrentPlan /> and <RunLimit /> render exactly these
values, so what you build from a hook and what we draw cannot disagree.
usePlans()#
JavaScript
const { offers, failed } = usePlans({ featured: "plan_pro" });
offers.map((o) => (
<YourCard key={o.plan.id} highlighted={o.featured}>
<YourPrice>{o.price.amount}{o.price.interval && `/${o.price.interval}`}</YourPrice>
<YourFigure note={o.allowanceNote}>{o.allowance}</YourFigure>
{o.connect && <YourNote onAct={o.connect.actionable ? o.connect.open : undefined}>
{o.connect.line}
</YourNote>}
<YourButton disabled={o.cta.disabled} onClick={o.cta.take}>{o.cta.label}</YourButton>
</YourCard>
));
Five things every app that drew its own pricing page got wrong at least one of,
and all five are decided here:
- Free is selected, priced is checked out.
select answers 402 for
anything with a price, so calling the wrong one is an error either way.
cta.take() picks.
- The connect row has two reasons — a plan that rewards connecting and a
plan that requires it. The second carries
connectedReward: none and is
invisible to anyone reading the reward alone.
- A credit is never a button.
connect.actionable is false for it: it
settles at renewal, so there is nothing to press today.
- Which control is loud.
connect.primary is true only on a plan that is
already theirs with nothing connected, where it is the only control that does
anything.
- Who pays for the compute.
compute[].owned is the whole argument of the
product as a boolean.
useCurrentPlan()#
JavaScript
const { state, percent, summary, trialDaysLeft } = useCurrentPlan();
state is none, trial, unlimited, within, metered or spent, and
summary is the sentence that goes with it. The ceiling, raised and
uncounted mirror allowanceFor — the rule the gateway actually
enforces — so your card and the run it describes cannot disagree about what is
left.
reward is the connected reward as a state rather than an offer — whether
it is inForce, whether it is a required prerequisite this plan has not met,
and actionable with an open() when connecting would still change something.
That last pair is what stops a "bring your own" plan from reporting that nothing
is connected and giving the person no way to fix it.
Not React#
There is a hosted fallback page, the trick Stripe Checkout uses: redirect to it,
the user connects or pays there, and they come back. That covers Vue, Svelte, Rails,
Django and everything else, without Soba shipping a component library per framework.
Underneath#
JavaScript
const { run, plan, compute, connect } = useSoba();
|
|
run |
Start a run and consume its events |
plan |
The user's current plan, allowance and usage |
compute |
What this user has connected, and its live state |
connect |
Start the connect flow yourself |
And under the hook, the same endpoint and
/v1/runs your server already calls.