Soba Docs

Integrate

Components

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:

  1. 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.
  2. 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.

© 2026 Soba resolved = machine grant ∩ broker request