TypeScript SDK
@onlyworlds/sdk is the typed client for the OnlyWorlds REST API v2. It also exports the schema’s constants (the 22 element types, their icons, colour families and field schema), generated from schema-dist at a pinned, hash-verified tag. The generated files name that tag in their header.
The 4.x line speaks v2 only and is ESM-only (Node 18 or later). Tools on the v1 API dialect (OnlyWorldsClient) or on CommonJS require() stay on the 3.x line, which is still published.
Install
Section titled “Install”npm install @onlyworlds/sdkRead a world
Section titled “Read a world”import { OwV2Client } from '@onlyworlds/sdk';
const client = new OwV2Client({ apiKey: 'ow_r_your_key' });const page = await client.list('character'); // { data, has_more, next_cursor }
for await (const character of client.listAll('character')) { console.log(character.name);}A read-only key (ow_r_) needs no PIN. Two demo keys read public sample worlds: 0000000000 (Hyperion) and 0000000001 (Moppetopia). See Keys and PINs for the key types.
const writer = new OwV2Client({ apiKey: 'ow_w_your_key', apiPin: '1234' });
const peak = await writer.create('location', { name: 'Dragon Peak' });const dragon = await writer.create('creature', { name: 'Vorrath', location: peak.id });
await writer.patch('location', peak.id, { supertype: 'Mountain' });
const breath = await writer.create('ability', { name: 'Ember Breath' });await writer.editLinks('creature', dragon.id, 'abilities', { add: [breath.id], remove: [] });The v2 rules the client follows:
- A link field has one bare name in both directions (
location,abilities). The_idand_idssuffixes belong to v1. - Never send a
worldfield: the key decides the world, and the client strips the field. nameis the one required field.''andnullare accepted and stored as''.- An
idis minted on the client when you leave it out, so a retried create stays idempotent. (A plain REST request without anidgets one from the server instead.) patchreplaces every field it sends, and an array replaces the whole list. To add or remove links without replacing them, useeditLinks.
Bulk writes
Section titled “Bulk writes”A bulk write succeeds partly by default: HTTP 200 with a status per slot. Check errors every time.
const res = await writer.bulk( [ { type: 'character', element: { name: 'A' } }, { type: 'event', element: { name: 'B' } }, ], { idempotencyKey: crypto.randomUUID() }, // keep it for a retry if the answer is lost);if (res.errors) { for (const slot of res.items.filter((s) => s.status >= 400)) { console.warn(slot.error?.code, slot.error?.message, slot.error?.doc_url); }}{ atomic: true }makes the batch all or nothing. After a failed atomic batch nothing was written, but the slots that would have succeeded still report 201: do not record those ids as created.- The server stores every 2xx answer under its idempotency key, and never an error. So when an answer is lost, retry with the same key: a stored answer replays, and an error runs again.
- A bulk answer with item errors is still a 200, so it is stored too. Fix the failed items and send them with a new key, because the same key replays the same errors.
res.wasReplayis true when the server answered from that store.
Images
Section titled “Images”const image = await writer.uploadImage(file); // a Blob, File, ArrayBuffer or Uint8Arrayawait writer.patch('character', id, { image_url: image.url });This makes two requests: a single-use ticket from the API, then the bytes straight to the media edge. The API never sees the bytes, and the edge never sees your key. Accepted formats are webp, png, jpeg and avif, read from the bytes (never SVG or gif). Each ticket counts toward the world’s daily limit and the account’s image storage. For a progress bar, call createMediaTicket() and POST the bytes to its upload_url yourself, with Authorization: Bearer <ticket>.
Follow changes
Section titled “Follow changes”let cursor; // opaque and never expires: persist itfor await (const change of client.changesAll(cursor)) { // changes arrive in (change_seq, id) order; apply them in order}Edits to the world itself (its name, calendar, public_read) do not enter the change feed. Poll client.getWorld() and compare updated_at for those. See Sync with Changes.
Errors
Section titled “Errors”Every non-2xx answer throws OwApiError with the platform’s error envelope: .status, .type, .code, .param (the field that failed) and .docUrl, which links to the code on the errors page. Show docUrl to your users. Transport failures throw OwNetworkError. err.isValidationError covers the common case.
Schema constants
Section titled “Schema constants”import { ELEMENT_TYPES, // the 22 type slugs, and the ElementType union ELEMENT_ICONS, // the Material Symbols icon for each type ELEMENT_LABELS, // plural display labels ELEMENT_SECTIONS, // the field groups and their display order FIELD_SCHEMA, // type and target of every field elementColor, // the colour of a type's family} from '@onlyworlds/sdk';
elementColor('character', 'dark');Colour marks a type’s family and the icon marks the type. Always pair a colour with its icon and label, because colour alone is not accessible. The package also ships SCHEMA.md (every field and its meaning, generated) and AGENTS.md for AI agents working in a codebase that uses it.
SDK or MCP?
Section titled “SDK or MCP?”Use the SDK for known operations in code: reads, writes, sync, bulk. For an AI exploring a world from a chat or an agent, use the MCP server at https://www.onlyworlds.com/mcp. It takes the same API-Key and API-Pin headers.
Reference
Section titled “Reference”Generated from the types of @onlyworlds/sdk 4.7.0, the version these docs pin. The full declarations ship in the package (dist/index.d.ts), with SCHEMA.md and AGENTS.md beside them.
Client options
Section titled “Client options”new OwV2Client(config) takes:
| Option | Type | Notes |
|---|---|---|
apiKey (required) |
string |
The key: an ow_w_ (write) or ow_r_ (read) world key, an ow_a_ account token (sent as a Bearer token, for the account routes), or a 10-digit legacy key. |
apiPin |
string |
The PIN, needed for writes when the world has one, and for legacy-key reads of private worlds. |
baseUrl |
string |
The API’s base URL, default https://www.onlyworlds.com/api/v2. |
pageSize |
number |
Page size for element lists, default 100 (the server’s default; at most 1000). |
changesPageSize |
number |
Page size for /changes pulls, default 100. |
fetch |
typeof globalThis.fetch |
A fetch implementation to use instead of globalThis.fetch (for tests and other runtimes). |
Methods
Section titled “Methods”| Method | Route | What it does | Returns |
|---|---|---|---|
health() |
GET /health |
unauthenticated liveness pulse. | Promise<unknown> |
getWorld() |
GET /world |
world meta (name, calendar/time fields, public_read). | Promise<OwWorldMeta> |
patchWorld(partial) |
PATCH /world |
partial world-meta update. | Promise<OwWorldMeta> |
list(type, params?) |
GET /{type}/ |
one cursor page. | Promise<OwPage> |
listAll(type, params?) |
Cursor-walk every page of a type. | AsyncGenerator<OwElement> |
|
get(type, id, opts?) |
GET /{type}/{id}/ |
optional one-level stub expansion / sparse fields. | Promise<OwElement> |
create(type, element, opts?) |
POST /{type}/ |
create one element, minting a UUIDv7 id on the client when you leave id out. | Promise<OwElement> |
upsert(type, id, element) |
PUT /{type}/{id}/ |
The local-first write primitive. | Promise<OwElement> |
patch(type, id, partial) |
PATCH /{type}/{id}/ |
DESTRUCTIVE on sent fields: arrays replace wholesale, omitted fields stay untouched. | Promise<OwElement> |
delete(type, id) |
DELETE /{type}/{id}/ |
idempotent (204 on absent). | Promise<void> |
editLinks(type, id, field, edit) |
POST /{type}/{id}/links/{field} with {add, remove} |
atomic link merge. | Promise<OwElement> |
bulk(items, opts?) |
POST /bulk |
up to ~1000 items. | Promise<OwBulkResponse> |
changes(opts?) |
GET /changes |
one page of the world’s ordered change feed. | Promise<OwChangesPage> |
changesAll(since?) |
Walk the feed from since (or from zero = full export) to the current tail, yielding ops in order. |
AsyncGenerator<OwChange, { cursor: string; head: number; }> |
|
createMediaTicket() |
POST /media/ticket |
permission to upload ONE image into this world (a write key and its PIN; no body). | Promise<OwMediaTicket> |
uploadImage(image, opts?) |
Upload one image and get its permanent public URL: a ticket from the API, then the bytes straight to the media edge (the API never sees them). | Promise<OwUploadedImage> |
|
request(method, path, opts?) |
Raw authenticated request against this client’s baseUrl. | Promise<T> |
