02 — Worlds and resources
The typed REST surface: one client object, a resource module per route group, and a raw escape hatch for everything else.
The client
import { createClient } from '@blueforge-studio/worldforge-sdk';
const client = createClient({ baseUrl, apiKey });
| Resource | Routes it covers |
|---|---|
client.worlds | GET/POST /worlds, GET/PATCH/DELETE /worlds/:id |
client.atlas | /atlas/nodes, /atlas/edges |
client.regions | /worlds/:id/regions incl. reveal / hide |
client.wiki | /wiki/pages CRUD + restore |
client.packs | /packs CRUD, duplicate, assets incl. multipart upload |
client.proposals | /proposals list/commit/reject/undo, per-world inbox, policies, auto-accept pause/resume |
client.printShops | /print-shops (+ near/radius_km filter), /print-orders |
client.collab | memberships, feed, presence (a snapshot read — the live view is SSE, see 04) |
client.raw | every other route — same auth, errors, query building |
Every method takes a trailing opts?: RequestOptions — { query, body, signal, headers, parse } — and returns a promise of the (type-only)
response. IDs are encodeURIComponent-escaped into the path for you.
Worlds
// Create — schema_version is the world JSON schema ladder (see /offline).
const { id, version } = await client.worlds.create({
name: 'My Campaign',
schema_version: 1, // positive integer; the server's tests all send 1
json: worldDocument, // typed `{} | null`, not `unknown` — pass a real object
});
const list = await client.worlds.list();
const detail = await client.worlds.get(id); // includes `json`
// PATCH requires EXACTLY ONE mode flag (else 400 commit_required):
await client.worlds.patch(id, { json: next, confirm: true }); // write now
await client.worlds.patch(id, { json: next, propose: true }); // file a proposal
WorldPatchBody also allows name, proposal_id, diff_summary,
changeset_id and base_version (optimistic-concurrency version; a
mismatch is a version_conflict error carrying currentVersion). patch
resolves to unknown — the response shape depends on the mode. A world is
removed with await client.worlds.delete(id).
confirm: true needs the world:commit scope (owner only);
propose: true needs world:write and is open to GMs — the proposal
lands in the owner's review inbox (client.proposals.inbox(worldId)).
Resource reference
Signatures as exported; opts is the trailing RequestOptions.
| Resource | Methods |
|---|---|
worlds | list(), create(body), get(id), patch(id, body), delete(id) |
atlas | listNodes(worldId), getNode(id), createNode(body), patchNode(id, body), deleteNode(id), listEdges(worldId), createEdge(body), deleteEdge(id) — worldId goes in the worldId query parameter |
regions | list(worldId), create(worldId, body), patch(worldId, regionId, body), remove(worldId, regionId), reveal(worldId, regionId, body?), hide(worldId, regionId, body?) |
wiki | list(worldId) (extra filters via opts.query), get(id), create({ worldId, title, body?, kind?, audience? }), patch(id, body), remove(id), restore(id) |
packs | list(), get(packId) (with assets?), create(body), patch(packId, body), remove(packId), duplicate(packId), listAssets(packId), uploadAsset(packId, file: Blob | FormData) — a bare Blob is sent as the multipart field file |
proposals | list(query?), commit(id, body?), reject(id, body?), undo(id, body?), inbox(worldId), listPolicies(worldId), createPolicy(worldId, body), patchPolicy(worldId, policyId, body), deletePolicy(worldId, policyId), pauseAutoAccept(worldId), resumeAutoAccept(worldId) |
printShops | list(query?), get(id), createOrder(body), listOrders(), orderQrRaw(id) (the QR image as a Response) |
collab | listMembers(worldId), addMember(worldId, { userId, role? }), removeMember(worldId, userId), feed(worldId, query?), presence(worldId) |
raw | request(method, path), get, post, patch, delete, response(method, path) — all take opts (no put) |
Most write bodies are Record<string, unknown>: the SDK does not pin
their shape, so check the route reference in docs/api/ for the fields.
Exported types
From the root entrypoint: WorldForgeClient, ClientOptions,
RequestOptions, QueryParams, TransportOptions, WorldForgeApiErrorBody,
the auth types listed in 01, and the data types
WorldSummary, WorldDetail, WorldCreateBody, WorldCreateResponse,
WorldPatchBody, AtlasNode, AtlasEdge, WikiPageSummary, WikiPage,
PackSummary, PackAsset, Proposal, ProposalStatus
('pending' | 'committed' | 'rejected' | 'expired' | 'stale'),
ProposalPolicy, PrintShop, PrintOrder, Region, Membership,
FeedItem. Most are open shapes ([key: string]: unknown) because new
fields ship additively. RegionsResource and PrintShopsResource are also
exported as classes; the other resource classes are not.
Transport (the fetch wrapper every resource uses: get/post/patch/delete,
request, raw, url) and buildQuery(query) are exported for building
your own resource modules. buildQuery skips undefined/null, repeats
the key for arrays, and returns '' or '?a=1&b=2'.
Errors and opt-in validation
Every non-2xx throws WorldForgeApiError (status, code, reason,
requestId, body). Responses are type-only by default — no runtime parsing
unless you ask:
const world = await client.worlds.get(id, {
parse: { parse: (v) => MyWorldShape.parse(v) }, // e.g. WorldSchema from /offline
});
This keeps the hot path free of zod-version skew and makes validation an explicit opt-in when a consumer's schema must win over the server's.
The raw escape hatch
Typed methods cover the core surface; everything else is one call away:
// e.g. a route the SDK hasn't wrapped yet
const items = await client.raw.get<InventoryItem[]>(`/worlds/${id}/inventory`);
// non-JSON responses (QR codes, PDFs, files) → the Response itself
const res = await client.raw.response('GET', `/print-orders/${orderId}/qr`);
Query parameters, JSON bodies, headers and AbortSignal work the same
as on typed methods ({ query: { … }, body: { … }, signal, headers }).
raw.response returns the Response untouched (still throwing on non-2xx);
raw.get/post/... return parsed JSON, or undefined for a 204 / empty body.
Pagination and filtering
There is no pagination helper — list methods return whole arrays. Routes
take their filters as query parameters — pass them inline (list(worldId, { query }) on wiki, a query first argument on printShops.list,
proposals.list and collab.feed):
await client.wiki.list(worldId, { query: { includeDeleted: 'true' } });
await client.printShops.list({ near: '55.67,12.56', radius_km: 10 });
await client.atlas.listNodes(worldId);