The backend protocol
The Studio Backend Protocol is the wire contract every Jx Studio backend implements. It lives in one package,
@jxsuite/protocol
, with two entrypoints.
@jxsuite/protocol/types
holds the request/response shapes (
DirEntry
,
GitStatusResult
,
StarterInfo
, and friends), which are environment-agnostic and importable in browsers, Bun, and Workers alike.
@jxsuite/protocol/routes
holds
STUDIO_ROUTES
, the canonical endpoint table. If you're serving the protocol over HTTP, this page plus the
route reference
is your specification; if you're
writing a platform adapter
instead, these are still the semantics your adapter must preserve.
What exactly is the contract
The
sub-path, method, and body shapes are the contract
. The transport and path prefix are not. The dev server serves the literal
/__studio/*
routes; the desktop app dispatches the same operations over typed RPC / WebSocket JSON-RPC; cloud platforms serve them over HTTP behind a session gateway prefix. All three answer the same shapes for the same sub-paths, so Studio can't tell them apart.
Each entry in
STUDIO_ROUTES
carries its method, path, a one-line contract summary, and, for optional routes, what Studio does without it:
// packages/protocol/src/routes.ts
export interface StudioRoute {
path: string;
method: StudioRouteMethod;
/**
* True when a backend may omit the route: its PAL member is optional, or it has no member and an
* adapter falls back to another route.
*/
optional: boolean;
/** One-line contract summary. */
summary: string;
/** What Studio does when an optional route is absent. */
degradation?: string;
}
The full table is generated into the protocol route reference . Consult it there rather than re-reading the source.
Core vs optional
Optional routes back optional
StudioPlatform
members. Omitting one is not an error. Studio degrades exactly as the entry's
degradation
describes: the starter picker empties, the Projects catalogue hides, the Publish panel explains git-push publishing instead, and so on.
A few optional routes back no member at all. They let an adapter serve the members it already has more cheaply, and their
degradation
names the route they stand in for.
filesRead
(
POST /__studio/files/read
) is the first: the cloud adapter folds the
readFile
calls issued in one tick into one batch request, and against a backend without the route it reads one file per request, exactly as before. Answer it only if your reads cross a network. The answer may be partial: list the paths you did not get to under
omitted
(never a 400), always answer the first path, and give each answered path its own content or error.
coreRouteNames()
and
optionalRouteNames()
split the table programmatically, so a conformance test for a new backend can iterate
STUDIO_ROUTES
and assert that every core route answers.
The core set is what a minimal backend must serve: project session and probing (
activate
,
project
,
project-info
,
resolve-site
,
create-project
), the filesystem CRUD family, component discovery, package listing, the git suite, and the AI proxy pair (
ai/chat
,
ai/models
). Everything else is optional: collab, starters, the data surface, secrets, Cloudflare publishing.
Versioning
STUDIO_PROTOCOL_VERSION
(currently
1
) bumps whenever any route's request or response shape changes incompatibly. The types are published as TypeScript source on the monorepo's release train; the package's only runtime dependency is
@jxsuite/schema
.
The error shape
Every failure is an
RFC 9457
problem document, sent as
application/problem+json
:
{
"type": "https://jxsuite.com/problems/conflict",
"title": "The request conflicts with the current state",
"status": 409,
"detail": "Pull stopped: 2 file(s) changed on both sides.",
"conflicts": ["src/app.json", "README.md"]
}
Four things are worth knowing before you implement one:
typeis what Studio keys on , and it's an absolute URI. The full list is in Studio routes , generated from the same table the reference backend answers from, so it can't drift.titledescribes the type ;detaildescribes this failure. Keeptitleidentical across occurrences and put the specifics indetail. Swapping them is the standard's most common misuse, and it's what stops Studio grouping two instances of the same problem.The status belongs to the type. If you find yourself wanting to send one type at two statuses, it's two types.
Extension members carry anything else the type documents :
conflictson a pull conflict,installUrlon a missing GitHub App installation. Studio's recovery UI reads them.
error
is emitted alongside
detail
, with the same value, for one release. It's the pre-RFC-9457 field name, kept so a client written against the older shape keeps working while it migrates. Don't write new readers against it.
Three surfaces deliberately answer 200 with an error field instead , and a conforming backend should too:
| Surface | Why |
|---|---|
Code services (
/code/*
) |
A syntax error in the author's snippet is the result of a lint, not a failed request |
| AI model catalogue | Degraded success: the catalogue is still delivered, from defaults |
| Mid-stream SSE frames | The response began with a 200; nothing can change the status, so the frame carries a problem |
Behaviors implementers must match
Route shapes alone don't capture everything. These semantics are part of the contract:
git/commitcommits the staged files if any are staged, otherwise all dirty files. Cloud backends may make commit+push atomic and treatgit/pushas a sync check (aheadstays 0).git/pullreturns409 { code: "pull_conflict", conflicts: [paths] }when local dirty files overlap remote changes; a clean pull fast-forwards.formatdispatches{ format, action: "parse" | "serialize", source? | doc?, options? }through the project's format registry. Without it, only.jsondocuments open.ai/chataccepts{ messages, tools, systemPrompt, model }and streams the normalizedStreamEventSSE defined by@jxsuite/ai/streaming-client. Studio also sends the conversation's id in anX-Jx-Ai-Sessionheader so a backend can keep a conversation's requests on one warm prompt cache. Never forward that id to a provider: check it withisAiSessionId, hash it withaffinityKey(scope, id)under a scope of your own, and forward only the hash. A missing or malformed header means no hint, never a refusal. The union and its eight members are exported, so implementers can import the type they are satisfying rather than reconstruct it. Two of them carry a turn's text:deltais the answer, andreasoningis a thinking model's chain-of-thought (reasoning_content, orreasoningat OpenRouter). A backend must forward the second as well as the first , because DeepSeek's thinking mode requires every prior turn's reasoning back on any request carryingtools, which is every request the assistant makes; a proxy that swallows those frames turns the round after a tool call into a 400 no client can repair.usageis optional: send it once, immediately beforedone, when the provider reported a count (inputTokens,outputTokens, andcachedInputTokensorreasoningTokenswhen the provider says). Studio replaces its four-characters-per-token estimate with it, and a reader that predates the frame ignores it. With OpenAI'sinclude_usagethe count arrives in a chunk of its own after the one carryingfinish_reason, so keep reading past the finish until[DONE]. Tool calls run whenever the stream carried any, whichever finish the provider reported (stop,lengthortool_calls), because some OpenAI-compatible providers end a tool-calling turn withstop. A round that endscancelledruns nothing: that is the author pressing Stop, not a finish.ai/modelsreturnsAiModelsResponse; reportconfigured: truewhen the backend holds credentials (Studio then unlocks the assistant without a locally stored key) andmanaged: truewhen the platform brokers them. It is a capability probe, so it answers200in every credential state. A non-2xx makes the probe throw, which withdraws the connect affordance from exactly the users who need it. Where a model's capabilities are known, report them per entry:toolSupport: falsemakes Studio warn that the chosen model will answer but never edit, andcontextWindowreplaces Studio's model-id prefix table, which recognizes only the families it was written for and budgets everything else at its default. A brokered backend distinguishescf_not_connected(invite),cf_reconnect_required(the grant lapsed, so explain and offer reconnection),cf_account_required(the grant is live but no account is chosen; offer a picker, since re-authorizing lands back here) andcf_upstream_error, which is reported withconfigured: truebecause a briefly unreachable provider is not a credential problem. Listing is best-effort per BYOK endpoint: a provider is not required to serve one at all (Cloudflare Workers AI's OpenAI-compatible surface has no/modelsroute), so a backend that cannot list still answers200withupstreamErrorset, and should carry the upstream's own reason inupstreamMessagerather than leave a caller unable to tell a missing route from a bad key.@jxsuite/ai/gatewayimplements both AI routes for a TypeScript backend, and it is the code the dev server itself runs.createChatHandler({ resolveUpstream })answersai/chat: it reads and checks the body, forwards it as an OpenAI-compatible streaming request, and normalizes the provider's stream into the frames above, so a backend inherits thereasoning,usageand finish rules instead of re-deriving them. The policy stays yours.resolveUpstreamdecides whose key a request uses and which base URL it may reach (the dev server never sends its own environment key to a URL the caller chose, and refuses link-local hosts), and the optionaladmit,maxBodyBytes,maxMessagesandonAcceptedadd rate limits, size limits and an audit hook. An upstream may carry asessionAffinity(the hashed key above), which the gateway forwards in the form the provider understands:prompt_cache_keyto OpenAI, thex-session-affinityheader to Workers AI and AI Gateway, and nothing to any other host. A refusal at any of those steps is anapplication/problem+jsonanswer, sent before the provider is contacted.modelsResponse(body)answersai/modelswith the200the probe requires, whatever catalogue you pass it. The module imports nothing host-bound, so the same handler runs in a Cloudflare Worker, under Bun or under Node.collabis a WebSocket upgrade speaking the@jxsuite/collabwire envelope: one socket per project, documents multiplexed by path. A plain GET (no Upgrade) answers{ collab: true, protocols, version }as the capability probe.protocolslists the WebSocket subprotocols the backend speaks, currently["jx.collab.v1"], and it is also the negotiation: Studio offers one of them asSec-WebSocket-Protocoland the backend must echo the one it accepts. A backend that lists none is offered none , because RFC 6455 §4.1 makes a client whose offer went unechoed fail the connection. An older backend keeps working exactly as before. A backend that lists only tokens Studio cannot parse gets no socket at all, which is the point: two peers whose envelopes disagree would merge divergent histories.filesanswers in stable path order , both when listing a directory (?dir=) and when searching (?glob=).readdirand glob scans report in filesystem order, which varies with a directory's write history; Studio's collection grid inserts rows in listing order, so an unsorted listing reaches the user as a table that reshuffles itself between opens. Sort by codepoint rather than locale collation, so two backends and two machines agree.cf/proxyis an allowlisted Cloudflare API passthrough (accounts and Pages projects/deployments only). The backend injects credentials (a header-borne user token on the dev server, a vaulted OAuth token on cloud), and stateless implementations must never persist them.The data routes (
data/connections,data/rows,data/push, …) intentionally bypass table permission rules. They are the owner console, and the backend boundary (loopback/token locally, collaboration permission on cloud) is the gate. The secrets routes carry env-var names only , never values. See connectors and the extension security model .
Related
Protocol route reference : the generated table of every route, method, summary, and degradation
Dev server internals : the reference implementation of these routes
Writing a platform adapter : the in-page interface these routes back