The whole procedure for adding Soba to an app, with every component live. Written for you and for your coding agent — it is the same text the Soba skill teaches.
Soba is the LLM layer for an application whose users bring their own compute.
Requests go to Soba instead of to a model provider; Soba routes each one to the
machine or plan belonging to the user it names, meters it, and bills for it.
For coding agents
This page is the procedure. Read it as Markdown at https://soba.so/docs/implement.md, where every live preview below is written out as text, or install the same procedure as a skill with npx skills add https://soba.so/skill. The steps, rules and code are one source in all three places.
See it working first#
The parts of an integration, placed where they go in one small app: the three every app needs, plus the navbar button an app adds when its AI only works once a user connects. Switch the state to watch each part react: nothing connected, a run refused, a machine attached, a machine asleep, an allowance spent.
Loading the live preview…
What your user sees#
Every Connect in your app hands off to the Soba app, on your user's Mac, which lends their AI to your app. You place none of it, but it is the half of the journey you cannot see from your code, so here it is step by step — the real app's screens, beside what your app is doing meanwhile.
Loading the live preview…
How an integration is shaped#
Every app, whatever it is — a chat, one AI button, a background job with a
page — gets the same two halves:
- The server: every LLM call goes through Soba as the signed-in user, a
session route lets the browser act for that user, and Soba's answer
(including a refusal) reaches the browser intact.
- The browser: one
<SobaProvider>, and a surface for each moment
somebody meets their compute — the settings page, every control that
starts a run, the place a run's result appears and, when the app's AI only
works on the user's own compute, the navbar.
Each surface has a component and a hook. Mount the component; build from
the hook only when the person asks for their own markup, and then show
everything the component would (see "Component or hook"). The integration is
finished when every surface in the required list exists, not when the first
request succeeds.
The server#
1. Point every LLM call site at Soba#
baseURL https://api.soba.so/v1
apiKey process.env.SOBA_KEY
An OpenAI client already speaks this, so on most projects the base URL is the
only line that changes. Prompts, tools, streaming and error handling stay
exactly as they are. Find every call site — a shared client, a route that
builds its own, a raw fetch to the provider's REST API — and move all of them.
Pass the signed-in user's id as the user field on every request.
Resolve that id on the SERVER, from the session, never from the request
body. It is what decides whose machine a run may reach, so a value the browser
can choose is a value any user can use to spend any other user's compute. This
is the one line in the integration that must not be guessed at: if the project's
auth is not obvious, read it until it is, or stop and ask. A wrong id here does
not throw — it silently pairs runs to the wrong person.
Derive it from something that outlives the database. A primary key minted
at sign-up (an autoincrement, a random UUID) is not: reset the database and the
same person arrives as somebody new, stranded from the machine they paired. An
email is not either, if the app lets people change it. sobaUserId(stable)
from @soba-so/sdk/identity turns a stable value — a username, an auth
provider's subject — into the id; use one helper for it and call it everywhere
the user is named. See https://soba.so/docs/identity.
2. Where the key comes from#
If .env.local already sets SOBA_KEY, use it. Otherwise sign the terminal in,
from the project root:
Terminal
npx -y @soba-so/cli login --no-link
It opens the person's browser once, they approve, and the terminal keeps a token
of its own; no key passes through you. Tell the person to approve before you run
it; it waits up to fifteen minutes, so give the command a long timeout or run it
in the background. Add --headless where no browser can open, and pass the
link it prints on to the person.
Then find out which app this project is. npx -y @soba-so/cli apps lists
theirs by workspace. Ask the person which one, or whether to make a new one, and
do not guess from the names; then link it:
Terminal
npx -y @soba-so/cli link --workspace "<workspace>" --app "<app>"
npx -y @soba-so/cli link --workspace "<workspace>" --new "<name>" # a new app
That writes SOBA_KEY (a development key, for this machine), SOBA_PROD_KEY
(production) and SOBA_APP_ID into .env.local, adding that file to
.gitignore if git would commit it. With nobody at the terminal the CLI never
waits on a question: a choice it needs ends the command with the flag to pass.
Every command is on https://soba.so/docs/reference/cli.
.env.local is right for Next.js, Vite and most Node servers. Where the code
that holds the key reads a different file, pass that file with --env-file
to link instead, from the project root, so the key lands where the server
will look:
| Server |
File |
Cloudflare Worker (wrangler dev) |
--env-file <worker dir>/.dev.vars |
| A server in a subfolder of a monorepo or an extension repo |
--env-file <server dir>/.env.local |
Find the file by reading how that server loads its environment, not by its
framework's reputation. The CLI adds whatever file it writes to .gitignore
if git would commit it.
On a host that keeps secrets itself — wrangler secret put SOBA_KEY, Vercel's
or Railway's environment — production takes SOBA_PROD_KEY's value under the
name SOBA_KEY. That is the person's to paste; tell them the command.
A development key reaches only the user's own AI — their machine, their
subscription, their API key. In production, SOBA_KEY is set to the value of
SOBA_PROD_KEY in the host's environment; say so to the person rather than
doing it.
If you cannot run commands but have a soba_create_key tool, call it: the
secret is returned once and nothing can show it again, so write it into
.env.local in the same step. Failing both, ask the person to run the command
above themselves. Do not leave SOBA_KEY empty and do not invent a value; both
fail at the first request, and the first fails as an auth error rather than as
a missing variable.
Put SOBA_KEY in .env.local (or the server's own file, above), and check
that file is gitignored before writing it. Never in .env, never in
.env.example, never in anything tracked, and never echoed back to the terminal.
3. The session route#
POST /api/soba/session, a route in the app that proxies
POST /v1/end_users/session with the secret key and the same server-resolved
user id, and returns the body as it came ({ session, expires_in }). It is
what every surface in the browser runs on, and the browser never sees
SOBA_KEY. Signed out, it answers 401.
4. Pass Soba's answer through#
A refused run is not an outage: no_compute (409) means nothing the run could
use is reachable for this user, payment_required (402) means the plan's
allowance is spent. The browser surfaces below turn each into the way out —
connect a machine, or upgrade — but only if the refusal reaches them with its
code. So in every route that calls Soba, return Soba's status and error body
as they came, instead of letting the client library's exception become a 500 or
a string:
JavaScript
} catch (err) {
if (err instanceof OpenAI.APIError) {
return Response.json({ error: err.error }, { status: err.status });
}
throw err;
}
A route that streams has to do this before the first byte is written: create
the completion (or start the run) first, and only then return the stream. In
the browser, keep the parsed body (setError(await res.json())) rather than
its text; that object is what the surfaces read. Branch on the code, never on
the message.
5. Which API#
chat/completions is stateless and the caller owns the loop, so it reaches
models. Reaching a user's own Claude or ChatGPT subscription means letting Soba
own the loop instead:
JavaScript
import { Soba } from "@soba-so/sdk";
import { sobaUserId } from "@soba-so/sdk/identity";
const soba = new Soba({ apiKey: process.env.SOBA_KEY });
const run = await soba.run({
user: sobaUserId(session.user.username),
model: "auto",
prompt: "Summarise everything I saved this week",
tools: [listNotes, readNote], // your code, executed in your app
});
Tools passed to a run execute in the app's own process, so they can read the
app's own data. The model asks for data by calling them; the data does not
travel in the prompt.
The browser: one provider, a surface for each moment#
Everything below reads from <SobaProvider sessionEndpoint="/api/soba/session">.
Mount it where a surface is, with sessionCacheKey set to the signed-in user's
id so reloads, navigations, tabs and other providers on the page share one
session. Never in a root layout that also renders the login page or marketing
pages: there is nobody there to mint a session for. A navbar's provider goes
in the layout that draws the signed-in navbar. All the components raise the
same pairing modal — the provider mounts exactly one, so an app with every
surface cannot put two pairings on screen at once.
| Surface |
Component |
Hook (for your own markup) |
| The settings page |
<ComputeSettings />, alone |
useCompute() + useConnectButton() |
| Above every control that starts a run |
<ConnectNudge /> |
useConnectNudge() |
| Where a run's result appears, when it failed |
<RunLimit error={error} /> then <RunError error={error} /> |
useRunLimit(error) + runErrorMessage(error) |
| The signed-in navbar, when AI only works on their compute |
<ConnectButton /> |
useConnectButton(); its panel from useCompute() and useMachineRuns() |
| The composer, in an app with one |
<ModelPicker /> beside send |
useModel() |
| Billing and pricing |
<SobaBilling />, or <SobaCurrentPlan /> + <SobaPlans />, <SobaCheckout />, <RedeemCode /> |
useCurrentPlan(), usePlans(), useCheckout(), useRedeemCode() |
| Anything a plan limits |
<SobaGate feature="…" /> |
useFeature(), plus the server check below |
Required in every app: the first three. The navbar follows from how the
app's AI is paid for (below); the composer, billing and gates follow from what
the app has — a model choice to offer, plans to sell, features to limit — and
the plans themselves are the person's to decide, not yours.
What each surface is for, and the mistakes it exists to prevent:
- Navbar, when the AI only works on their compute. Add
<ConnectButton />
to the signed-in navbar when nothing can run until a user connects — a
bring-your-own plan, a route that only allows their machine or their
subscription, an app on a development key. There, connecting is a
prerequisite rather than an offer, so the door belongs on every page: it
says "Connect compute" until something is attached, then names the machine
and its state, and once connected it is a pill that opens a popover with the
machines and what they are running. An app whose runs fall back to compute
it pays for does not need it; settings, the nudge and the run limit already
meet the person where connecting matters. Wherever it is used, it lives in
the header and only there — a popover pill is wrong inside a page.
- Settings. A settings page is where somebody checks on a machine they
already attached, not where they decide to attach one.
<ComputeSettings />
draws it as rows of that page in its own type: each machine, which agent the
app's runs go to there, and whether it is awake, asleep or serving, with
"Connect compute" or "Connect another computer". Nothing else from the
package goes on that page — <ConnectButton /> or <ComputeStatus /> beside
it draws the same machine twice. (<ConnectCompute /> is its older name.) If
there is no settings page, build one, and do not point the connect return URL
at a page that does not render it.
- Above each run-starting control. Not only a chat composer: every "Tidy",
"Summarise", "Draft", "Generate", every form that kicks off a job — find each
call site from step 1 and the control that triggers it.
<ConnectNudge />
stands there before anything is pressed and speaks only when it can help:
nothing connected and nothing else can serve, a paired machine gone quiet, or
an allowance running down that connecting would lift. It draws nothing
otherwise.
- The result's place. When a run fails,
<RunLimit error={error} />
renders where the answer was going to be: for no_compute the way to connect
(or wake the machine), for payment_required the plan to upgrade to. It goes
quiet by itself once a machine is online, so a refusal from before connecting
is never shown as the answer to connecting. <RunError error={error} />
beside it says every other failure in a sentence; the two never both speak.
One run-starting control, written both ways#
The same button in any app, as components:
JavaScript
"use client";
import { ConnectNudge, RunError, RunLimit } from "@soba-so/react";
function TidyButton({ noteId }) {
const [error, setError] = useState(null);
async function tidy() {
setError(null);
const res = await fetch("/api/tidy", { method: "POST", body: JSON.stringify({ noteId }) });
if (!res.ok) return setError(await res.json()); // Soba's body, per step 4
// …read the result as the app already does
}
return (
<>
<ConnectNudge />
<button onClick={tidy}>Tidy</button>
<RunLimit error={error} />
<RunError error={error} />
</>
);
}
And built from the hooks, in the app's own components (Your… below stands for
whatever the app already draws banners, buttons and cards with), saying
everything the components would:
JavaScript
import { runErrorMessage, useConnectNudge, useRunLimit } from "@soba-so/react";
function TidyButton({ noteId }) {
const [error, setError] = useState(null);
const nudge = useConnectNudge(); // before: is anything in the way?
const limit = useRunLimit(error); // after: the way out of a refusal
const message = runErrorMessage(error); // after: every other failure
return (
<>
{nudge.visible && (
<YourBanner title={nudge.title} detail={nudge.detail} steps={nudge.steps}
onAction={nudge.open} onDismiss={nudge.dismissible ? nudge.dismiss : undefined} />
)}
<YourButton onClick={tidy}>Tidy</YourButton>
{limit ? (
<YourCard title={limit.title} body={limit.body}
onConnect={limit.connect}
onUpgrade={limit.upgradeTo ? limit.upgrade : undefined} />
) : message ? (
<p className="error">{message}</p>
) : null}
</>
);
}
Both halves are inside a provider. In a chat, the nudge stands above the
composer and the limit and error render in the message that failed; see "The
chat stays theirs".
Component or hook#
Both halves ship, and the component is the answer. Mount it even when the
app already has its own composer, its own billing cards, its own design system
and Tailwind everywhere. Do not look at the repo's styling and decide to
rebuild one of these from its hook.
The reason is that the components are not just markup. The vendor configures
them in the Soba dashboard: the model picker's layout (by provider, one list,
custom only), which of Automatic, Fast and Best are offered, search, the agents'
marks and the line under each choice, the connect popup's look and theme. The
component reads all of that from the provider and draws it. A hand-built copy
reads none of it, so every setting the vendor changes in the dashboard silently
does nothing in their app, and nobody finds out until they compare the two.
What that looks like in practice, from a real integration: an app with its own
Tailwind composer took useModel() and rendered it as a native
<select> — no marks, no descriptions, no submenu per agent, a flat list
where the dashboard said "by provider". It worked, and it was wrong.
The components fit a foreign screen on purpose: className on each one for
placement and size, theme on <SobaProvider> (or the dashboard) for
light and dark, <ComputeSettings /> inherits the page's type, and
<ModelPicker /> is a pill beside a send button, not a card. If one still
clashes, say so to the person and leave the component in.
Reach for a hook only when the person asks for their own markup, or for a
decision no component draws (useMachineRuns(), useOpenSobaConnect(),
gating a server action on useFeature()). Every component is one rendering of
an exported hook and derives nothing of its own, and a surface built from a
hook must still show everything the component shows. Each line in the last
column is something a hand-built copy has dropped:
| Component |
Hook |
Built from the hook, it must still show |
<ConnectButton /> |
useConnectButton() |
In the header, on every page, when the app needs their compute. label, which names the machine once one is attached; the state dot from state.tone with state.word; disabled when canOpen is false. Pressing it calls open(), which raises Soba's connect flow — never a page of your own. Its panel's machine list and "Running now" with Stop come from useCompute() and useMachineRuns() |
<ComputeSettings /> |
useCompute() + useConnectButton() |
The settings page's section, as rows in the page's own type: each machine's name, hardware, runsOn.name and status.text with a dot from state.tone, lastSeen instead of the hardware while it is asleep; "Connect compute" with nothing connected and "Connect another computer" with something, both calling open(). No pill and no popover |
<ComputeStatus /> |
useCompute() |
All four of view: loading, error, empty, machines. Each machine's state in words, and stalled called out. "Nothing connected" only when view is "empty" |
<ConnectNudge /> |
useConnectNudge() |
Nothing unless visible. title, detail, the meter, the steps and stalled; a dismiss only when dismissible; the action is open() |
<RunLimit /> |
useRunLimit() |
In the place the answer was going to be. title and body; Connect calling connect(), with connectOffer as its line when there is one; Upgrade only when upgradeTo is, calling upgrade(). Nothing when it returns null, which includes a no_compute the person has since fixed by connecting |
<RunError /> |
runErrorMessage() |
Its sentence as given, nothing when it is null, and never beside a <RunLimit /> that is speaking |
<ModelPicker /> |
useModel() |
A menu, never a <select>. Automatic, Fast and Best (the auto and size options) each with its note; the app's models laid out as layout says (providers, models, choices or automatic, which is the vendor's dashboard setting); every agent in agents with its logo, as its own submenu, and an agent with no models (Codex) as a single row; providers with their marks (<ProviderMark /> draws each), and locked when the plan says so. A search field when search is on. unavailable as the reason a stale pick fell back. Nothing at all when pickable is false. Spread run onto the request |
<SobaPlans /> |
usePlans() |
Every offer: price, allowance and allowanceNote, features, the compute it runs on, the connect row, and its cta calling take(). The current plan and the featured one marked. failed when it is set |
<SobaCurrentPlan /> |
useCurrentPlan() |
summary; used and remaining as a meter unless unlimited; trialDaysLeft, periodEnd and the reward when there are any |
<SobaCheckout /> |
useCheckout() |
label on the button, busy and error, and buy(). Nothing is granted in the browser |
<SobaGate /> |
useFeature() |
The children while allowed; otherwise the reason it is not and the plans that would allow it |
<RedeemCode /> |
useRedeemCode() |
A field and an Apply that call redeem(), busy, error as given, and the reward once it lands |
<SobaBilling /> and <ConnectModal /> are compositions of the above and
have no rule of their own. <ConnectPill /> is the half of <ConnectButton />
that reports — the mark, the state and the machine, with no popover under it
and no flow behind it. It is for an app whose person asked for their own panel;
otherwise the header gets <ConnectButton />, panel included. Pressing the pill
is yours to wire, and it still opens Soba's flow through
useSoba().connect.open(); everything it says is connectSays.
<SobaProvider> is needed either way: the hooks read from it, so a hook with no
provider above it is the one mistake this split invites.
A gate draws the button; it does not secure the action. On the server, ask
before you create: soba.users.check(user, "workspaces", { use: 1 }) answers
{ allowed, reason } and counts the use in the same call, so two requests for
the last one cannot both get it. Record use you did not ask about with
soba.users.track(user, "workspaces", -1) when one is deleted. Prices,
allowances and the connected reward come from the plan objects, so changing
them later is not a deploy. Pass showConnect to <SobaPlans /> only on a page
with no current-plan card: a bring-your-own plan would otherwise state a
requirement with no way to meet it.
Three belong to no component of their own. useOpenSobaConnect() brings the
Soba app forward on this person's machine and hands back the one honest thing a
page can say about having tried — a browser cannot tell whether a soba://
link was answered, and a page that claims it worked is guessing.
useMachineRuns() is the "Running now" list out of <ConnectButton />'s
panel with no panel around it: what this person's machines are running FOR THIS
APP, and stop(runId) to end one as the person whose machine it is on; the app
that started that run sees it finish with reason: "stopped_by_user".
useRedeemCode() is a promo code field's behaviour: redeem(code) gives the
user what the app's reward hands out and re-reads their plan, so the allowance
on screen moves without a reload; <RedeemCode /> is that field already drawn.
useSoba() is the raw provider state. Reach for a named hook first and use
this one for what no hook covers — run(), connect.open(),
plan.refresh(). A page that reads compute.connected or plan.remaining
straight off it is re-deriving a decision that is already exported, and the two
copies are how our own apps drifted.
What not to write yourself#
These are not style preferences. Each one is a judgement this package already
makes, and each has been got wrong in production — in our own code, which is why
they are exported at all. If you find yourself writing one of them, delete it
and import the answer:
- A list of model names. The names come off the person's own machine, so a
list in your code refuses the model they can see on their own screen.
useModel() reads it from what they have connected.
- A model id on a run without its agent. Soba applies a person's own agent
preference only to a run whose model is blank or a size, so a named model
travelling alone goes to whichever agent is found first. Spread
useModel().run whole — it is { model, runtime? } — and have your route
forward both fields.
- "Nothing connected yet." That is a claim about somebody's account and it
is only true once the account has been read. Said while the first request is
in flight, or after it failed, it tells a person with a paired laptop that it
has vanished.
useCompute().view is the four-way answer.
- A match on an error message, or an error line of your own for a refusal.
useRunLimit and runErrorMessage read the CODE. Messages are prose we
rewrite, matching on them breaks on a copy edit, and Soba's own refusal text
names internal ids no user should see.
- Words for a cost class.
tierLabel(tier) is the one glossary for the five
economics this product is about. Four hand-written copies in this repo
disagreed with each other, and three reported somebody's own API key as
frontier supply the app had paid for.
- Where the pick is remembered, or how a stale one is handled. A model whose
machine is gone falls back to Automatic and stays picked.
useModel() does
that and tells you with unavailable.
Read https://soba.so/docs/components for each hook's full shape.
The chat stays theirs#
There is no chat component, on purpose. An app built on Soba already has its own
composer and its own messages, and they are the product. Keep every line of
that markup. What Soba adds goes INTO it, and these are the things a chat on
Soba has to handle — check each one against the code, not against memory:
In the composer
<ModelPicker /> in the composer's footer, beside the send button. The
component, per the section above, not a copy.
- Spread
useModel().run onto the request, and have the route forward
model and runtime both.
- Send becomes Stop while a run is going. Stop aborts the request's
signal, and a run the person stopped is not an error to show. Neither is
reason: "stopped_by_user", which is the machine's owner stopping it
from their side.
<ConnectNudge /> above the composer. It draws nothing until it has
something to say.
- Images, only if the composer already takes them: forward each message's
images and the runtimes that can see them will.
In the messages
delta text is appended as it arrives. thinking is the running total
and REPLACES what is shown — appending it repeats every sentence. Fold it
away by default.
status events are what the agent is doing: show message, and
toolInput beside it when there is one. state: "interrupted" means
the person's machine went offline mid-run and "resumed" that it came
back; say so in the answer, because the run goes on either way.
- A failed run renders
<RunLimit /> where the answer was going to be, and
<RunError /> beside it for everything else. Branch on the reason
code, never the message. Offer Retry only when retryable is not false.
- Copy on every answer, on hover, copying the text as written (markdown, not
rendered HTML). Retry on the last one.
done carries usage and tier. If the app shows what answered,
word the tier with tierLabel().
Across turns
done and error carry a session. Send it back as resume
on the next run, and have the route forward it, so a follow-up continues the
agent's conversation instead of starting a new one with the whole transcript
pasted in.
An app with no chat at all gets one built in its own components and styles,
the same way it would build any other screen — with everything above in it.
Proving it#
soba/echo is answered by Soba itself — no machine, no metered supply,
nothing billed — and still proves the key, the base URL and the user field:
Terminal
curl https://api.soba.so/v1/chat/completions \
-H "Authorization: Bearer $SOBA_KEY" \
-H "Content-Type: application/json" \
-d '{ "model": "soba/echo", "user": "usr_123",
"messages": [{ "role": "user", "content": "ping" }] }'
A curl is evidence the credential works. It is NOT evidence the app is
integrated — the dashboard's integrate step ticks on a request arriving from the
real call site, and it cannot tell a curl from a deployment. Do not send one to
make a checklist go green. Before calling the integration done, walk the
required surfaces as a signed-in user with nothing connected: settings says
"Connect compute", the nudge stands above each run-starting control when
connecting would help, pressing one shows the limit card where the result
would have been (and the navbar says "Connect compute", where there is one) —
then connect, and each of them goes quiet.
A new project rather than an existing one#
There is no separate procedure for this and no question to ask the person: look
at the directory. If there is no app yet, run npx create-soba@latest and let
it write one — it ships a correct /api/soba/session, which is the security
boundary of the whole integration, and a plausible hand-written one is the
failure mode that matters. Then the server half is already done and what
remains is the key.
What is not yours to do#
Pairing is a person installing the Soba app on their own laptop and approving
it. Pricing, allowances and rewards are decisions about somebody's business.
Do neither on their behalf; say what is left and stop.
Every part, live#
Each component in every state that changes what it draws, with the line of code that places it and the hook to build your own version from. The same parts have a page of their own at soba.so/components, with more room for each.
Loading the live preview…