Skip to content

Moving a block off the bridge ​

Most data a block used to fetch by posting a message to its host now has a REST route, and a block can call those routes today with the token it already has. This page maps each bridge hook to its route, and says what each credential reaches.

Which transport for what: Concepts → Transport models.

@civitai/sdk runs in a block now — and nothing is asking you to move

@civitai/app-sdk + @civitai/blocks-react continue; no app has to move.

On the default block token, @civitai/sdk's initialize() succeeds (since 0.5.0; 0.4.0 threw). app.storage, app.site on blocks/* paths, app.requestGrants and all of app.host (bridge messages underneath) work. app.orchestration rejects before it sends for a signed-in viewer, and app.site outside blocks/* returns the API's own 401/403 — with the auth: "oauth" opt-in appended — on a route that requires auth, while a public route answers anonymously.

auth: "oauth" gets a real OAuth access token that /api/v1, the orchestrator and the MCP accept — but it is a trade that costs the block routes which re-verify the bearer as a block token; see choosing your credential. The replacement table below is a map of routes, not packages, and applies either way.

The bridge is not deprecated. A block that moves its data calls uses both — fewer messages, not zero.

Before you write any code ​

Scope binding is per-route — declare only what you use ​

A route that declares a required scope binds only that scope against your block context: GET /api/v1/models/{id} declares models:read:self and 403s unless ?id is the model your block renders beside; GET /api/v1/blocks/buzz declares buzz:read:self and ignores your other scopes. A route with no required scope binds nothing — GET /api/v1/blocks/models accepts any valid block token, clamped only by its maturity ceiling. (An unrecognised scope is rejected outright, but the manifest validator catches that first.)

So declare the scopes your app actually uses, and pass each route the parameters its scope binds on.

Your tests will pass while exercising nothing ​

After the port your block sends no postMessage operations, so a mock host answers nothing and the suite goes green having tested none of the new path. Move the fake to fetch — see Testing below.

Choose your credential: the manifest's auth field ​

json
{
  "blockId": "my-app",
  "auth": "oauth"
}
authCredentialReaches
"block-token" (default when omitted)the block-scoped JWTthe block-token routes under /api/v1/blocks/*, plus /api/v1/models/{id}
"oauth"a real OAuth access token for the block's own client/api/v1, the orchestrator and the MCP, unchanged

Omitting auth keeps today's behaviour, so an existing manifest needs no edit to start calling the block routes.

The OAuth mint is live in production, but block-token is the default for good reasons:

  • The block token reaches further than its name suggests — every route under /api/v1/blocks/* except the publisher tooling (submit-version, withdraw, submissions and dev-token, which take the developer's own API key), plus /api/v1/models/{id}. If that is your whole surface, auth: "oauth" buys nothing and costs the routes below.
  • The oauth path has little production use yet, and its consent surface is new — declare the narrowest scope set you can.

auth: "oauth" gives up four groups of block REST routes

An OAuth token resolves to the same (app, viewer) claims, but these routes re-verify the raw bearer as a block JWS, which it is not, so they fail with a 401 or 403: every app storage route (app-storage/*), every shared storage route (shared-storage/*), every workflow route (workflows/estimate, submit, poll, cancel and query) and user-checkpoint/set. Routes that do not re-verify the bearer work on either token — buzz, me, models, images, gated-images, generation-resources, tools, tip, tip-allowance and the collections routes among them.

If your app uses app storage, it cannot use auth: "oauth" today — the document store included: the refusal covers every apps:storage* scope. The server refuses the pair when a moderator approves the version (and on a direct git push), not when civitai app submit uploads it, and civitai app validate doesn't check it either — so fix it before submitting, or the request sits in review until approval fails. Left unrefused, every storage read and write would 401 as soon as a viewer signs in.

If your block generates, stay on the host-proxied workflow routes. They carry the per-call Buzz budget, the per-viewer and per-app daily caps, the maturity clamp and the app-block:<appId> attribution tag; a direct orchestrator call keeps only the per-viewer consent budget.

oauth is for a block that needs the general /api/v1 surface or the orchestrator directly, and none of the routes above.

The adapter shape ​

Put a thin per-app layer in src/platform/ rather than calling the API from components: one place to own the token, one place to fake in tests, and no wire shapes in your UI. It uses the validated host origin and the bearer the block already holds — the same direct-fetch pattern useTip and useGenerationResources use internally:

tsx
// src/platform/useApi.ts
import { useCallback } from 'react';
import { useHostOrigin, useBlockToken } from '@civitai/blocks-react';

/** A fetcher bound to this block's own credential. Throws until BLOCK_INIT. */
export function useApi() {
  const host = useHostOrigin();
  const { raw } = useBlockToken();

  // useCallback is load-bearing, not tidiness. A fresh function identity every
  // render turns the usual `useEffect(..., [call])` into an unbounded request
  // loop — against money-scoped routes. blocks-react's own useTip does the same.
  return useCallback(
    async function call<T>(path: string, init?: RequestInit): Promise<T> {
      if (!host) throw new Error('not ready: BLOCK_INIT has not landed');
      const headers = new Headers(init?.headers);
      headers.set('authorization', `Bearer ${raw}`);
      const res = await fetch(`${host}/api/v1/blocks/${path}`, { ...init, headers });
      if (!res.ok) throw new Error(`${path}: ${res.status}`);
      return (await res.json()) as T;
    },
    [host, raw],
  );
}
tsx
// src/platform/buzz.ts — a capability, named the way your app thinks about it
import { useCallback } from 'react';
import { useApi } from './useApi';

export interface BuzzBalance {
  blue: number;
  green: number;
  yellow: number;
}

export function useBuzzBalanceFetcher() {
  const call = useApi();
  return useCallback(() => call<BuzzBalance>('buzz'), [call]);
}

Components import the capability, never the fetcher.

Two things this layer does not do for you

Gate on readiness. The function throws until BLOCK_INIT lands, so gate the call site on useBlockContext().ready, or a mount-time read fails with an unhandled rejection first.

Retry the 401 race. For a request that outlived its token, retry once through useBlockToken().refresh().

@civitai/sdk's initialize() runs in a block on either credential, so useApi can later become app.site with nothing above it changing.

Hook replacements ​

Every hook @civitai/blocks-react exports — the package root, plus useBlocksStyles from @civitai/blocks-react/ui. "Local" means the hook never talked to the platform — it is your own code now, with nothing to replace.

Don't trust a hook count — enumerate

The package gains hooks between releases. List the ones in your installed version:

bash
node -e "import('@civitai/blocks-react').then(m => console.log(Object.keys(m).filter(k => k.startsWith('use')).sort().join(' ')))"
Bridge hookReplacement
useAppStorageThe document store: a private collection through app.storage.collection(name) (@civitai/sdk 0.14.0 or later) or /api/v1/blocks/storage/*. The legacy /api/v1/blocks/app-storage/{get,set,delete,list,quota} routes (block-token only) still work and are deprecated; see Moving from the legacy stores
useAppWorkflowsPOST /api/v1/blocks/workflows/query. In @civitai/sdk (0.12.0 or later): app.workflows.list({ cursor?, limit? }), which resolves { workflows, cursor }
useBatchEstimateNo route yet — ESTIMATE_WORKFLOW_BATCH is a bridge message, and no /api/v1/blocks/* route prices a batch. Over REST, price each body with POST /api/v1/blocks/workflows/estimate; in @civitai/sdk that is one app.workflows.estimate(body) per body
useBlockAnalyticsKeep — a bridge message with no route. Its events are recorded once your manifest declares them; @civitai/sdk's app.host.track() sends the same message (from 0.11.0). See the note below
useBlockBreakpointLocal — element measurement, keep as is
useBlockContextKeep — the handshake payload arrives over the bridge and has no route
useBlockResizeKeep — host UI; only the host can size your frame
useBlockSettingsKeep — part of the handshake payload
useBlockThemeKeep — part of the handshake payload
useBlockTokenKeep — this is where the bearer for a direct fetch comes from, and its refresh() covers the 401 race
useBlocksStylesLocal — stylesheet injection
useBuzzAccountsNo route yet
useBuzzBalanceGET /api/v1/blocks/buzz
useBuzzPurchaseKeep — host UI
useBuzzTransactionsNo route yet
useBuzzWorkflow/api/v1/blocks/workflows/{estimate,submit,poll,cancel}. In @civitai/sdk (0.12.0 or later): app.workflows.estimate, submit, get (the poll route), watch, wait and cancel — see Money calls for what changes in the port
useCheckpointPickerKeep the picker (host UI); .persist → POST /api/v1/blocks/user-checkpoint/set
useCivitaiNavigateKeep — host UI
useCivitaiRouteKeep — reads the host-owned sub-path from the handshake and ROUTE_CHANGED; off the bridge your own router owns the URL, so there is nothing to replace
useCollectionFollowPOST /api/v1/blocks/collections/{id}/follow
useConsentUnavailableKeep — consent is host UI
useCreatePostFromAppStays on the bridge, by design — see Posting from an app
useDailyCompensationNot carried
useDirectLoadKeep — see Embedding
useDomainMaturityNo direct equivalent — keep the hook. Gate through its derived isSfw / isLevelAllowed, never on a raw bitmask: maxBrowsingLevel is the domain's ceiling (identical for every viewer on it, including one whose NSFW setting is off)
useEntitlementsGET /api/v1/blocks/entitlements — the read half of the goods rail, scope goods:read:self. The reply is scoped server-side to your own appBlockId, so the scope is consent-EXEMPT and a read-only block needs no purchase power. Revoked entitlements are excluded server-side
useGatedImagesGET /api/v1/blocks/gated-images
useGenerationResourcesGET /api/v1/blocks/generation-resources?ids=
useGoodPurchasePOST /api/v1/blocks/goods/purchase — scope goods:purchase:self and a goods entry in your manifest; without both the endpoint answers 404. Same direct-fetch-on-the-validated-origin shape as useTip, and the buyer is always the token subject (the block never supplies a user id). The platform renders no confirmation for the purchase itself, so the confirm UI is yours. Its optional top-up on insufficient funds opens useBuzzPurchase — host UI, and the opposite money direction
useHostOriginKeep — it is the validated base URL every direct fetch needs
useImagePickerKeep — host UI; Civitai opens the picker over the viewer's own posted and generated images in its own chrome, and the block receives only the pick. Page apps only. @civitai/sdk has no equivalent yet
useImageUploadKeep — host UI. (@civitai/sdk has covered it since 0.3.0 as host.openImageUpload, usable on either credential — but that is still a bridge message, not a route.)
useNestedDocumentLocal — fetches one of your own bundled documents into a srcdoc string; it never talks to the platform
usePrepareTrainingDatasetNo route yet — PREPARE_TRAINING_DATASET is a bridge message, and no /api/v1/blocks/* route prepares a training dataset
usePublishGenerationOutputsStays on the bridge, by design
useRequestConsentKeep — consent is host UI
useRequestSignInKeep — host UI
useResourcePickerKeep — host UI; a sandboxed frame cannot draw the picker
useRunConfirmedStepKeep — host UI; the job runs only after the viewer confirms its price in a dialog Civitai shows in its own chrome
useRunTrainingKeep — host UI; the run starts only from the consent dialog Civitai shows in its own chrome
useSaveImageKeep — host UI; the host fetches from origins it allowlists
useSharedStorageThe document store: a shared collection, with votes as a declared mark. The legacy /api/v1/blocks/shared-storage/* routes still work and are deprecated; their list takes a mine filter the hook has no equivalent for — see Listing only rows the viewer wrote. App Store sub-listings still read the legacy shared store
useTipPOST /api/v1/blocks/tip
useTipAllowanceGET /api/v1/blocks/tip-allowance
useUploadImageBytesKeep — a bridge message with no route. It sends OPEN_IMAGE_UPLOAD with bytes, and the host stores, persists and scans the image; no /api/v1/blocks/* route accepts an image upload. Page apps only, and the token needs posts:write:self. See Posting from an app
useViewerGET /api/v1/blocks/me. Keeping the hook is not free: it is a GET_VIEWER bridge round trip on mount, not a snapshot read — but it is the authoritative self-read, where useBlockContext().viewer is the coarser BLOCK_INIT snapshot
useWildcardPackNot carried
useWorkflowBatchNo batch route — the hook is one watch() and one cancel() per workflow id, with no server-side batch. Over REST, poll and cancel each id with /api/v1/blocks/workflows/{poll,cancel}; in @civitai/sdk, app.workflows.watch(id) (or wait(id)) and app.workflows.cancel(id) per id

Keep your track() calls — and declare the events

useBlockAnalytics() sends TRACK_EVENT, and both hosts forward it: your events reach your app's analytics. An earlier version of this page said the calls were no-ops and could be deleted. That is no longer true, and deleting them loses data.

Two things to do when you port:

  • Declare every event under analytics.events in your manifest. Only declared events, with declared properties of the declared type, are recorded; an undeclared event is counted as a drop and its name is not stored.
  • Move the calls, don't drop them. TRACK_EVENT has no REST route. If you are moving to @civitai/sdk, app.host.track(eventName, properties) sends the same message (@civitai/sdk 0.11.0 or later).

See Custom events for the declaration, the limits and where the numbers show up.

Reading and writing ​

Routes are addressed by path, so a route the API gains needs no package release:

tsx
import { useApi } from './platform/useApi';

declare const call: ReturnType<typeof useApi>;

// Read. Paging differs per route — shared-storage nests its cursor under
// `metadata`, while app-storage returns a top-level one. Check the route.
const page = await call<{ items: unknown[]; metadata: { nextCursor?: string } }>(
  'shared-storage/list?limit=50',
);

// Write
await call('shared-storage/append', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    value: { title: 'My entry', body: 'optional', data: { anything: true } },
  }),
});
  • Treat every refusal as a refusal. No route has a success shape that means "could not read", so branch on the error, never on an empty result. A missing cursor proves a scan completed only if you read it from the right place; otherwise you get a silent one-page truncation.
  • Read message ?? error. Block-scope middleware rejections carry error; service-layer refusals carry message. Branch on the HTTP status.

Listing only rows the viewer wrote ​

shared-storage/list takes a mine filter that narrows the page to rows the viewer authored, so an app showing someone their own submissions need not page the whole board. There is no bridge-hook equivalent, and none is planned.

http
GET /api/v1/blocks/shared-storage/list?limit=50&mine=true
  • Values: true filters to the viewer's rows; false means the whole board, the same as omitting the key. Anything else is a 400, including ?mine= with an empty value (how most query-string helpers serialise an unset field or undefined), a bare ?mine, the key sent twice, and 1/yes/TRUE. Drop the key when the filter is off.
  • It composes with prefix, limit and pagination; the envelope, cursor and apps:storage:shared:read scope are unchanged.
  • Anonymous callers get an empty page, indistinguishable from "no rows yet". Check whether the viewer is signed in (useBlockContext().viewer or GET /api/v1/blocks/me) before rendering an empty state.
  • It is not a privacy control. It filters on the token subject (no caller-supplied id), but every row already carries its authorUserId and is world-readable either way.

Host UI stays on the bridge ​

Only the host can draw this UI, so @civitai/blocks-react's hooks remain the path on either transport:

tsx
import { useResourcePicker } from '@civitai/blocks-react';

const { open } = useResourcePicker();
const picked = await open({ resourceType: 'Checkpoint' });
if (picked) {
  console.log(picked.modelName, picked.versionId);
}

A dismissal resolves to nothing chosen — that is an answer, not an error.

Do not do this ​

app.orchestration is not a replacement for the workflow routes

The block workflow routes delegate to procedures carrying the per-call Buzz budget, the per-viewer and per-app daily caps, the maturity clamp, and the app-block:<appId> attribution tag. app.orchestration is the raw orchestrator and has none of them.

On the default credential app.orchestration rejects before it sends (since @civitai/sdk@0.5.0). On an auth: "oauth" block the substitution type-checks, passes tests and reaches the orchestrator. Submit through /api/v1/blocks/workflows/*, which in @civitai/sdk (0.12.0 or later) is app.workflows. Those routes take the block token only, so an auth: "oauth" block cannot use them; see choosing your credential.

Likewise prefer POST /api/v1/blocks/workflows/query (app.workflows.list()) to orchestration.queryWorkflows({ tags }): the route forces the app tag from the token server-side, while the client trusts caller-supplied tags.

Testing ​

Move the fake to fetch, and record what was requested, not only what came back:

ts
const calls: string[] = [];

globalThis.fetch = (async (input: RequestInfo | URL) => {
  calls.push(String(input));
  return new Response(JSON.stringify({ items: [], metadata: { nextCursor: null } }), {
    status: 200,
    headers: { 'content-type': 'application/json' },
  });
}) as typeof fetch;

Then assert on calls — the requests your app made, not just the values it rendered. A fixture that fits on one page cannot see a dropped cursor parameter.

Keep your existing mock host for the calls that remain on the bridge — the resource picker, Buzz purchase, sign-in, publishing.

Known gaps ​

Document these as gaps; do not design around them yet.

GapState
useImageUpload / OPEN_IMAGE_UPLOADNo REST twin, and deliberately so — it raises host UI. @civitai/sdk exposes it as host.openImageUpload (since 0.3.0, either credential), which is the same bridge message behind a different package.
usePublishGenerationOutputsSame shape, and staying on the bridge by design — host chrome is the consent control. @civitai/sdk's host.publishGenerationOutputs (also 0.3.0) is the same message.
useBlockAnalytics / TRACK_EVENTNo REST twin — a fire-and-forget bridge message, recorded only when the manifest declares the event. @civitai/sdk carries it as host.track from 0.11.0. See Custom events.
Anonymous app-storage readsREST returns 403 where the bridge resolved a read to null; handle both.
useBuzzAccounts, useBuzzTransactions, useDailyCompensation, useWildcardPackNot carried.

Because of the first two, a block that uploads images or publishes generation outputs cannot reach zero bridge messages — by design.

Next ​

Civitai Developer Documentation