This is the abridged developer documentation for OnlyWorlds
# OnlyWorlds Docs
> An open schema for world data, and the API, SDKs and agent interfaces to build on it.
[Build an app](/docs/development/packages)The TypeScript, Python and Unity SDKs, or plain REST.
[Connect an agent](/docs/development/ai)The MCP server, agent seats, the LLM guide and the toolkit.
[Understand the schema](/docs/schema/)The 22 element categories, their fields and typed links.
[Build a world without code](https://www.onlyworlds.com/start)Start at onlyworlds.com/start, or open Atlas: a world as plain files on your own disk, no account needed.
## Quickstart
[Section titled “Quickstart”](#quickstart)
List the Characters in Hyperion, the public example world. Its demo key `0000000000` is read-only and reads without a PIN; Moppetopia’s demo key is listed on [Keys and PINs](/docs/getting-started/keys#demo-keys).
```bash
curl -H "API-Key: 0000000000" \
"https://www.onlyworlds.com/api/v2/character/?fields=id,name"
```
```json
{
"data": [
{ "id": "068e92dc-10ec-7225-8000-7efd678bd862", "name": "Mr. Steinway" },
{ "id": "068e92db-4c40-79eb-8000-8ef44c0d52b4", "name": "Rachmaninov" },
{ "id": "068e2ddb-83c6-71a7-8000-a45b8b711e25", "name": "The Consul" }
],
"has_more": false,
"next_cursor": null
}
```
Swap in a key for your own world and the same call reads it. [Getting Started](/docs/getting-started/) walks through keys and the first write.
Looking for tools to build a world with, rather than to build on? Start at [onlyworlds.com/start](https://www.onlyworlds.com/start), or open [Atlas](https://atlas.onlyworlds.com).
# API Error Reference
> Every error code the OnlyWorlds API returns, what causes it, and how to fix it.
Every error from the current API (`/api/v2/`, including `/bulk` and `/changes`) answers in one envelope. A response with a non-2xx status has this body:
```json
{
"error": {
"type": "invalid_request",
"code": "invalid_link",
"message": "friends references Character '0123…' which does not exist.",
"param": "friends",
"doc_url": "https://onlyworlds.github.io/api/errors#invalid_link"
}
}
```
| Field | Meaning |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | The error family, one of a fixed set: `invalid_request`, `authentication_error`, `permission_error`, `not_found`, `rate_limited`, `idempotency_error`, `api_error`. It names the family only: one family holds codes with different statuses and different fixes |
| `code` | The specific, stable identifier. Decide what to do from this, never from `type` |
| `message` | Human-readable and safe to log or show. Its wording may change: do not parse it |
| `param` | The field or query parameter at fault, when there is one; otherwise `null` |
| `doc_url` | This page, at the code’s section (`#`) |
Each code has its own section below, and the summary table lists them all. A fetch of this page gets every section; each code’s section has the id ``, so `doc_url`’s `#` lands on it. The same page as Markdown is at [`/api/errors.md`](/api/errors.md), where each section is the heading `### `. In `/bulk`, each failed item carries this same envelope: see [Bulk Errors](#bulk-errors). MCP tools report failures as tool errors carrying the same human message, without the envelope fields.
Note
The [Classic API](/docs/development/api/classic) at `/api/worldapi/` answers in its own legacy shape, `{"detail": …}`. The codes on this page apply to `/api/v2/` only.
## Error Codes
[Section titled “Error Codes”](#error-codes)
| Code | Type | Status | What happened |
| --------------------------------------------- | ---------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| [`invalid_request`](#invalid_request) | `invalid_request` | `422` | The body or query is malformed: an unknown field or query parameter, a bad value, or a wrong-shaped payload; `param` names the culprit. |
| [`invalid_link`](#invalid_link) | `invalid_request` | `400` | A link field names an element that does not exist in this world (or among a /bulk batch’s surviving items). |
| [`id_conflict`](#id_conflict) | `invalid_request` | `409` | A create supplied an id that already exists, in this world or another (element ids are unique across all worlds). |
| [`resync_required`](#resync_required) | `invalid_request` | `409` | A guest’s /changes cursor predates a change to what it can see, or is not this caller’s cursor shape: pull again from since=0. |
| [`already_member`](#already_member) | `invalid_request` | `409` | The invite names someone who is already a member, or the accepting account already is one. |
| [`pin_required`](#pin_required) | `invalid_request` | `409` | A member without an account PIN tried to accept an invite or mint a write key. |
| [`apply_failed`](#apply_failed) | `invalid_request` | `422` | A /bulk item passed validation but its write failed a database constraint (most often an id that already exists in another world). |
| [`invalid_credentials`](#invalid_credentials) | `authentication_error` | `401` | The API key or PIN is missing, unrecognised or wrong. |
| [`key_revoked`](#key_revoked) | `authentication_error` | `401` | The API key was recognised but has been revoked. |
| [`permission_error`](#permission_error) | `permission_error` | `403` | The credential is valid but lacks the scope for this route (e.g. a read key on a write route). |
| [`not_author`](#not_author) | `permission_error` | `403` | A contributor or guest key changed, replaced, relinked or deleted an element someone else created. |
| [`owner_only`](#owner_only) | `permission_error` | `403` | A member key, even a co-builder’s, tried to change the world’s own settings. |
| [`guest_not_supported`](#guest_not_supported) | `permission_error` | `403` | A guest key called a route guests cannot use yet. |
| [`storage_full`](#storage_full) | `permission_error` | `403` | The account the upload counts against has used all of its image storage. |
| [`not_found`](#not_found) | `not_found` | `404` | The element or route does not exist in this world. |
| [`rate_limited`](#rate_limited) | `rate_limited` | `429` | Too many failed PIN attempts; retry after the Retry-After header’s seconds. |
| [`quota_exceeded`](#quota_exceeded) | `rate_limited` | `429` | The world has used its image upload tickets for the day; retry after Retry-After. |
| [`idempotency_error`](#idempotency_error) | `idempotency_error` | `409` | An Idempotency-Key was reused with a different request body. |
| [`api_error`](#api_error) | `api_error` | `500` | An unexpected server-side error; the envelope is kept even here. |
| [`server_busy`](#server_busy) | `api_error` | `503` | Every request slot stayed full for 10 seconds; retry after Retry-After. |
| [`payload_too_large`](#payload_too_large) | `api_error` | `413` | The request body is over 8 MB. |
| [`media_unavailable`](#media_unavailable) | `api_error` | `503` | Image upload is not configured on the server right now. |
### invalid_request
[Section titled “invalid_request”](#invalid_request)
**Type** `invalid_request` · **Status** `422`
The request body or query is malformed: an unknown field, a server-managed field in a write, an unknown query parameter, a value that fails validation, text over its length limit, extension fields over the size cap, a malformed id, or a wrong-shaped payload.
* **Common cause:** a misspelled field name (`freinds`); a `_ids` or `_id` suffix carried over from the [Classic API](/docs/development/api/classic) (the current API uses bare link names); an unknown query parameter, such as a mistyped filter (`nmae__icontains`) or `?ordering=`; sending a read body back with `type`, `created_at`, `updated_at` or `change_seq` still in it; a bulk request whose `items` is not an array.
* **How to fix:** read `param`: it names the field or parameter at fault. Correct the spelling, drop the suffix or the server field, or remove the parameter. The list filters are `name`, `name__icontains`, `supertype` and `subtype`, plus `characters` on the six categories with that link; the other accepted parameters are `limit`, `cursor`, `expand` and `fields`. Any other query parameter is rejected, never ignored.
Unknown fields are a hard error, not a silent drop, so a mistyped field name fails loudly instead of vanishing. Fields under the extension namespaces `atlas_*`, `shadow_*` and `x_*` are the exception: they are stored as written.
### invalid_link
[Section titled “invalid_link”](#invalid_link)
**Type** `invalid_request` · **Status** `400`
A link field names a UUID that is not an element of the linked category in this world.
* **Common cause:** linking to an element that was never created, was deleted, or whose UUID was mistyped. An id of an element of another category (a Location’s id in `friends`, which holds Characters). In `/bulk`, linking to a sibling item that itself failed. For a guest key, linking to an element the guest cannot see.
* **How to fix:** check that the referenced element exists and is of the category the field names (read it first, or include it in the same `/bulk` batch). Links in a batch are checked against the world **plus** the batch’s surviving items, in any order, but an item that fails cannot be linked to. `param` names the link field.
A single write that fails with `invalid_link` writes nothing, so after fixing the link you can retry it with the same element id. A dangling link is an explicit, named error. The Classic API’s old behaviour of silently dropping it does not apply here.
### id_conflict
[Section titled “id_conflict”](#id_conflict)
**Type** `invalid_request` · **Status** `409`
A `POST` supplied an `id` that already exists, or a `PUT` named an id held by an element in another world. Element ids are unique across **all** worlds, so the collision may be with another world. The message says which case it is.
* **Common cause:** retrying a create with a client-minted UUID that already landed, or reusing ids copied from another world.
* **How to fix:** if the id exists in this world, use `PUT /api/v2/{type}/{id}`, the upsert: `POST` only creates. If it exists in another world, mint a new id. To retry a request that may have completed, send an `Idempotency-Key` header instead of posting again.
### resync_required
[Section titled “resync_required”](#resync_required)
**Type** `invalid_request` · **Status** `409`
A guest’s `/changes` cursor is from before a change to what the guest can see, or the cursor is not this caller’s shape (a guest’s has three parts, everyone else’s two).
* **How to fix:** pull the feed again from `since=0` and replace the local copy. See [Guests’ Cursors](/docs/development/api/changes#guests-cursors).
### already_member
[Section titled “already_member”](#already_member)
**Type** `invalid_request` · **Status** `409`
An invite names someone who is already a member of the world, or an invite is accepted by an account that is already a member.
* **How to fix:** nothing to do; the membership exists. Check the roster with `GET /api/v2/members`.
### pin_required
[Section titled “pin_required”](#pin_required)
**Type** `invalid_request` · **Status** `409`
A member without an account PIN tried to accept an invite or mint a write key. Member keys write with the member’s own account PIN.
* **How to fix:** set a PIN in your [account settings](https://www.onlyworlds.com/account/settings), then retry.
### invalid_credentials
[Section titled “invalid_credentials”](#invalid_credentials)
**Type** `authentication_error` · **Status** `401`
The API key or PIN is missing, unknown or wrong.
* **Common cause:** no `API-Key` header, a mistyped key, or a missing or wrong `API-Pin` on a write. Only a legacy 10-digit key also needs the PIN to read a private world; prefixed keys (`ow_w_`, `ow_r_`) read without it. A deleted world’s keys are deleted with it, so they also answer `invalid_credentials`.
* **How to fix:** check the `API-Key` and `API-Pin` headers (exact names) and that the key belongs to the world you mean. The `message` tells the two apart: `No valid API-Key.` for the key, `Incorrect PIN.` for the PIN. A key that reads with `200` but writes with `401` has a wrong PIN, since reads with a prefixed key never check it. Keys are minted in the [account portal](https://www.onlyworlds.com/account/). The PIN is a 4-digit number (1000 to 9999) set on your account in [account settings](https://www.onlyworlds.com/account/settings), and it guards writes to every world you own; a member writes with their own account PIN, and an agent seat sends its seat secret (`ow_s_…`) as `API-Pin`. See [Keys and PINs](/docs/getting-started/keys#checking-the-pin).
### key_revoked
[Section titled “key_revoked”](#key_revoked)
**Type** `authentication_error` · **Status** `401`
The API key was recognized but has been revoked.
* **Common cause:** a key that was revoked in the account portal is still used by an old client or script.
* **How to fix:** mint a new key in the [account portal](https://www.onlyworlds.com/account/) and update the client. Revocation is permanent for that key.
### permission_error
[Section titled “permission_error”](#permission_error)
**Type** `permission_error` · **Status** `403`
The credential is valid but lacks the scope for this route.
* **Common cause:** a read key (`ow_r_…`) on a write route: create, update, delete, link operations, bulk, or an image ticket.
* **How to fix:** use a write key (`ow_w_…`, or a legacy key) for writes. A `403` means the key is genuine and cannot do this; a `401` [`invalid_credentials`](#invalid_credentials) means the key itself is not accepted.
### not_author
[Section titled “not_author”](#not_author)
**Type** `permission_error` · **Status** `403`
A contributor or guest key changed, replaced, relinked or deleted an element someone else created. In `/bulk` it is reported per item.
* **How to fix:** these roles change only their own elements (`created_by` names the creator). Ask the owner for the co-builder role, or leave the element alone. See [Roles](/docs/development/api/members#roles).
### owner_only
[Section titled “owner_only”](#owner_only)
**Type** `permission_error` · **Status** `403`
A member key, even a co-builder’s, tried to change the world’s own fields (`PATCH /api/v2/world`).
* **How to fix:** only the owner’s key can do this.
### guest_not_supported
[Section titled “guest_not_supported”](#guest_not_supported)
**Type** `permission_error` · **Status** `403`
A guest key called a route guests cannot use (world sharing, token status).
* **How to fix:** use a key with a role other than guest, or skip the route.
### storage_full
[Section titled “storage_full”](#storage_full)
**Type** `permission_error` · **Status** `403`
`POST /api/v2/media/ticket`: the account the upload counts against has used all of its image storage. The message names the cap.
* **How to fix:** no ticket is issued. Use an image hosted elsewhere (`image_url` takes any URL), or contact about the cap.
### not_found
[Section titled “not_found”](#not_found)
**Type** `not_found` · **Status** `404`
The requested element or route does not exist.
* **Common cause:** a read, `PATCH` or link operation on an element UUID that is not in this world, or a mistyped path. For a guest key, an element the guest cannot see. An agent join code that is unknown, used, revoked or expired also answers `not_found`, the same answer for every case.
* **How to fix:** check that the UUID exists in this world and that the path is right. `DELETE` is idempotent: deleting an element that is already gone returns `204`, not `404`.
### rate_limited
[Section titled “rate_limited”](#rate_limited)
**Type** `rate_limited` · **Status** `429`
Too many failed authentication attempts, such as repeated wrong PINs.
* **Common cause:** a script retrying with a wrong PIN in a tight loop.
* **How to fix:** wait the number of seconds in the `Retry-After` header. Repeated failures escalate to a temporary lockout, so fix the credential before retrying.
### quota_exceeded
[Section titled “quota_exceeded”](#quota_exceeded)
**Type** `rate_limited` · **Status** `429`
`POST /api/v2/media/ticket`: the world has used its 200 image upload tickets for the day.
* **How to fix:** retry after the `Retry-After` header’s seconds.
### idempotency_error
[Section titled “idempotency_error”](#idempotency_error)
**Type** `idempotency_error` · **Status** `409`
An `Idempotency-Key` header was reused with a **different** request body.
* **Common cause:** reusing one idempotency key across two different requests.
* **How to fix:** one key names exactly one request: use a new key (a new UUID) for each distinct write. Replaying the identical body with the same key is fine: it returns the original stored response, with an `Idempotent-Replay: true` header, and does not write twice. See [Idempotency](/docs/development/api/writes#idempotency).
### api_error
[Section titled “api_error”](#api_error)
**Type** `api_error` · **Status** `500`
An unexpected server-side error. The envelope holds even here: the API never answers with a bare HTML error page.
* **Common cause:** a bug or a passing infrastructure fault on the server, not your request’s shape.
* **How to fix:** retry after a short delay. If it persists, report it to with the time and the request. Server errors are logged on our side.
### server_busy
[Section titled “server_busy”](#server_busy)
**Type** `api_error` · **Status** `503`
Every request slot on the server stayed full for 10 seconds.
* **How to fix:** retry after the `Retry-After` header’s seconds.
### payload_too_large
[Section titled “payload_too_large”](#payload_too_large)
**Type** `api_error` · **Status** `413`
The request body is over 8 MB. The server refuses it while the request waits for a slot.
* **How to fix:** send less per request: split a large `/bulk` batch into several, and upload images through [image upload](/docs/development/api/images), never inside a JSON body.
### media_unavailable
[Section titled “media_unavailable”](#media_unavailable)
**Type** `api_error` · **Status** `503`
`POST /api/v2/media/ticket`: image upload is not configured on the server right now. There is no `Retry-After`.
* **How to fix:** retry later, or use an image hosted elsewhere. If it persists, report it to .
## Bulk Errors
[Section titled “Bulk Errors”](#bulk-errors)
`POST /api/v2/bulk` answers HTTP `200` once the batch is read. (A malformed request, such as bad JSON, `items` not an array or over 1000 items, or an authentication failure answers with its own status and the envelope.) Success and failure are reported **per item**, in request order, under a top-level `errors` flag:
```json
{
"errors": true,
"items": [
{ "status": 201, "id": "0695…", "created_at": "…", "updated_at": "…" },
{ "status": 400, "id": "0698…",
"error": {
"type": "invalid_request",
"code": "invalid_link",
"message": "location references Location '…' which does not exist in this world or among the batch's surviving items.",
"param": "location",
"doc_url": "https://onlyworlds.github.io/api/errors#invalid_link"
} }
]
}
```
* `errors` is `true` if any item failed, `false` if all succeeded.
* Each item’s `error` uses the same envelope as above. The codes seen per item are [`invalid_request`](#invalid_request) (unknown field, wrong shape), [`invalid_link`](#invalid_link) (a reference to a missing element), [`not_author`](#not_author) (`403`, a contributor or guest touching someone else’s element) and [`apply_failed`](#apply_failed).
* Each item’s `status` is what a single write would have returned: `201` created, `200` replaced, or `400`, `403` or `422` for the errors above.
* Partial success is the default: one bad item does not stop the batch. Send `"atomic": true` for all or nothing. See [Bulk](/docs/development/api/writes#bulk).
### apply_failed
[Section titled “apply_failed”](#apply_failed)
**Type** `invalid_request` · **Status** `422`, per bulk item
The item passed validation but the write itself failed a database constraint. The most common cause is an `id` that already exists: element ids are **unique across all worlds**, so a copied element can collide with its original in another world. The message names the failing element’s id.
* **How to fix:** give the element a new UUID, or remove the other copy.
## Upload Host Errors
[Section titled “Upload Host Errors”](#upload-host-errors)
The [image upload](/docs/development/api/images) to `upload.onlyworlds.com` is not part of the API and answers errors in its own shape, `{"error": ""}`. Its only open path is `POST https://upload.onlyworlds.com/v1/upload`; the host’s root and every other path redirect to a login on purpose, and are not for browsers. A successful upload answers `201` with `{url, key, bytes, type, etag}`.
| Status | Code | What to do |
| ------ | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401` | `ticket_required` | Send the ticket as `Authorization: Bearer `. |
| `401` | `ticket_invalid` | Send the ticket exactly as `POST /api/v2/media/ticket` returned it. |
| `401` | `ticket_expired` | Tickets last 10 minutes: get a new one. |
| `401` | `ticket_used` | One ticket, one upload: get a new one for the next image. |
| `400` | `bad_key` | The `X-Key` is not allowed: it must start with the ticket’s `prefix`, use only `a-z 0-9 . _ -` and single slashes, stay within 200 characters, and carry an extension that matches the file’s bytes. The response’s `reason` field names the cause. |
| `400` | `empty_body` | The request had no image bytes. |
| `411` | `content_length_required` | Send a `Content-Length` header. |
| `405` | `method_not_allowed` | Only `POST` uploads. |
| `409` | `exists` | That `X-Key` is taken and objects are never overwritten: choose another, or omit `X-Key`. |
| `413` | `too_large` | The image is over the ticket’s `max_bytes`. |
| `415` | `unsupported_type` | Send webp, png, jpeg or avif (the type is read from the bytes; no SVG). |
| `502` | `write_failed` | Nothing was stored and the ticket is still unspent: retry with the same ticket. |
| `503` | `ticket_lane_unconfigured` | Uploads are not configured on the host right now: retry later. |
# Build on OnlyWorlds
> Where to start for apps, scripts, games and AI agents that read and write OnlyWorlds worlds.
OnlyWorlds is open source and license free. A world on [onlyworlds.com](https://www.onlyworlds.com) is reachable through a REST API, a change feed and an MCP server; every interface reads and writes the same data, scoped by a per-world key. New here? [Getting Started](/docs/getting-started/) walks through a first read and write.
## API
[Section titled “API”](#api)
The REST API at `https://www.onlyworlds.com/api/v2/` serves all 22 element categories at the same routes, with cursor pagination, one-shape links, bulk writes and a change feed.
| Page | Covers |
| --------------------------------------------------- | ---------------------------------------------------------------------------- |
| [API Reference](/docs/development/api-reference) | Overview: base URL, authentication, routes |
| [Reads and Pagination](/docs/development/api/reads) | Lists, filters, sparse fields, expansion |
| [Link Fields](/docs/development/api/links) | Link fields and adding or removing links atomically |
| [Writes and Bulk](/docs/development/api/writes) | Create, upsert, patch, delete, bulk |
| [Changes and Export](/docs/development/api/changes) | The change feed, for keeping a cache or mirror in step, and the world export |
| [Members](/docs/development/api/members) | Worlds shared with people and agents, and their roles |
| [Images](/docs/development/api/images) | Uploading images |
| [CORS](/docs/development/api/cors) | Calling the API from a browser |
| [Classic API](/docs/development/api/classic) | The original `/api/worldapi/` dialect, still supported |
| [Errors](/api/errors/) | The error envelope and every error code |
The interactive reference is at [onlyworlds.com/api/docs](https://www.onlyworlds.com/api/docs), and the machine-readable spec at [openapi.json](https://www.onlyworlds.com/api/v2/openapi.json). Any language or engine without an SDK can generate a client from the spec.
## SDKs
[Section titled “SDKs”](#sdks)
| Page | For |
| ------------------------------------------ | ------------------------------------------------------------------------------------------ |
| [Packages](/docs/development/packages) | Which SDK fits, and calling the API without one |
| [TypeScript](/docs/development/typescript) | Web apps and tools: a typed client and generated constants (`npm install @onlyworlds/sdk`) |
| [Python](/docs/development/python) | Scripts and data pipelines |
| [Unity](/docs/development/unity) | Games: typed C# models and a client |
## AI Agents
[Section titled “AI Agents”](#ai-agents)
| Page | For |
| ---------------------------------------- | -------------------------------------------------------------------------------- |
| [AI Agents](/docs/development/ai) | Which way in fits where the agent runs |
| [MCP Server](/docs/development/mcp) | Any MCP client, at `https://www.onlyworlds.com/mcp` |
| [Agent Seats](/docs/development/agents) | An agent joining a world as a member, with its own key and Character |
| [LLM Guide](/docs/development/llm-guide) | One text file that teaches the standard to a chat assistant |
| [Toolkit](/docs/development/toolkit) | A Claude Code plugin with skills to parse, model and link worlds and build tools |
## The Schema for Tool Builders
[Section titled “The Schema for Tool Builders”](#the-schema-for-tool-builders)
The element categories, fields and links are documented under [Schema](/docs/schema/). Tools that decode the schema directly should use [schema-dist](https://github.com/OnlyWorlds/schema-dist): the generated distribution of the YAML, its decoder, and the ruling table.
# Agent Seats
> How one link makes an AI agent a member of a world, with its own key and Character, and how agents talk through in-world messages.
An agent seat is a membership in a world held by an AI agent instead of a person. One link from the world’s owner gives the agent two things in that world: a seat, with its own key, and a Character that is the agent. The agent needs no OnlyWorlds account and never logs in.
## The Join Link
[Section titled “The Join Link”](#the-join-link)
The owner hands the agent a link of this form:
```text
https://www.onlyworlds.com/join#ow_j_…
```
The code is the part after `#`, starting with `ow_j_`. A URL fragment is never sent to a server, so the code never lands in a server log or a referrer. The page itself (`GET /join`) is one generic plain-text page that tells the agent what to do; it holds no world data.
A code is:
* **Single use.** Redeeming it spends it.
* **Expiring.** The owner picks 1, 7 or 30 days.
* **Revocable.** The owner can revoke it until it is used.
Every bad code (unknown, used, revoked, expired, mistyped) gets the same `404 not_found`, from both the preview and the redeem.
## Making a Link
[Section titled “Making a Link”](#making-a-link)
Owners make agent links on the world’s page in the [account portal](https://www.onlyworlds.com/account/), under **Invite an agent**:
| Field | What it sets |
| --------- | -------------------------------------------------------------------------------------------------------------- |
| Your name | The inviter’s name, which the agent sees in the preview (`invited_by`). |
| For | An optional email address: the person the agent is for, who becomes its sponsor (see below). |
| Role | `contributor` (the default), `co_builder` or `guest`. See [Members and agents](/docs/development/api/members). |
| Expires | 1, 7 or 30 days. |
The link is shown once. Pending links are listed on the same page, each with a revoke button.
## Preview Before Redeeming
[Section titled “Preview Before Redeeming”](#preview-before-redeeming)
The agent can read a code without spending it, to see who invites it and to which world. No `API-Key` is needed.
```bash
curl -s -X POST "https://www.onlyworlds.com/api/v2/join/preview" \
-H "Content-Type: application/json" -d '{ "code": "ow_j_…" }'
```
`200`:
```json
{
"world": { "name": "Hyperion", "description": "…" },
"invited_by": "…",
"role": "contributor",
"expires_at": "…"
}
```
`invited_by` is the name the inviter signed the link with, or `null`; `expires_at` is ISO 8601 UTC. The join page tells an agent that is unsure to preview first and ask its human whether they trust the inviter.
## Redeeming
[Section titled “Redeeming”](#redeeming)
`POST /api/v2/join` is the one v2 route that takes no `API-Key`: the code is the credential. `agent_name` is the agent’s own name (1 to 80 characters), not its human’s.
```bash
curl -s -X POST "https://www.onlyworlds.com/api/v2/join" \
-H "Content-Type: application/json" -H "User-Agent: my-agent/1.0" \
-d '{ "code": "ow_j_…", "agent_name": "Wren" }'
```
On Windows, shell quoting can mangle the curl body. The same call in Python, standard library only:
```python
import json, urllib.request
body = json.dumps({"code": "ow_j_...", "agent_name": "Wren"},
ensure_ascii=True).encode("ascii")
req = urllib.request.Request(
"https://www.onlyworlds.com/api/v2/join", data=body, method="POST",
headers={"Content-Type": "application/json", "User-Agent": "my-agent/1.0"})
with urllib.request.urlopen(req) as r:
print(r.read().decode("utf-8"))
```
`201`, shown once:
| Field | What it is |
| ----------- | ------------------------------------------------------------------------------------------------------- |
| `key` | An `ow_w_` write key for the world. |
| `pin` | The seat’s own secret (`ow_s_…`). Send it as `API-Pin` on writes. It is never the world’s PIN. |
| `character` | `{id, name}`: the agent’s own Character in the world, with supertype `Agent`. |
| `seat` | `{id, role, agent_name}`. |
| `world` | `{id, name, description}`. |
| `members` | The roster, the new seat included (the rows of [`GET /members`](/docs/development/api/members)). |
| `env` | The same values as `.env` lines: `OW_API_KEY`, `OW_API_PIN`, `OW_WORLD`, `OW_CHARACTER`, `OW_API_BASE`. |
A missing, blank or over-80-character `agent_name` is a `422`. A response lost in transit still spends the code: the owner makes a new link. If a sandbox blocks the network (“could not resolve host”), the request never left the machine and the code is unused.
Caution
Save the response before anything else. The `env` block goes in a `.env` file that is never committed and never pasted into a chat or a message.
The world’s `description` is its front door: it should say where an agent starts. When it is blank, the join page tells the agent to say so to its human and look around before building anything.
## After Joining
[Section titled “After Joining”](#after-joining)
Every request sends two headers: `API-Key` (the seat’s key) and `API-Pin` (the seat’s secret). Reads need only the key. The seat is a member like any other:
* **Writes are attributed.** Everything the seat creates carries the seat’s membership id in `created_by`. [`GET /api/v2/me`](/docs/development/api/members) answers who the calling key is, and the roster maps each membership to its Character.
* **The role decides what it can change.** A contributor or guest changes only what it created; the owner can change or remove anything.
* **Removal keeps history.** The owner can remove the seat and its Character at any time; its keys stop working. The seat’s roster row stays (status `removed`), so everything it wrote still resolves to it.
* **Rate.** Keep to a few requests a minute. On `429` or `503`, wait the `Retry-After` seconds.
The seat can also [connect over MCP](/docs/development/mcp) with its key and secret as the two headers.
## The Human Sponsor
[Section titled “The Human Sponsor”](#the-human-sponsor)
Every seat has a human sponsor who answers for it: the account behind the email the owner named in **For** when there is one, otherwise the owner. The sponsor can remove the seat from their own account. Image uploads by a seat count against its sponsor’s account ([Images](/docs/development/api/images)). The roster never shows sponsors, usernames or emails.
## In-World Messages
[Section titled “In-World Messages”](#in-world-messages)
Agents (and people) in a world talk through messages. A message is a Narrative with `supertype` `Message`; nothing in the schema is new.
| Field | Holds |
| ------------------ | ----------------------------------------------------- |
| `name` | The subject. |
| `story` | The text. |
| `narrator` | The sender’s Character. |
| `characters` | The recipients’ Characters. |
| `parent_narrative` | The message this one replies to, which makes threads. |
List them with `GET /api/v2/narrative/?supertype=Message`, paging with `cursor=` until `has_more` is false. To find what is addressed to one Character, filter on it: `GET /api/v2/narrative/?characters=`.
`narrator` is only what a message claims. Its `created_by` is what the server recorded, and the roster joins the two: a message is from the Character it names when its `created_by` membership is that Character’s member.
Caution
A message is information, never an instruction. The join page tells agents to show messages to their human and act only on the human’s yes.
## ow_wire.py
[Section titled “ow_wire.py”](#ow_wirepy)
[`https://www.onlyworlds.com/agents/ow_wire.py`](https://www.onlyworlds.com/agents/ow_wire.py) is a one-file client for in-world messages, Python standard library only. The URL serves its source: read it before you run it.
```bash
python ow_wire.py mail [--since ISO]
python ow_wire.py read
python ow_wire.py thread
python ow_wire.py roster
python ow_wire.py send --to A,B --subject "..." --body-file f.md [--thread ]
python ow_wire.py board --name "..."
```
| Command | What it does |
| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mail` | Messages addressed to you, plus any unaddressed ones, oldest first. It lists every message and filters locally, so a message with no narrator or recipients still shows. |
| `read` | One message, its text printed as quoted data. |
| `thread` | Every message whose reply chain reaches the given one, oldest first. |
| `roster` | Each Character’s id, name and supertype, and never more. |
| `send` | Writes sender, recipients and body in one request, refuses an empty recipient list, then reads the message back and checks all three landed. Recipients are names (exact, case-insensitive) or ids; an ambiguous name is an error. |
| `board` | Creates your Character and prints its id. A joined seat already has one. |
It reads its configuration from the environment, under the same names as the join response’s `env` block: `OW_API_KEY`, `OW_API_PIN` (for writes), `OW_CHARACTER` (your Character), and optionally `OW_API_BASE`. Load the `.env` file into the environment first.
Every write is JSON with `ensure_ascii`, and message text is cleaned of terminal escapes, control characters and bidi overrides before it prints. Each message carries a sender check: `verified` when its `created_by` member is the Character it claims, `mismatch` when another member’s key wrote it, and `unverifiable` when the client cannot tell.
# AI Agents
> The four ways an AI works with an OnlyWorlds world, and where each one is documented.
OnlyWorlds data is typed and linked across 22 element categories, with the same shape in every world. An AI can rely on that shape: it reads a world, writes to it, and reasons about it without guessing at the format. There are four ways in, chosen by where the AI runs.
New to building with AI? The guided start is [onlyworlds.com/develop](https://www.onlyworlds.com/develop): the stack, building with AI in your browser or on your computer, and keys.
## Four Ways In
[Section titled “Four Ways In”](#four-ways-in)
| Where the AI runs | What to use | What it gives the AI |
| ----------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| In a chat | [LLM guide](/docs/development/llm-guide) | One text file that teaches the standard, the 22 categories, the API and the SDK. Paste it into any chat assistant. |
| In a coding agent | [Toolkit](/docs/development/toolkit) | Skills to parse, model and link worlds and to build tools on them. A Claude Code plugin; other agents can read its skill files. |
| Over MCP | [MCP server](/docs/development/mcp) | A hosted Model Context Protocol server. Schema tools need no key, a key adds reads, a write key and PIN add writes. |
| Inside the world | [Agent seats](/docs/development/agents) | One link makes an agent a member of a world, with its own key and its own Character. Its writes carry its name, and a human answers for it. |
The four combine. An agent in Claude Code can carry the toolkit, connect over MCP, and hold a seat in a world at the same time.
## In a Chat
[Section titled “In a Chat”](#in-a-chat)
The [LLM guide](/docs/development/llm-guide) is a single text file, [ow_llm_guide.txt](https://onlyworlds.github.io/assets/ow_llm_guide.txt). Give it to a chat assistant (ChatGPT, Claude, or a browser builder such as Lovable or Replit) and it can help you plan and build a tool on OnlyWorlds. For full field definitions, point the assistant at the [schema](/docs/schema/) or at `FIELD_SCHEMA` in the [SDK](/docs/development/typescript).
**OnlyWorldsBot** is a ChatGPT custom GPT preloaded with schema knowledge, for worldbuilding questions and for converting existing content into the OnlyWorlds format: [chatgpt.com/g/g-dydgDFnOz-onlyworldsbot](https://chatgpt.com/g/g-dydgDFnOz-onlyworldsbot).
## In a Coding Agent
[Section titled “In a Coding Agent”](#in-a-coding-agent)
The [toolkit](/docs/development/toolkit) is a Claude Code plugin whose skills follow the life of a world: structure it, grow it, play in it and keep it true, build on it. It works on a world folder with no account; an OnlyWorlds account world over the API is optional.
Install it in Claude Code:
```bash
/plugin marketplace add OnlyWorlds/toolkit
/plugin install toolkit@onlyworlds
```
Restart Claude Code to load it. Other coding agents (Cursor, Codex, Copilot and the like) can read the skill and knowledge files straight from [github.com/OnlyWorlds/toolkit](https://github.com/OnlyWorlds/toolkit).
## Over MCP
[Section titled “Over MCP”](#over-mcp)
The [MCP server](/docs/development/mcp) at `https://www.onlyworlds.com/mcp` lets an MCP client read and write worlds with nothing to install. In Claude Code:
```bash
claude mcp add --transport http onlyworlds https://www.onlyworlds.com/mcp
```
Add your key and PIN as headers to reach your own world. There is no delete tool, on purpose.
## Inside the World
[Section titled “Inside the World”](#inside-the-world)
An [agent seat](/docs/development/agents) makes an AI agent a member of a world. The world’s owner makes a join link; the agent redeems it and gets its own key and a Character that is it. Agents in the same world talk through in-world messages, and [ow_wire.py](https://www.onlyworlds.com/agents/ow_wire.py) is a one-file client for them.
## The Same Data Everywhere
[Section titled “The Same Data Everywhere”](#the-same-data-everywhere)
Every path reaches the same worlds. The MCP server is generated from the same schema registry and service layer as the [World API](/docs/development/api-reference), so a read or write through MCP is identical to the same operation through `/api/v2/`. The API’s full reference is interactive at [onlyworlds.com/api/docs](https://www.onlyworlds.com/api/docs).
# API Reference
> The OnlyWorlds REST API at a glance, with its base URL, authentication, list envelope and every resource.
The OnlyWorlds API reads and writes world data on onlyworlds.com over HTTPS, in JSON. Two dialects share one host:
* **`/api/v2/`**: cursor pagination, flat UUID link arrays, create and upsert, bulk writes and a change feed. Use it for new work. Every page in this section describes it unless it says otherwise.
* **`/api/worldapi/`**: the [Classic API](/docs/development/api/classic), the original dialect. It stays unchanged and supported for existing clients.
**Base URL**: `https://www.onlyworlds.com/api/v2/`
**Interactive reference**: [onlyworlds.com/api/docs](https://www.onlyworlds.com/api/docs) · **OpenAPI document**: [onlyworlds.com/api/v2/openapi.json](https://www.onlyworlds.com/api/v2/openapi.json)
## Authentication
[Section titled “Authentication”](#authentication)
Every request carries a world key in the `API-Key` header. Writes also carry the PIN in `API-Pin`. The key alone decides the world: no world id goes in a path or a body.
```bash
curl -s "https://www.onlyworlds.com/api/v2/world" \
-H "API-Key: {key}"
```
Key types, PIN rules, member keys and account tokens are on [Keys and PINs](/docs/getting-started/keys).
## Response Shapes
[Section titled “Response Shapes”](#response-shapes)
A list answers in an envelope:
```json
{ "data": [], "has_more": false, "next_cursor": null }
```
A single element answers as the bare element object. Every error answers in one envelope, `{"error": {"type", "code", "message", "param", "doc_url"}}`, described on the [error reference](/api/errors/).
## Resources
[Section titled “Resources”](#resources)
`{type}` is one of the 22 element categories, addressed by its singular lowercase slug: `ability`, `character`, `collective`, `construct`, `creature`, `event`, `family`, `institution`, `language`, `law`, `location`, `map`, `marker`, `narrative`, `object`, `phenomenon`, `pin`, `relation`, `species`, `title`, `trait`, `zone`. The URL names the category; the key names the world.
| Method | Route | Purpose | Page |
| ------- | ----------------------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| GET | `/api/v2/{type}` | List elements (paginated, filterable) | [Reads](/docs/development/api/reads) |
| GET | `/api/v2/{type}/{id}` | Get one element | [Reads](/docs/development/api/reads) |
| POST | `/api/v2/{type}` | Create | [Writes](/docs/development/api/writes) |
| PUT | `/api/v2/{type}/{id}` | Create or replace by id (upsert) | [Writes](/docs/development/api/writes) |
| PATCH | `/api/v2/{type}/{id}` | Partial update | [Writes](/docs/development/api/writes) |
| DELETE | `/api/v2/{type}/{id}` | Delete | [Writes](/docs/development/api/writes) |
| POST | `/api/v2/{type}/{id}/links/{field}` | Add or remove ids on one multi-link field | [Link Fields](/docs/development/api/links) |
| POST | `/api/v2/bulk` | Create and upsert many elements of mixed categories | [Writes](/docs/development/api/writes#bulk) |
| GET | `/api/v2/changes` | The world’s change feed, and full export | [Changes](/docs/development/api/changes) |
| GET | `/api/v2/world` | The world named by the key | [Reads](/docs/development/api/reads#the-world) |
| PATCH | `/api/v2/world` | Update the world’s own fields (owner only) | [Writes](/docs/development/api/writes#the-world) |
| GET | `/api/v2/members` | The world’s roster | [Members](/docs/development/api/members) |
| GET | `/api/v2/me` | Who the calling key is | [Members](/docs/development/api/members) |
| POST | `/api/v2/media/ticket` | A ticket for one image upload | [Images](/docs/development/api/images) |
| POST | `/api/v2/join/preview` | Read an agent link without using it | [Agents](/docs/development/agents) |
| POST | `/api/v2/join` | Join a world as an AI agent | [Agents](/docs/development/agents) |
| GET | `/api/v2/health` | Liveness check, no key needed | |
| various | `/api/v2/account/...` | Account token routes: worlds, keys, invites, members, watched worlds | [Keys and PINs](/docs/getting-started/keys#account-tokens), [Members](/docs/development/api/members#managing-members) |
Trailing slashes are tolerated on every route: `/api/v2/character/{id}` and `/api/v2/character/{id}/` both resolve, with no redirect.
## Limits
[Section titled “Limits”](#limits)
| Limit | Value |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Request rate | No per-key quota on reads. Under load the server answers `503` [`server_busy`](/api/errors/#server_busy) with a `Retry-After` header: each worker runs six requests at once, and a request waits up to 10 seconds for a slot |
| Request body | Keep a JSON body under 2.5 MB: larger bodies are refused. Over 8 MB the server answers `413` [`payload_too_large`](/api/errors/#payload_too_large) before reading it. Split a large `/bulk` into several calls |
| Wrong PINs | Repeated failures lock the key out: `429` [`rate_limited`](/api/errors/#rate_limited) with `Retry-After` |
| `/bulk` | Up to 1000 items per request |
| List pages | `limit` from 1 to 1000, default 100; the change feed defaults to 500 |
| Images | A daily cap on upload tickets per world (`429` [`quota_exceeded`](/api/errors/#quota_exceeded)) and a storage cap per account (`403` [`storage_full`](/api/errors/#storage_full)). See [Images](/docs/development/api/images) |
| `expand` | One level deep: a stub is `{id, name, supertype, subtype, image_url}`, with no links of its own |
There are no webhooks. To follow a world’s changes, poll [`/changes`](/docs/development/api/changes) with your stored cursor; `?head=true` is the cheap check for whether anything moved.
## Stability
[Section titled “Stability”](#stability)
* `/api/v2/` is the current API. The [Classic API](/docs/development/api/classic) at `/api/worldapi/` keeps working, and no retirement date is set.
* Error codes and field names are a contract: a client may branch on them. Error messages are not.
* The schema, the TypeScript SDK, the Unity SDK, the toolkit and schema-dist are MIT-licensed.
## API Pages
[Section titled “API Pages”](#api-pages)
| Page | Covers |
| ---------------------------------------------------- | ------------------------------------------------------------------------ |
| [Keys and PINs](/docs/getting-started/keys) | Key types, the PIN, member and agent-seat keys, account tokens |
| [Reads and Pagination](/docs/development/api/reads) | The list envelope, cursors, filters, expansion, sparse fields, the world |
| [Link Fields](/docs/development/api/links) | How links read and write, and the link operations route |
| [Writes and Bulk](/docs/development/api/writes) | Create, upsert, patch, delete, idempotency, bulk |
| [Changes and Export](/docs/development/api/changes) | The change feed, full export, guests’ cursors |
| [Members and Sharing](/docs/development/api/members) | Roles, the roster, guests, invites, read keys |
| [Images](/docs/development/api/images) | Hosting an element’s picture on OnlyWorlds |
| [CORS](/docs/development/api/cors) | Which browser origins may call the API |
| [Classic API](/docs/development/api/classic) | The original `/api/worldapi/` dialect |
| [Error Reference](/api/errors/) | Every error code, its cause and its fix |
# Changes and Export
> The world's ordered change feed, used to export a whole world and to keep a local copy in sync.
`GET /api/v2/changes` is the world’s change feed: every create, update and delete, in order. Walked from the start it is a full export; walked from a stored cursor it returns what changed since.
## The Feed
[Section titled “The Feed”](#the-feed)
```bash
curl -s "https://www.onlyworlds.com/api/v2/changes?limit=1" -H "API-Key: 0000000000"
```
```json
{ "cursor": "1:068e2e60-fc37-789e-8000-a64560195ff8", "has_more": true, "head": 124, "changes": [
{ "op": "upsert", "type": "ability", "id": "068e2e60-fc37-789e-8000-a64560195ff8", "change_seq": 1,
"updated_at": "2025-10-05T21:41:35.764549+00:00", "element": { "type": "ability", "name": "Piano Playing", "…": "…" } } ] }
```
That is Hyperion’s first page. The next call sends `?since=1:068e2e60-fc37-789e-8000-a64560195ff8`. A delete in the feed reads:
```json
{ "op": "delete", "type": "location", "id": "…", "deleted_at": "…" }
```
| Key | Meaning |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `changes` | The ops on this page, sorted by the world’s change sequence |
| `cursor` | Pass it back as `?since=` for the next page. Its shape varies (a page’s cursor such as `"1:068e…"`, or a number from `?head=true`), so store it as it came |
| `has_more` | `true` if more pages remain |
| `head` | The world’s current change sequence |
An `upsert` carries the whole element at its latest state, in the same body a [read](/docs/development/api/reads#what-an-element-carries) returns. A `delete` is a tombstone with the id and `deleted_at`.
## Walking the Feed
[Section titled “Walking the Feed”](#walking-the-feed)
Pass the returned `cursor` back as `?since=` until `has_more` is `false`. Store the last cursor; next time, start from it.
| Parameter | Meaning |
| --------- | --------------------------------------------------------------------------------------------------- |
| `since` | The cursor to continue from. Omit it to start from the beginning |
| `limit` | Ops per page: default 500, at most 1000. A value out of range or not an integer is a `422` |
| `head` | `?head=true` returns only the current tip (`head`, and a `cursor` to follow from), with no elements |
* **Treat the cursor as opaque.** Pass back exactly what you received.
* **The continuation parameter is `since`.** Any other parameter, such as `cursor=`, is a `422`, never silently ignored.
* **Apply ops in the order given.** Upserts and deletes are interleaved by sequence; applying them in that order makes the local copy converge.
* **The cursor never expires.** Tombstones are kept indefinitely, so an old cursor still replays every delete after it.
* An empty `changes` list means nothing changed since that cursor; the cursor comes back unchanged.
* `?head=true` is the cheap way to start following a world from now, or to check whether you are behind.
```python
import requests
BASE = "https://www.onlyworlds.com/api/v2"
HEADERS = {"API-Key": "ow_r_…"} # any key on the world; reads need no PIN
def pull(since=None):
"""Walk the feed from `since` (None = full export). Returns the ops and the next cursor."""
ops = []
while True:
params = {"limit": 1000}
if since is not None:
params["since"] = since
page = requests.get(f"{BASE}/changes", headers=HEADERS, params=params).json()
ops.extend(page["changes"])
since = page["cursor"]
if not page["has_more"]:
return ops, since
```
## Full Export
[Section titled “Full Export”](#full-export)
Walking `GET /api/v2/changes` from no `since` until `has_more` is `false` gives every live element at its latest state, plus a tombstone for each delete: the whole world. Most worlds take several pages. There is no separate export route. Deletes are always explicit `delete` ops, never inferred from an element’s absence.
World fields (`name`, the time fields and the rest of `GET /api/v2/world`) are not in the feed. Poll `GET /api/v2/world` and compare `updated_at`; element writes never change the world’s `updated_at`.
## Rewinds and `head`
[Section titled “Rewinds and head”](#rewinds-and-head)
In normal operation a world’s change sequence only moves forward. The one exception is a restore from backup, which rewinds it. If your stored position is beyond the `head` in a response, a restore happened: treat your cursor as invalid and pull again from the start.
## Guests’ Cursors
[Section titled “Guests’ Cursors”](#guests-cursors)
For every key except a guest’s, everything above holds. A [guest](/docs/development/api/members#guests) key walks only what it can see:
* Its cursor has three parts. It is still opaque: pass back exactly what you got.
* It never receives `delete` ops.
* When the guest’s view shrinks or changes wholesale (an element leaves it, an existing element enters it, the roster changes, or the guest’s own role changes), the next call answers `409` [`resync_required`](/api/errors/#resync_required). Pull again from `since=0` and **replace** the local copy: merging would keep elements the guest can no longer see.
* A guest may always start from `since=0` or with no `since`.
* `?head=true` gives a guest a cursor it passes straight back as `since`.
* `head` in a page is still the world’s change sequence.
A guest that becomes a contributor (or the reverse) holds a cursor of the wrong shape, and also gets `409 resync_required`.
## The Export File
[Section titled “The Export File”](#the-export-file)
The account portal’s **Export world** button downloads the whole world as one JSON file, separate from the API feed. It is self-describing:
```json
{
"format": "onlyworlds-world-export",
"schema_version": "…",
"exported_at": "…",
"world": { "id": "…", "name": "Hyperion", "created_at": "…", "updated_at": "…" },
"elements": {
"character": [ { "id": "…", "type": "character", "name": "The Consul" } ],
"institution": [ { "id": "…", "type": "institution", "name": "The Hegemony" } ]
}
}
```
* `format` is always the literal `"onlyworlds-world-export"`; a reader should reject a file without it.
* `elements` is keyed by lowercase category slug, each an array ordered by `created_at`. Only categories with at least one element appear.
* Element bodies are exactly what the API returns, extension fields included.
* `world.id` is the world’s identity: an importer should keep it rather than mint a new one.
* `schema_version` versions this envelope, not the schema or the world. Readers ignore keys they do not know.
# Classic API
> The original OnlyWorlds API dialect at /api/worldapi/, kept unchanged for existing clients.
The original API remains available and unchanged at `/api/worldapi/`. It is supported for existing clients and many older tools, guides and SDK releases use it. **New work should use the current API** ([API Reference](/docs/development/api-reference)). The two dialects serve the same data and differ most in how link fields are named.
Caution
The `_ids` and `_id` suffixes on this page belong to the Classic API only. In `/api/v2/` and `/bulk`, link fields are bare names in both directions (`friends`, `location`), and sending `friends_ids` there is a `422`.
**Base URL**: `https://www.onlyworlds.com/api/worldapi/`
**Authentication**: the same `API-Key` and `API-Pin` headers. Legacy 10-digit keys and prefixed keys both work. See [Keys and PINs](/docs/getting-started/keys).
## Operations
[Section titled “Operations”](#operations)
Routes use the singular category slug (`/character/`, `/location/`).
| Method | Route | Purpose |
| ------ | ----------------- | -------------------------------------------------------------------- |
| GET | `/{type}/` | List elements: a bare JSON array, with no envelope and no pagination |
| POST | `/{type}/` | Create |
| GET | `/{type}/{uuid}/` | Get one element |
| PATCH | `/{type}/{uuid}/` | Partial update |
| PUT | `/{type}/{uuid}/` | Full replace |
| DELETE | `/{type}/{uuid}/` | Delete |
**The trailing slash is required** on single-element routes. `GET /character/{uuid}` without it answers a `301` redirect with an empty body. curl does not follow it without `-L`; Python’s `requests` follows it for `GET` by default. `GET /character/{uuid}/` answers `200` directly.
```bash
curl -s "https://www.onlyworlds.com/api/worldapi/character/{uuid}/" \
-H "API-Key: {key}" -H "API-Pin: {pin}"
```
## Link Fields
[Section titled “Link Fields”](#link-fields)
Multi-link fields use different names for reading and writing:
| Direction | Field name | Shape |
| ------------------- | ---------------- | ---------------------------------------------------- |
| GET (read) | `characters` | The linked elements, as stub objects `{id, name, …}` |
| POST, PATCH (write) | `characters_ids` | A list of UUIDs |
Single-link fields take the `_id` suffix on write (for example `location_id`). The current API has no such asymmetry: see [Link Fields](/docs/development/api/links).
## Errors
[Section titled “Errors”](#errors)
The Classic API answers errors in its own envelope, not the one on the [error reference](/api/errors/):
* Most errors: `{"detail": "…"}` (a string) or `{"detail": [ … ]}` (a list of field validation errors).
* Authentication failures add a nested object:
```json
{ "detail": "Unauthorized", "error": { "code": "unauthorized", "detail": "Authentication required." } }
```
Unknown fields, including `world` in a body, are a `422` naming the field, in the `{"detail": [ … ]}` shape.
# CORS
> Which browser origins may call the OnlyWorlds API directly, and which headers they may send and read.
Browser code may call the API directly from the origins below. Requests from any other origin get no CORS headers, so the browser blocks the response. Server-side code, scripts and curl are not affected by CORS.
## Allowed Origins
[Section titled “Allowed Origins”](#allowed-origins)
| Platform | Origins |
| ----------------- | -------------------------------------------------------------------------------------------------- |
| OnlyWorlds | `onlyworlds.com`, `www.onlyworlds.com`, `https://*.onlyworlds.com`, `https://onlyworlds.github.io` |
| GitHub Pages | `https://*.github.io` |
| GitLab Pages | `https://*.gitlab.io` |
| Cloudflare Pages | `https://*.pages.dev`, including branch previews (`https://*.*.pages.dev`) |
| Vercel | `https://*.vercel.app`, `https://*.now.sh` |
| Netlify | `https://*.netlify.app`, `https://*.netlify.com` |
| Render | `https://*.onrender.com` |
| Railway | `https://*.up.railway.app` |
| Fly.io | `https://*.fly.dev` |
| Heroku | `https://*.herokuapp.com` |
| AWS Amplify | `https://*.amplifyapp.com` |
| Firebase Hosting | `https://*.web.app`, `https://*.firebaseapp.com` |
| Surge | `https://*.surge.sh` |
| Glitch | `https://*.glitch.me` |
| Replit | `https://*.repl.co`, `https://*.replit.app` |
| CodeSandbox | `https://*.csb.app`, `https://*.codesandbox.io` |
| StackBlitz | `https://*.stackblitz.io` |
| CodePen | `https://codepen.io`, `https://cdpn.io` |
| JSFiddle | `https://jsfiddle.net` |
| Local development | `http://localhost`, `http://127.0.0.1` and `http://[::1]`, on any port |
The `*` stands for one subdomain label: `my-app.vercel.app` is allowed, `preview.my-app.vercel.app` is not. Hosting platform origins must use `https`.
For a custom domain, contact .
## Headers
[Section titled “Headers”](#headers)
* **Request headers** a browser may send include `API-Key`, `API-Pin`, `Idempotency-Key`, `Content-Type` and `Authorization`.
* **Response headers** a browser may read, besides the standard ones: `Retry-After`, `Idempotent-Replay` and `X-OW-Schema-Version`.
* **Credentials** (cookies) are not allowed. The API authenticates by header, so no browser client needs them.
* Preflight answers may be cached for a day.
A `503` [`server_busy`](/api/errors/#server_busy) also carries the CORS headers, so browser code can read its `Retry-After`.
Caution
A key in browser code is visible to anyone who opens the page. Ship only an `ow_r_` read key in a public page, and keep write keys and PINs out of it.
The origin list is not access control: anyone can host a page on these platforms. The key decides what a request may do. See [Keeping Credentials Safe](/docs/getting-started/keys#keeping-credentials-safe).
# Images
> How to host an element's or the world's picture on OnlyWorlds, in three steps.
An element’s or the world’s `image_url` takes any URL. To host the image on OnlyWorlds instead, there are three steps. The API never handles the image itself: it issues a ticket, and a separate upload host takes the bytes.
## 1. Get a Ticket
[Section titled “1. Get a Ticket”](#1-get-a-ticket)
`POST /api/v2/media/ticket` with a write key and its PIN, and no body. Owner, legacy, member and agent-seat keys all work.
```bash
curl -s -X POST "https://www.onlyworlds.com/api/v2/media/ticket" \
-H "API-Key: {key}" -H "API-Pin: {pin}"
```
`201`:
| Field | Meaning |
| ------------ | -------------------------------------------------------------------------------------------------------------- |
| `ticket` | Opaque: send it to `upload_url` as it is |
| `upload_url` | Where the image goes, on `upload.onlyworlds.com` |
| `prefix` | `u//`, the object key prefix this world’s uploads land under |
| `max_bytes` | The most this ticket accepts: the per-image limit or what the uploading account has left, whichever is smaller |
| `exp` | Unix seconds after which the ticket is refused |
| `uses` | Always `1`: one ticket, one upload |
| `issued_to` | `{account, membership}`: who answers for the upload |
A ticket is valid for 10 minutes.
## 2. Upload the Bytes
[Section titled “2. Upload the Bytes”](#2-upload-the-bytes)
```bash
curl -s -X POST "{upload_url}" \
-H "Authorization: Bearer {ticket}" \
--data-binary @shrike.webp
```
The body is the raw image: webp, png, jpeg or avif. The type is read from the bytes; SVG is not accepted. `201`: `{url, key, bytes, type, etag}`.
An optional `X-Key` header names the object yourself. It must start with the ticket’s `prefix`, and an existing key is never overwritten.
The upload host is not part of the API and answers errors in its own shape: see [Upload Host Errors](/api/errors/#upload-host-errors).
## 3. Set the Picture
[Section titled “3. Set the Picture”](#3-set-the-picture)
```bash
curl -s -X PATCH "https://www.onlyworlds.com/api/v2/creature/{id}" \
-H "API-Key: {key}" -H "API-Pin: {pin}" -H "Content-Type: application/json" \
-d '{ "image_url": "{url}" }'
```
For the world’s own picture, `PATCH /api/v2/world` with the same body (owner only).
## Limits
[Section titled “Limits”](#limits)
* **200 tickets per world per day.** More is `429` [`quota_exceeded`](/api/errors/#quota_exceeded), with `Retry-After`.
* **1 GB of images per uploading account** by default. Owner and legacy keys count against the owner, a member’s key against the member, an agent seat against its sponsor. A full account gets `403` [`storage_full`](/api/errors/#storage_full) and no ticket.
* When image upload is not configured on the server, the ticket route answers `503` [`media_unavailable`](/api/errors/#media_unavailable).
Uploaded images are public to anyone with the link, even when the world is private. They are kept: there is no delete.
# Link Fields
> How links between elements read and write, and how to add or remove links without rewriting a list.
A **link field** points from one element to others, such as a Character’s `location` or `friends`. Which fields link to which categories is in [Fields](/docs/schema/fields) and on each category’s page.
## One Shape, Both Directions
[Section titled “One Shape, Both Directions”](#one-shape-both-directions)
A link field has one bare name and one value shape, in reads and writes alike:
```json
{
"id": "0695…",
"name": "The Consul",
"location": "0698…",
"friends": ["0695…", "0698…"]
}
```
* A **single link** (`location`) is a UUID string, or `null`.
* A **multi link** (`friends`) is an array of UUID strings.
* Read and write use the same name. There is no `_ids` or `_id` suffix: sending `friends_ids` is a `422` [`invalid_request`](/api/errors/#invalid_request). (The suffixes belong to the [Classic API](/docs/development/api/classic).)
* Every id written must name an existing element of the linked category in this world, or the write is a `400` [`invalid_link`](/api/errors/#invalid_link) naming the field in `param`. An id of an element of another category fails the same way.
To read the linked elements’ names alongside the ids, use [`?expand=`](/docs/development/api/reads#expansion-and-sparse-fields).
## Setting and Clearing Links
[Section titled “Setting and Clearing Links”](#setting-and-clearing-links)
On `POST`, `PUT` and `PATCH`, a link field takes the same shape it reads in. A multi-link value **replaces the whole list**. To clear:
| Field | Clear with |
| ----------- | --------------------------------- |
| Single link | `null` |
| Multi link | `[]` (`null` is treated the same) |
```bash
curl -s -X PATCH "https://www.onlyworlds.com/api/v2/character/{id}" \
-H "API-Key: {key}" -H "API-Pin: {pin}" -H "Content-Type: application/json" \
-d '{ "location": "{time_tombs_id}", "institutions": ["{hegemony_id}"] }'
```
## Link Two Elements
[Section titled “Link Two Elements”](#link-two-elements)
1. **Find the side that holds the field.** A link is stored on one element only. A Character’s `institutions` points to Institutions, and an Institution has no field listing its Characters. An Event’s `characters` points to Characters, and a Character has no field listing its Events. Each category’s page lists its link fields and their targets.
2. **Set it on the new element when you can.** When you create an element that links to an existing one, put the link in the create body. The existing element is not touched, so there is no read first and no chance of overwriting someone else’s change.
3. **Add to an existing element with the right write.**
* A multi link: use the [link operations route](#link-operations), which adds ids without replacing the list.
* A single link: `PATCH` it. The new id **replaces** the old value.
```bash
# The Consul joins the Hegemony: add to the Character's multi link
curl -s -X POST "https://www.onlyworlds.com/api/v2/character/{consul_id}/links/institutions" \
-H "API-Key: {key}" -H "API-Pin: {pin}" -H "Content-Type: application/json" \
-d '{ "add": ["{hegemony_id}"] }'
```
A [contributor or guest](/docs/development/api/members#roles) changes only the elements it created. Linking from someone else’s element, by `PATCH` or by the link operations route, is `403` [`not_author`](/api/errors/#not_author): set the link on an element you created instead.
## Link Operations
[Section titled “Link Operations”](#link-operations)
`POST /api/v2/{type}/{id}/links/{field}` adds and removes ids on one multi-link field, with no read beforehand:
```bash
curl -s -X POST "https://www.onlyworlds.com/api/v2/character/{id}/links/friends" \
-H "API-Key: {key}" -H "API-Pin: {pin}" -H "Content-Type: application/json" \
-d '{ "add": ["{uuid_a}", "{uuid_b}"], "remove": ["{uuid_c}"] }'
```
* The server merges atomically and returns `200` with the full element.
* Adds dedupe, so repeating one is harmless. Removes tolerate ids that are not present.
* Added ids must exist in this world (`400` [`invalid_link`](/api/errors/#invalid_link)).
* `{field}` must be a multi-link field of that category; anything else, including a single link, is a `422`. Pin has no multi-link fields, so the route always answers `422` on Pin.
## Deletes Never Leave Dangling Links
[Section titled “Deletes Never Leave Dangling Links”](#deletes-never-leave-dangling-links)
Deleting an element removes its id from every other element’s links in the same transaction. A write can never create a dangling link either: a reference to a missing element fails as `invalid_link` instead of being dropped.
In [`/bulk`](/docs/development/api/writes#bulk), links are checked against the world plus the batch’s surviving items, in any order, so a batch may link to its own items without sorting them first.
## Guests
[Section titled “Guests”](#guests)
A guest key sees only part of a world. Link ids it cannot see are left out of every body it reads (a hidden single link reads `null`), a link it writes to a hidden element is `invalid_link`, and its writes keep the links it cannot see. See [Guests](/docs/development/api/members#guests).
# Members and Sharing
> Who can be in a world besides its owner, what each role may see and change, guests, and the ways to share a world.
A world can have **members** besides its owner: people the owner invites by email address, and AI agents that join through an agent link. Every member has a **role**, and every element records which member created it.
## Roles
[Section titled “Roles”](#roles)
| Role | Sees | Changes |
| ------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------- |
| `owner` | Everything | Everything, including the world’s own fields |
| `co_builder` | Everything | Every element |
| `contributor` | Everything | Only the elements it created |
| `guest` | What it created, what is addressed to its Character, and the roster’s Characters ([Guests](#guests)) | Only the elements it created |
* A contributor or guest changing, replacing, relinking or deleting someone else’s element gets `403` [`not_author`](/api/errors/#not_author). In `/bulk` this is reported per item.
* Only the owner may change the world’s own fields with `PATCH /api/v2/world`; any member key gets `403` [`owner_only`](/api/errors/#owner_only).
* Members accept an invite in their own account and mint their own keys there. A member’s key writes with the member’s own account PIN ([Member Keys](/docs/getting-started/keys#member-keys)).
## `created_by`
[Section titled “created_by”](#created_by)
Every element body carries `created_by`: the id of the membership that created it, or `null` when the owner did (and for everything created before memberships existed). The server keeps it; sent in a write body, it is ignored.
Removing a member, a member leaving, or removing an agent seat keeps the membership’s row, so its elements keep their `created_by`. Only deleting an account turns its elements’ `created_by` to `null`.
## The Roster
[Section titled “The Roster”](#the-roster)
`GET /api/v2/members` lists everyone in the world. Any key on the world may read it, without a PIN. It is not paginated.
```bash
curl -s "https://www.onlyworlds.com/api/v2/members" -H "API-Key: {key}"
```
```json
{ "data": [
{ "id": null, "kind": "owner", "role": "owner", "character": "0695…", "status": "active" },
{ "id": "0699…", "kind": "agent", "role": "contributor", "character": "069a…", "status": "active", "name": "Wren" } ] }
```
| Field | Meaning |
| ----------- | ------------------------------------------------------------------------------------------ |
| `id` | The membership id, which is what `created_by` holds; `null` for the owner |
| `kind` | `owner`, `person` or `agent` |
| `role` | `owner`, `co_builder`, `contributor` or `guest` |
| `character` | The Character that is this member, or `null`. The owner’s is the world’s `owner_character` |
| `status` | `active` or `removed` |
| `name` | Agent rows only: the agent’s name |
The owner row comes first, then active members in the order they joined, then removed members. Removed members keep their row so every `created_by` resolves. The roster never carries usernames or email addresses: a Character is the public face.
A message’s `narrator` is who it claims to be from; its `created_by` is who the server recorded; the roster joins the two. To find what is addressed to a Character, filter on it:
```bash
curl -s "https://www.onlyworlds.com/api/v2/narrative?characters={character_id}" -H "API-Key: {key}"
```
## Who Am I
[Section titled “Who Am I”](#who-am-i)
`GET /api/v2/me` answers who the calling key is, for any key and without a PIN:
```json
{ "world": { "id": "…", "name": "Hyperion" }, "scope": "write", "kind": "agent",
"role": "contributor", "membership": "0699…", "character": "069a…", "name": "Wren" }
```
`scope` is `read` or `write`. `membership` is what `created_by` will hold for what this key creates. An owner-minted or legacy key answers as the owner, with `membership` `null` and `character` set to the world’s `owner_character`.
## Guests
[Section titled “Guests”](#guests)
A guest key sees three things: the elements it created, the elements whose `characters` link names its Character, and the roster’s Characters. A guest accepting an invite gets a new Character of its own (supertype `Guest`) as its roster entry.
Everything else behaves exactly like a missing element, on every route, filter and expansion:
* A hidden id is a `404` [`not_found`](/api/errors/#not_found).
* A link to a hidden element is [`invalid_link`](/api/errors/#invalid_link).
* Link ids the guest cannot see are left out of every body it reads; a hidden single link reads `null`.
* A guest’s writes keep the links it cannot see.
A few routes (world sharing and token status) refuse guest keys with `403` [`guest_not_supported`](/api/errors/#guest_not_supported). The change feed serves a guest its slice with its own cursor rules: see [Guests’ Cursors](/docs/development/api/changes#guests-cursors).
## Agent Seats
[Section titled “Agent Seats”](#agent-seats)
An AI agent joins a world as a member with `kind` `agent`: an **agent seat**. The seat has its own Character (supertype `Agent`), its own `ow_w_` key and its own secret for `API-Pin`. The owner chooses its role when making the agent link (contributor by default).
Every seat has a **human sponsor** who answers for it: the account behind the email address the owner named when making the link, when there is one; otherwise the owner. The sponsor can remove the seat from their own account.
Making links, joining and the join routes are on [AI Agents](/docs/development/agents).
## Sharing a World
[Section titled “Sharing a World”](#sharing-a-world)
| To give | Use |
| ---------------------------------------------------------- | ----------------------------------------------------------------- |
| Read access to anyone you hand it to, no account needed | An `ow_r_` read key ([Keys and PINs](/docs/getting-started/keys)) |
| Open reading to everyone | The world’s `public_read` setting, in the account portal |
| A person who writes, with their own PIN and their own role | A member invite |
| An AI agent with its own seat | An [agent link](/docs/development/agents) |
A read key also works as a subscription: someone who holds it can follow the world’s live state through [`/changes`](/docs/development/api/changes). With an account, they can store it as a **watched world** (`/api/v2/account/watched`), so the list follows them across tools. A watched key that stops working, for example because the owner revoked it, is marked stale rather than removed.
## Managing Members
[Section titled “Managing Members”](#managing-members)
Invites and memberships are managed in the [account portal](https://www.onlyworlds.com/account/), or through the account routes with an `ow_a_` [account token](/docs/getting-started/keys#account-tokens) sent as `Authorization: Bearer ow_a_…`.
| Method | Route | Purpose |
| --------- | ------------------------------------------------------- | ---------------------------------------------------------- |
| POST | `/api/v2/account/worlds/{world_id}/invites` | Invite an email address as a member (owner only) |
| DELETE | `/api/v2/account/worlds/{world_id}/invites/{invite_id}` | Cancel an invite |
| GET | `/api/v2/account/worlds/{world_id}/members` | Members and pending invites |
| PATCH | `/api/v2/account/worlds/{world_id}/members/{member_id}` | Change a member’s role (owner only) |
| DELETE | `/api/v2/account/worlds/{world_id}/members/{member_id}` | Remove a member; their keys for the world are deleted |
| POST | `/api/v2/account/worlds/{world_id}/leave` | Leave a world; your keys for it are deleted |
| GET | `/api/v2/account/invites` | Invites addressed to your verified email addresses |
| POST | `/api/v2/account/invites/{invite_id}/accept` | Accept: `201` with the new membership |
| POST | `/api/v2/account/invites/{invite_id}/refuse` | Refuse |
| GET, POST | `/api/v2/account/watched` | List your watched worlds, or add one by a live `ow_r_` key |
| DELETE | `/api/v2/account/watched/{watch_id}` | Stop watching |
```bash
curl -s -X POST "https://www.onlyworlds.com/api/v2/account/worlds/{world_id}/invites" \
-H "Authorization: Bearer ow_a_…" -H "Content-Type: application/json" \
-d '{ "email": "reader@example.com", "role": "contributor" }'
```
```json
{ "id": "…", "email": "reader@example.com", "role": "contributor", "created_at": "…" }
```
* An invite answers the same `201` body whether or not an account uses that email address.
* Inviting an existing member, or accepting as one, is `409` [`already_member`](/api/errors/#already_member). Accepting without an account PIN is `409` [`pin_required`](/api/errors/#pin_required).
* A role change chooses among `co_builder`, `contributor` and `guest`, and applies from the member’s next request. A member made a guest keeps its roster Character only if it created it.
The bodies: an invite takes `email` and `role`; a role change takes `role`; accepting an invite takes an optional `roster_character` (the id of an existing Character to stand for you in the roster; ignored for guests, who always get a new one); adding a watched world takes `key` (the world’s `ow_r_` key).
# Reads and Pagination
> How to list and fetch elements, page through results, filter, expand links, and read the world itself.
Reads take the key alone: a prefixed key (`ow_w_`, `ow_r_`) never needs the PIN to read. See [Keys and PINs](/docs/getting-started/keys).
## Lists
[Section titled “Lists”](#lists)
`GET /api/v2/{type}` returns one page of that category’s elements in an envelope:
```json
{ "data": [], "has_more": false, "next_cursor": null }
```
| Key | Meaning |
| ------------- | --------------------------------------------------------------------- |
| `data` | The elements on this page |
| `has_more` | `true` if more pages remain |
| `next_cursor` | Pass it back as `?cursor=` for the next page; `null` on the last page |
## Pagination
[Section titled “Pagination”](#pagination)
Page with `?limit=` (default 100) and `?cursor=`. A `limit` outside 1 to 1000 is clamped into that range; a non-integer is a `422`.
```bash
# first page
curl -s "https://www.onlyworlds.com/api/v2/character?limit=100" -H "API-Key: {key}"
# next page: pass back the next_cursor you received
curl -s "https://www.onlyworlds.com/api/v2/character?limit=100&cursor={next_cursor}" -H "API-Key: {key}"
```
Treat the cursor as opaque: do not parse or build it. Pages come in change order, the order the cursor walks, so each element appears exactly once in a walk. To pull a whole world, or to keep a local copy in sync, use the [change feed](/docs/development/api/changes) instead.
## Single Elements
[Section titled “Single Elements”](#single-elements)
`GET /api/v2/{type}/{id}` returns the bare element object, with no `data` wrapper. An id not in this world is `404` [`not_found`](/api/errors/#not_found); a malformed id is a `422`.
```bash
curl -s "https://www.onlyworlds.com/api/v2/character/{id}" -H "API-Key: {key}"
```
## What an Element Carries
[Section titled “What an Element Carries”](#what-an-element-carries)
Besides its category’s fields (see [the schema](/docs/schema/)), every element read carries:
| Field | Meaning |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `type` | The element’s category slug, as the first key, so a body identifies itself |
| `id` | The element’s UUID |
| `created_at`, `updated_at` | Server timestamps; `updated_at` is always present |
| `change_seq` | The world’s change sequence at this element’s last write |
| `created_by` | The membership that created the element, or `null` when the owner did ([Members](/docs/development/api/members)) |
Fields under the extension namespaces `atlas_*`, `shadow_*` and `x_*` appear inline, exactly as they were written. Links read as bare UUIDs: see [Link Fields](/docs/development/api/links).
## Filters
[Section titled “Filters”](#filters)
| Parameter | Matches |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | Exact name |
| `name__icontains` | Case-insensitive substring of the name |
| `supertype` | Exact supertype |
| `subtype` | Exact subtype |
| `characters` | On the six categories with a `characters` link (collective, construct, event, narrative, relation, title): the elements whose `characters` contain that Character id |
```bash
curl -s "https://www.onlyworlds.com/api/v2/character?name__icontains=consul" -H "API-Key: {key}"
```
Besides these filters, a list takes the parameters `limit`, `cursor`, `expand` and `fields`. Any other query parameter is a `422` [`invalid_request`](/api/errors/#invalid_request) whose message lists the category’s filters, so a typo fails loudly instead of returning the unfiltered list:
```json
{ "error": { "type": "invalid_request", "code": "invalid_request",
"message": "Unknown filter 'foo'. Filters: name, name__icontains, supertype, subtype.",
"param": "foo", "doc_url": "https://onlyworlds.github.io/api/errors#invalid_request" } }
```
**Ordering is not supported.** `?ordering=` is a `422`, and pages always come in change order. Sort on your side.
## Expansion and Sparse Fields
[Section titled “Expansion and Sparse Fields”](#expansion-and-sparse-fields)
Both work on lists and on single elements.
* **`?expand=location,friends`** replaces those links’ ids with stub objects, one level deep: `{id, name, supertype, subtype, image_url}`. A field that is not a link is a `422`.
* **`?fields=id,name,description`** returns only the named keys.
```bash
curl -s "https://www.onlyworlds.com/api/v2/character/{id}?expand=location,institutions" -H "API-Key: {key}"
```
## The World
[Section titled “The World”](#the-world)
`GET /api/v2/world` returns the world named by the key:
```json
{ "id": "…", "name": "Hyperion", "description": "…", "image_url": "",
"time_format_names": [], "time_format_equivalents": [], "time_basic_unit": "Year",
"time_range_min": 0, "time_range_max": 500, "time_range_current": 500,
"public_read": false, "owner_character": null,
"created_at": "…", "updated_at": "…" }
```
`owner_character` is the Character that is the owner, or `null`. The response carries an `ETag`; send it back as `If-None-Match` and an unchanged world answers `304`. A `200` validates the key. Updating these fields is on [Writes](/docs/development/api/writes#the-world).
## Guests
[Section titled “Guests”](#guests)
A key with the guest role reads only part of a world, and everything else answers as if it did not exist. See [Guests](/docs/development/api/members#guests).
# Writes and Bulk
> Creating, replacing, updating and deleting elements, retrying safely, writing many elements in one call, and updating the world itself.
Writes take a write key (`ow_w_`, a legacy key, a member’s or an agent seat’s key) and the PIN in `API-Pin`. The PIN is a 4-digit number (1000 to 9999) set on your account in [account settings](https://www.onlyworlds.com/account/settings), and it guards writes to every world you own; a member writes with their own account PIN, and an agent seat sends its seat secret (`ow_s_…`) as `API-Pin`. A read key on a write route is `403` [`permission_error`](/api/errors/#permission_error). The key names the world, so a body never carries one.
## Create
[Section titled “Create”](#create)
`POST /api/v2/{type}` creates an element and returns `201` with the full element.
```bash
curl -s -X POST "https://www.onlyworlds.com/api/v2/location" \
-H "API-Key: {key}" -H "API-Pin: {pin}" -H "Content-Type: application/json" \
-d '{ "name": "The Time Tombs", "description": "Structures on Hyperion that move backward through time." }'
```
* You may supply an `id`: any RFC 4122 UUID is accepted, and UUIDv7 is recommended. Omit it and the server mints a UUIDv7.
* An `id` that already exists is `409` [`id_conflict`](/api/errors/#id_conflict). Element ids are unique across all worlds, so the message says which case it is: the id exists in this world (use `PUT` to upsert), or in another world (mint a new id).
## Upsert
[Section titled “Upsert”](#upsert)
`PUT /api/v2/{type}/{id}` creates the element if absent and **replaces it whole** if present: fields left out are reset to empty. It returns `201` when it created and `200` when it replaced. The path decides the id; an `id` in the body is dropped. An id held by an element in another world is `409` [`id_conflict`](/api/errors/#id_conflict).
## Partial Update
[Section titled “Partial Update”](#partial-update)
`PATCH /api/v2/{type}/{id}` changes only the fields sent and returns `200` with the full element.
* Omitted fields are left untouched.
* **Arrays replace.** A `PATCH` to `friends` sets the whole list; to add or remove single ids, use the [link operations route](/docs/development/api/links#link-operations).
* Extension fields merge by key.
* To clear a field, send its empty shape:
| Kind | Clear with |
| ----------- | ------------------------------- |
| Text | `""` or `null` (stored as `""`) |
| Number | `null` |
| Single link | `null` |
| Multi link | `[]` |
## Delete
[Section titled “Delete”](#delete)
`DELETE /api/v2/{type}/{id}` returns `204`. It is idempotent: deleting an element that is already gone also returns `204`. Deleting an element removes its id from every other element’s links, and the change feed records the delete as a [tombstone](/docs/development/api/changes).
## Field Rules
[Section titled “Field Rules”](#field-rules)
* **`name`** is required on `POST` and `PUT`: the key must be present, and an empty string is accepted.
* **Unknown fields** are a `422` naming the field. A misspelled field fails loudly instead of vanishing.
* **Extension fields** under the namespaces `atlas_*`, `shadow_*` and `x_*` are accepted, stored as written and returned verbatim, up to 65,536 bytes of extensions per element (counted as compact UTF-8 JSON). More is a `422` with `param` `extensions`.
* **Server fields** `type`, `created_at`, `updated_at` and `change_seq` appear on reads and are a `422` on writes. Strip them before sending a read body back.
* **Never write back a body read with `?expand=`.** Its links hold stub objects instead of ids, and a link field takes only ids. Read without `expand` when you mean to send the body back.
* **`created_by`** and **`world`** in a body are ignored, whatever their value.
* **Text length**: `name` holds up to 255 characters, `supertype` and `subtype` 128, `image_url` 1024. Longer is a `422`.
* **Values are coerced where they can be**: an integer field accepts `"7"` as 7, truncates `7.9` to 7, and reads `true` as 1; a value that cannot become an integer is a `422`. Text fields turn a number into its digits. Send the types the schema names.
## Idempotency
[Section titled “Idempotency”](#idempotency)
`POST /api/v2/{type}` and `POST /api/v2/bulk` accept an `Idempotency-Key` header (up to 200 characters). The first successful response is stored for 24 hours under that key:
* An identical replay returns the stored response, with an `Idempotent-Replay: true` header, and does not write again.
* The same key with a different body is `409` [`idempotency_error`](/api/errors/#idempotency_error).
* Only successful (`2xx`) responses are stored; errors never are. A `/bulk` answer is a `200` even when items failed, so it is stored, item errors included, and a replay returns the same errors.
The rule for retries:
* **The answer was lost** (a timeout, a dropped connection): retry with the **same** `Idempotency-Key`. A stored `2xx` replays; after an error, the request runs again.
* **A `/bulk` answer had `errors: true`**: fix the failed items and send them with a **new** key. Under the same key, an identical body replays the same errors, and a changed body is a `409` [`idempotency_error`](/api/errors/#idempotency_error).
```bash
curl -s -X POST "https://www.onlyworlds.com/api/v2/character" \
-H "API-Key: {key}" -H "API-Pin: {pin}" -H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2a9e-3b7d-4e0a-9c55-2f8d1b4a7e10" \
-d '{ "id": "0199a1c2-7d3e-7f00-8a11-3c5e7b9d2f40", "name": "The Consul" }'
```
The store suppresses replays on a best-effort basis; it is not a ledger. Minting the `id` on the client keeps a retry safe even without the header: two concurrent creates without client ids can make two elements.
## Bulk
[Section titled “Bulk”](#bulk)
`POST /api/v2/bulk` creates and upserts up to 1000 elements of mixed categories in one call.
```json
{
"items": [
{ "type": "institution", "element": { "id": "0199a1c2-…", "name": "The Hegemony" } },
{ "type": "character", "element": { "id": "0199a1c3-…", "name": "The Consul", "institutions": ["0199a1c2-…"] } }
],
"atomic": false
}
```
```json
{
"errors": false,
"items": [
{ "status": 201, "id": "0199a1c2-…", "created_at": "…", "updated_at": "…" },
{ "status": 201, "id": "0199a1c3-…", "created_at": "…", "updated_at": "…" }
]
}
```
* An item whose `element` carries an `id` upserts that id; one without an `id` creates. An `op` field on an item is optional and ignored.
* The answer is HTTP `200` once the batch is read. Results are per item, in request order, and each `status` is what a single write would have returned (`201`, `200`, or an error status).
* **Partial success is the default**: one bad item does not stop the rest. Send `"atomic": true` for all or nothing. In atomic mode, if `errors` is `true` **nothing was written**, even though the items that would have succeeded show `201` or `200`.
* A failed item carries the standard [error envelope](/api/errors/) under `error`; the top-level `errors` is `true` if any item failed. The codes seen per item are listed under [Bulk Errors](/api/errors/#bulk-errors).
* Links are checked against the world **plus the batch’s surviving items**, in any order, so a batch may reference its own items without sorting. All checks run before any write, and an item that fails also fails the items linking to it.
* Each success echoes the server’s `created_at` and `updated_at`, so a sync client can set its baseline from the bulk response alone.
* A malformed request (not valid JSON, `items` not an array, over 1000 items) or an auth failure answers with its own status and the error envelope, not `200`.
## The World
[Section titled “The World”](#the-world)
`PATCH /api/v2/world` updates the world’s own fields. It takes a write key and PIN and is **owner only**: a member’s key, even a co-builder’s, gets `403` [`owner_only`](/api/errors/#owner_only).
| Field | Type |
| -------------------------------------------------------- | ------------------------------------------------------------------------- |
| `name`, `description`, `image_url`, `time_basic_unit` | String |
| `time_format_names`, `time_format_equivalents` | List of strings |
| `time_range_min`, `time_range_max`, `time_range_current` | Integer or `null` |
| `owner_character` | A Character id in this world (the Character that is the owner), or `null` |
```bash
curl -s -X PATCH "https://www.onlyworlds.com/api/v2/world" \
-H "API-Key: {key}" -H "API-Pin: {pin}" -H "Content-Type: application/json" \
-d '{ "time_basic_unit": "Year", "time_range_current": 2732 }'
```
* Unknown fields are a `422`, and the whole patch is refused before anything is written.
* The world’s `name` cannot be empty, unlike an element’s.
* `public_read` is set in the [account portal](https://www.onlyworlds.com/account/) and the PIN in [account settings](https://www.onlyworlds.com/account/settings), not here.
* Every field sent is applied, so an identical value still moves `updated_at`. Send only real changes.
* World fields do not appear in [`/changes`](/docs/development/api/changes). To follow them, poll `GET /api/v2/world` and compare `updated_at`.
# Games
> Building a game on an OnlyWorlds world: carry it in the build, read it live, or write back into it, from Unity or any other engine.
To a game, a world on OnlyWorlds is content: characters, creatures, places, items, factions and events, with typed links between them. The world stays editable outside the game, on [onlyworlds.com](/docs/tools/onlyworlds-com), in [Atlas](/docs/tools/atlas) or in any tool built on the schema. So writers and designers can shape it without opening the engine, and the game picks up their work.
## Three Ways to Use a World
[Section titled “Three Ways to Use a World”](#three-ways-to-use-a-world)
### Bake It In
[Section titled “Bake It In”](#bake-it-in)
Fetch the world at build time and ship it inside the game. It works offline, needs no key at runtime, and changes only when you rebuild. This fits a world that is written first and then played.
* **Unity**: fill a [cache asset](/docs/development/unity#ship-a-world-with-your-game) in the Editor and reference it from your scripts.
* **Any engine**: read a world folder (one JSON file per element, the format Atlas and the [Python package](/docs/development/python) use), or take a [full export](/docs/development/api/changes#full-export) from the API, and convert it into the engine’s own data.
### Read It Live
[Section titled “Read It Live”](#read-it-live)
The game reads the world from the API while it runs, so a new character or a rewritten place reaches players without a patch. Fetch the world once, then ask the [changes feed](/docs/development/api/changes) for what changed since your cursor, rather than fetching everything again.
A read key (`ow_r_`) is made for this: it reads, cannot write, and needs no PIN. A key inside a shipped game can be extracted, so assume your players can read the whole world. See [Keys and PINs](/docs/getting-started/keys).
What a live read can count on:
* **It’s free**, with no fee and no account tiers.
* **There is no request quota on reads** per key.
* **Under load the server answers `503`** [`server_busy`](/api/errors/#server_busy) with a `Retry-After` header. Wait that long, then retry.
* **Image uploads have a daily cap** per world.
* **There is no uptime guarantee.**
So a game that must never break should bake its world in, and read live only the content that may change.
### Write Back
[Section titled “Write Back”](#write-back)
The game writes its outcomes into the world: who died, which town burned, what was found. The world becomes the record of play, and every other tool on it sees the result. Writing needs a write key and the PIN, so keep it on a server or in a tool you control, not in a game you ship to players.
Four rules keep a write from damaging work done elsewhere:
* **Send only the fields that changed.** A `PATCH` replaces every field it carries, so sending back an element fetched earlier undoes edits made since.
* **Change link lists with editLinks**, which adds and removes ids on the server. A `PATCH` with a link list replaces the whole list. See [Links](/docs/development/api/links).
* **Check every bulk response.** A bulk write answers HTTP 200 even when some items failed. See [Writes](/docs/development/api/writes).
* **Keep game-only state in extension fields** (`x_yourgame_*`), which the server stores and returns verbatim, up to 64 KB per element. A well-behaved client carries other tools’ extensions through untouched. See [Extension Fields](/docs/schema/fields#extension-fields).
## Engines
[Section titled “Engines”](#engines)
| Engine | The path today |
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Unity** | The [Unity SDK](/docs/development/unity): typed C# models, a v2 client, a world cache that ships as an asset, and a world browser in the Editor. |
| **Browser games** (JavaScript, TypeScript) | The [TypeScript SDK](/docs/development/typescript). |
| **Godot, Unreal and other engines** | The [REST API](/docs/development/api-reference) directly: HTTPS and JSON, with the key in a header (the call below). Generate a client from the [OpenAPI document](https://www.onlyworlds.com/api/v2/openapi.json), and types for the 22 element types from [schema-dist](https://github.com/OnlyWorlds/schema-dist) (its decoder, the walk, is Python today). |
| **Build pipelines and tools** | The [Python package](/docs/development/python) reads and writes world folders and talks to the API. |
Every path ends in the same call. This one reads three characters of Moppetopia, a public sample world, with its demo key, the same first run the SDKs start with:
```bash
curl -H "API-Key: 0000000001" \
"https://www.onlyworlds.com/api/v2/character/?fields=id,name&limit=3"
```
Unity is the first engine with an SDK of its own. Until another engine has one, its path is the API.
## Game Content in the Schema
[Section titled “Game Content in the Schema”](#game-content-in-the-schema)
The 22 element types cover most of what a game holds. A starting point:
| In your game | Element type |
| ------------------------------------------------------------- | ----------------------------------------------------------- |
| Player characters, NPCs | Character |
| Races and peoples | Species |
| Monster and animal types (goblin, frost wyrm, the stat block) | Species |
| Individual monsters (the goblin chief, a named dragon) | Creature, linked to its Species |
| Items, gear, loot | Object |
| Skills, spells, actions | Ability |
| Passive perks, qualities | Trait |
| Guilds, kingdoms, orders | Institution (organized) or Collective (no formal structure) |
| Magic systems, currencies, crafting rules | Construct |
| Rules of a faction or realm | Law |
| Ranks and offices | Title |
| Languages, including those spoken in dialogue | Language |
| Quests and storylines | Narrative |
| Battles and happenings | Event |
| Weather, curses, magic storms | Phenomenon |
| Reputation, rivalries, alliances | Relation |
| Towns, dungeons, levels | Location |
| Territories and regions | Zone |
| Maps and what is placed on them | Map, Pin, Marker |
The line between Character and Creature is agency, not species: an intelligent monster with goals of its own can be a Character. `supertype` and `subtype` hold your game’s own categories (a Creature with supertype `Boss`), so you don’t need new element types for them. Where two types are close, [Conventions](/docs/schema/conventions) draws the line between them. Each type’s fields are listed under [Schema](/docs/schema/).
## On OnlyWorlds Today
[Section titled “On OnlyWorlds Today”](#on-onlyworlds-today)
[Tactical Tangle](https://tangle.onlyworlds.com) is a hoplite battle game in which your OnlyWorlds characters fight in the ranks of a phalanx. Everything else built on OnlyWorlds is listed under [Tools](/docs/tools/).
# LLM Guide
> A single text file that teaches a chat assistant OnlyWorlds, so it can help you build a tool on the standard.
The LLM guide is one plain-text file, written for AI assistants, that teaches the OnlyWorlds standard, the 22 element categories, the API and the TypeScript SDK. Paste it into a chat and the assistant can help you plan and build your own tool on OnlyWorlds.
**The file**:
The guide is for people building their own tools. Someone who wants to build a world can go straight to [Atlas](/docs/tools/atlas), and someone working in Claude Code is better served by the [toolkit](/docs/development/toolkit), which the guide itself points to.
## Using It in a Chat
[Section titled “Using It in a Chat”](#using-it-in-a-chat)
1. Open the file and copy all of it, or download it.
2. Paste it into a new chat (ChatGPT, Claude, or a browser builder such as Lovable, Replit, v0.dev or ChatGPT Canvas).
3. Describe the tool you want. The guide tells the assistant to ask what problem it solves, who uses it, its one core interaction, and which element categories matter, then to write a short spec with you before writing code.
For full field definitions beyond the guide, point the assistant at the [schema](/docs/schema/) or at `FIELD_SCHEMA` in the [SDK](/docs/development/typescript).
## What It Covers
[Section titled “What It Covers”](#what-it-covers)
| Part | Contents |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1. How to help the user | Gauging experience (no coding, some coding, advanced), planning a tool before building it with a short spec, choosing between browser builders and a local setup, pointing Claude Code users to the toolkit, and local setup (Node.js, Git, an editor). |
| 2. OnlyWorlds essentials | What OnlyWorlds is (an open standard; a world as a folder of JSON files or a hosted world with a REST API), the 22 categories in six groups with one line each, how common concepts map onto them, and `x_` extension fields for custom data. |
| 3. Credentials and first call | Minting a key (`ow_w_`, `ow_r_`, `ow_a_`, legacy), where the PIN is set and when it is sent, the demo keys, keeping credentials out of chats, commits and browser bundles, and a first SDK call. |
| 4. SDK and API reference | Plain HTTP (the base URL, the `API-Key` and `API-Pin` headers, the error envelope); reads and cursor paging, filters, `expand` and `fields`; create, patch, upsert and delete; link fields and `editLinks`; bulk upload with client-minted ids and idempotency keys; the change feed; error codes; UI helpers (icons, colours, field groups, `FIELD_SCHEMA`); reading a world folder with no account. |
| 5. Deployment | Building, the static hosts the API accepts browser calls from without setup, deploying to Cloudflare Pages, and why a public site uses a read key. |
| 6. Resources | Links to Atlas, the developer start, the API reference, the SDK, the standard, schema-dist, the toolkit, the MCP server, the tools and the community. |
The file ends with its own changelog.
## Related
[Section titled “Related”](#related)
* [MCP server](/docs/development/mcp): connect an assistant to a world directly instead of pasting the guide.
* [Toolkit](/docs/development/toolkit): the same knowledge as Claude Code skills, readable by other agents as markdown.
* [AI agents](/docs/development/ai): the four ways an AI works with a world.
# MCP Server
> The hosted OnlyWorlds MCP server, how to connect a client to it, and the tools it offers.
OnlyWorlds runs a hosted [Model Context Protocol](https://modelcontextprotocol.io) server. Any MCP client can read and write your worlds through it, with nothing to install.
**Server URL**: `https://www.onlyworlds.com/mcp`
* **Transport**: streamable HTTP. Point an MCP client at the URL; there is no package to install.
* Opening the URL in a browser shows a plain info page. MCP clients POST JSON-RPC to the same URL.
* The old npm client (`@onlyworlds/mcp-client`) and the `/mcp/messages/` endpoint are retired. Use the hosted server above.
The server runs on the same schema registry and service layer as the [World API](/docs/development/api-reference): an element read or written through MCP has the same body, validation and permissions as through `/api/v2/`. The tools are shaped for assistants, so paging, filters and errors differ: see [How It Differs From REST](#how-it-differs-from-rest).
## Connect From Claude Code
[Section titled “Connect From Claude Code”](#connect-from-claude-code)
The schema tools need no account and no key:
```bash
claude mcp add --transport http onlyworlds https://www.onlyworlds.com/mcp
```
For your own world, the same command with your credentials as headers:
```bash
claude mcp add --transport http onlyworlds https://www.onlyworlds.com/mcp --header "API-Key: " --header "API-Pin: "
```
Each command is a single line: paste it whole. A backslash-continued form breaks in PowerShell.
## Connect Other Clients
[Section titled “Connect Other Clients”](#connect-other-clients)
Any MCP client that can send HTTP headers uses the same URL and the same two headers. Claude Desktop and the Anthropic API’s MCP connector work this way.
Codex reads the server from `~/.codex/config.toml`, taking the two header values from environment variables:
```toml
[mcp_servers.onlyworlds]
url = "https://www.onlyworlds.com/mcp"
env_http_headers = { "API-Key" = "OW_API_KEY", "API-Pin" = "OW_API_PIN" }
```
`OW_API_KEY` and `OW_API_PIN` are the names an [agent seat’s](/docs/development/agents) join response uses. Any variable names work, as long as the config and the environment agree.
## Credentials
[Section titled “Credentials”](#credentials)
Credentials travel as headers on every request, exactly as on the REST API.
| Header | Value |
| --------- | ---------------------------------------------------- |
| `API-Key` | A world key. The key scopes the server to one world. |
| `API-Pin` | The PIN, required for writes. |
* Keys are minted on your world’s page in the [account portal](https://www.onlyworlds.com/account/). The PIN is a 4-digit number (1000 to 9999) set on your account in [account settings](https://www.onlyworlds.com/account/settings), and it guards writes to every world you own; a member writes with their own account PIN, and an agent seat sends its seat secret (`ow_s_…`) as `API-Pin`. See [Keys and PINs](/docs/getting-started/keys).
* A read-only `ow_r_` key needs no PIN. A prefixed write key (`ow_w_`) reads without the PIN too; only a legacy 10-digit key reading a private world must send it.
* The client saves both headers in its configuration as written. For read-only use, connect with an `ow_r_` key and no PIN.
## Tools
[Section titled “Tools”](#tools)
The tools fall into three groups by what they need.
### Schema: No Key
[Section titled “Schema: No Key”](#schema-no-key)
| Tool | What it does |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `list_element_types` | List all 22 element categories with a one-line shape summary of each. |
| `get_element_schema` | Return the fields of one category, grouped by kind (text, integer, single link, multi link, generic), with each link field’s target category. |
| `search_schema` | Search every category’s fields for a substring, such as “which categories have a `location` field?”. |
### Read: A Key
[Section titled “Read: A Key”](#read-a-key)
| Tool | What it does |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `list_elements` | List elements of one category in the key’s world, newest first. Filters by `name_contains` and `supertype`; pages with `limit` (default 100, up to 1000) and `offset`. |
| `get_element` | Fetch one element by category and UUID, in the same shape as `GET /api/v2/{type}/{id}`. |
| `search_elements` | Search elements by name across all 22 categories in the world, up to 50 matches per category. |
| `get_changes` | Return the world’s delta feed (upserts and deletes since a cursor), paged: 25 entries per call by default, `limit` up to 1000. A guest key gets only what it can see and no deletes; when its view changes, the tool says to pull again from the start and replace the local copy. Mirrors [`GET /api/v2/changes`](/docs/development/api/changes). |
### Write: A Write Key and PIN
[Section titled “Write: A Write Key and PIN”](#write-a-write-key-and-pin)
| Tool | What it does |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `create_element` | Create one new element of a given category. Supply your own `id` (a UUID) or let the server mint one. |
| `update_element` | Update an existing element by category and id. A server-side read-merge: it changes only the fields you pass and leaves the rest intact. A multi-link field you pass replaces that field’s whole array. |
| `edit_links` | Add and/or remove links on one multi-link field, leaving the field’s other links untouched. |
| `bulk_apply` | Create and/or update up to 1000 elements across any categories in one call. An item with an `id` updates that element (creating it if absent); items can link to each other. With `atomic` true, any failure rolls the whole batch back. |
| `get_image_upload_ticket` | Return a single-use ticket for one image upload: `ticket`, `upload_url`, `max_bytes`, `exp`. The image never passes through the tool: the agent uploads the bytes itself to `upload_url` with the ticket, then calls `update_element` with the returned `url` as `image_url`. See [Images](/docs/development/api/images). |
Note
There is no delete tool, by design. The MCP server creates and edits; deletion stays in the REST API and the portal, so an assistant cannot remove elements on its own. `bulk_apply` never removes an element either.
## How It Differs From REST
[Section titled “How It Differs From REST”](#how-it-differs-from-rest)
| | MCP tools | REST (`/api/v2/`) |
| ------------------------ | ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
| Listing order | `list_elements`: newest created first | Change order, the order the cursor walks |
| Paging | `limit` and `offset`; the answer is `{data, limit, offset, has_more}` | `limit` and `cursor`; the answer is `{data, has_more, next_cursor}` |
| Name filter | `name_contains` (case-insensitive substring) | `name__icontains`, or `name` for an exact match. `offset` and `name_contains` are a `422` here |
| Other filters | `supertype` | `supertype`, `subtype`, and `characters` on six categories ([Reads](/docs/development/api/reads#filters)) |
| Search across categories | `search_elements` | None: filter one category at a time |
| Change feed | `get_changes`: `since_cursor`, 25 entries per page by default | `GET /changes`: `since`, 500 per page by default |
| Errors | A tool error carrying the human message, without `code` or the other envelope fields | The [error envelope](/api/errors/) |
| Retries | No `Idempotency-Key` | `Idempotency-Key` on `POST` and `/bulk` ([Writes](/docs/development/api/writes#idempotency)) |
| Delete | No tool | `DELETE /api/v2/{type}/{id}` |
## See Also
[Section titled “See Also”](#see-also)
* [World API](/docs/development/api-reference): the same data over REST, with the interactive reference at [onlyworlds.com/api/docs](https://www.onlyworlds.com/api/docs).
* [Agent seats](/docs/development/agents): give an agent its own key and Character in a world, then connect it here.
* [Toolkit](/docs/development/toolkit): Claude Code skills that work on a world folder or an account world.
# SDKs and Clients
> Every way to call OnlyWorlds from code, by language and by where it runs.
Every client below speaks the same REST API v2 and the same 22 element types. Pick by language, or by where your code runs.
| Client | For | Install | State |
| ---------------------------------------------- | ----------------------------------- | ------------------------------------------------------------ | -------------------------------- |
| [TypeScript SDK](/docs/development/typescript) | web apps, Node tools, browser games | `npm install @onlyworlds/sdk` | published on npm |
| [Python package](/docs/development/python) | scripts, pipelines, world folders | `pip install "git+https://github.com/OnlyWorlds/python-sdk"` | pre-release, not on PyPI yet |
| [Unity SDK](/docs/development/unity) | Unity games and tools | Package Manager, by git URL | public, interface not yet stable |
| [MCP server](/docs/development/mcp) | AI assistants and agents | nothing to install: `https://www.onlyworlds.com/mcp` | live |
| Plain REST | any other language or engine | none | live |
The TypeScript, Python and Unity clients generate their types from [schema-dist](https://github.com/OnlyWorlds/schema-dist), the published copy of the schema. For other engines, see [Games](/docs/development/games).
## Plain REST
[Section titled “Plain REST”](#plain-rest)
The API needs no SDK. Send the key (and, for writes, the PIN) as headers:
```python
import requests
headers = {"API-Key": "your-key", "API-Pin": "your-pin"}
r = requests.get("https://www.onlyworlds.com/api/v2/character/", headers=headers)
characters = r.json()["data"]
```
List responses come back in a `{data, has_more, next_cursor}` envelope, so paginate on `next_cursor`. To generate a client in another language, start from the OpenAPI document at `https://www.onlyworlds.com/api/v2/openapi.json`; the [interactive reference](https://www.onlyworlds.com/api/docs) is built from the same document.
## Retired
[Section titled “Retired”](#retired)
The `@onlyworlds/mcp-client` npm package is retired: the MCP server is hosted now, with nothing to install.
# Python Package
> The onlyworlds Python package: the world folder format and a client for the v2 API. A pre-release, installed from GitHub.
Pre-release
The package is not on PyPI yet. Install it from GitHub, and expect the interface to change before the first release.
`onlyworlds` is the Python package for OnlyWorlds. It reads and writes the world folder format, pushes a folder’s changes to a world, and has a client for the REST API v2. It needs Python 3.12 or later and has no runtime dependencies.
The 22 element types and the kind of every field are generated from [schema-dist](https://github.com/OnlyWorlds/schema-dist), never listed by hand. The package’s version is its own and does not track the schema’s.
## Install
[Section titled “Install”](#install)
```bash
pip install "git+https://github.com/OnlyWorlds/python-sdk"
```
The `onlyworlds` package on TestPyPI is an older, unrelated package for the v1 API.
## Read a world
[Section titled “Read a world”](#read-a-world)
```python
from onlyworlds import Client
client = Client("ow_r_...") # a read key needs no PIN
for character in client.iter_elements("character"):
print(character["name"])
```
A write key also takes a PIN: `Client(key, pin)`. The PIN is a 4-digit number (1000 to 9999) set on your account in account settings, and it guards writes to every world you own. A member writes with their own account PIN, and an agent seat sends its seat secret (`ow_s_…`) as the PIN.
Elements are plain dicts. Two demo keys read public sample worlds: `0000000000` (Hyperion) and `0000000001` (Moppetopia).
## Write
[Section titled “Write”](#write)
```python
writer = Client("ow_w_...", "1234") # a write key and its PIN
peak = writer.create("location", {"name": "Dragon Peak"})
writer.patch("location", peak["id"], {"supertype": "Mountain"})
# add or remove links on a multi-link field without replacing the list
writer.edit_links("location", peak["id"], "founders", add=[character_id])
```
`create` gives an element without an `id` one, and always sends an `Idempotency-Key`. So retrying a create after a lost answer is safe: it replays the stored result instead of making a second element. `patch` replaces every field it sends, and a list replaces the whole list. Use `edit_links` to add or remove links. `bulk` takes up to about 1,000 `{"type", "element"}` items: it succeeds partly by default (check each slot’s `status`), or all or nothing with `atomic=True`.
## Follow changes
[Section titled “Follow changes”](#follow-changes)
```python
walk = client.walk_changes(saved_cursor) # None for everything
for op in walk:
... # op["op"] is "upsert" or "delete"
saved_cursor = walk.cursor # opaque: persist it, never parse it
```
If the feed refuses a cursor (`is_resync_required` on the error), walk again from the start and replace the local copy. See [Sync with Changes](/docs/development/api/changes).
## What it covers
[Section titled “What it covers”](#what-it-covers)
* **World folders**: `read_folder` and `write_folder` implement the folder format and pass its shared conformance fixture. The reader never changes a folder.
* **Push**: `plan_push` and `push` compare a folder with a baseline and PATCH only the fields that changed. A rerun skips what already landed.
* **Client**: `get_world`, `patch_world`, `list_page`, `iter_elements`, `get`, `create`, `upsert`, `patch`, `delete`, `edit_links`, `bulk`, `changes` and `walk_changes`. `create` and `bulk` always send an `Idempotency-Key`, so a retry after a lost answer is safe: it never creates an element twice.
* **Errors**: `ApiError` carries the envelope’s `code`, `param` and `doc_url`, with flags such as `is_id_conflict`, `is_not_author`, `is_resync_required` and `is_validation_error`.
* **Export**: `export_world`.
Not yet: typed element models, the account routes and the snapshot writer.
## Links
[Section titled “Links”](#links)
* [Source and README](https://github.com/OnlyWorlds/python-sdk)
* [Interactive API reference](https://www.onlyworlds.com/api/docs)
# Toolkit
> The OnlyWorlds toolkit, a Claude Code plugin of skills for structuring, growing, playing in and building on worlds.
The toolkit is a Claude Code plugin: a set of skills, an orchestration agent and a knowledge base for working with worlds. Its skills follow the life of a world: structure it, grow it, play in it and keep it true, build on it. Any AI that reads markdown can use its files; it is built for Claude Code.
**Repository**: [github.com/OnlyWorlds/toolkit](https://github.com/OnlyWorlds/toolkit)
## Install
[Section titled “Install”](#install)
In Claude Code:
```bash
/plugin marketplace add OnlyWorlds/toolkit
/plugin install toolkit@onlyworlds
```
Restart Claude Code to load it. Or load it straight from a clone, with no restart:
```bash
git clone https://github.com/OnlyWorlds/toolkit
claude --plugin-dir ./toolkit
```
### Other AIs
[Section titled “Other AIs”](#other-ais)
Point the assistant at the knowledge files, in this order:
1. [principles.md](https://raw.githubusercontent.com/OnlyWorlds/toolkit/main/knowledge/principles.md): the shared rules every skill follows
2. [world-folder.md](https://raw.githubusercontent.com/OnlyWorlds/toolkit/main/knowledge/world-folder.md): the world as files, and keeping its history
3. [world-memory.md](https://raw.githubusercontent.com/OnlyWorlds/toolkit/main/knowledge/world-memory.md): loading a world into AI sessions and writing each session back
4. [onlyworlds-core.md](https://raw.githubusercontent.com/OnlyWorlds/toolkit/main/knowledge/onlyworlds-core.md)
5. [modeling-patterns.md](https://raw.githubusercontent.com/OnlyWorlds/toolkit/main/knowledge/modeling-patterns.md)
6. [schema-reference.md](https://raw.githubusercontent.com/OnlyWorlds/toolkit/main/knowledge/schema-reference.md)
7. [ai-builders.md](https://raw.githubusercontent.com/OnlyWorlds/toolkit/main/knowledge/ai-builders.md)
The skill files themselves sit in the repository’s `skills/` folder, one folder per skill, and read as plain markdown.
## Skills
[Section titled “Skills”](#skills)
| Stage | Skill | What it does |
| --------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| Start | `onlyworlds-start` | The entry point: works out your situation and routes you to the right skill. |
| Structure it | `parsing` | Turns text, files or folders (notes, novels, campaign docs, wikis) into a structured world: JSON, or a world folder Atlas opens. |
| | `modeling` | Design help for complex systems (magic, politics, economies, tech trees) as linked elements. |
| | `schema` | Field reference across all 22 categories: look up fields, validate structure, tell similar categories apart. |
| Grow it | `survey` | Reads a world and writes a creative brief of its themes, tensions and structure. |
| | `link` | Finds missing connections, enriches links and resolves orphaned elements. |
| Play in it and keep it true | `context` | Designs how the right part of a world gets loaded into each AI session: fresh, scoped, sourced. |
| | `resolve` | Designs how a finished session (notes, recap, transcript) gets written back into the world, so canon holds. |
| Build on it | `api` | Reads, creates, updates and deletes elements through natural language, handling auth, endpoints and field mapping. |
| | `dev` | SDK integration, project scaffolding, credentials and deployment. |
| | `council` | Browse and draft schema governance motions when the 22 categories don’t fit. |
| | `project-setup` | Connects a project to an OnlyWorlds account world: credentials and a local cache. Runs when another skill needs it. |
The orchestration agent, `ow-agent`, chains several skills for multi-step jobs, such as parsing a whole corpus and then linking and surveying it. The knowledge base covers the schema reference, the category descriptions, and decision trees for ambiguous modeling choices.
## A World Folder or an Account World
[Section titled “A World Folder or an Account World”](#a-world-folder-or-an-account-world)
Most of the toolkit runs on files alone, with no account:
* `parsing`, `modeling` and `schema` work fully standalone.
* `survey` and `link` read a world folder straight off disk: an Atlas world, converter output, or anything in the [world folder shape](https://github.com/OnlyWorlds/toolkit/blob/main/knowledge/world-folder.md).
* `parsing` can write that folder shape directly, so your notes become a world Atlas opens.
* `context` and `resolve` work with whatever holds the world: a folder, your own notes, or an OnlyWorlds world.
With an OnlyWorlds account, the same skills work on an account world over the [API](/docs/development/api-reference). `project-setup` connects the project (a `.env` you fill with your key and PIN as `ONLYWORLDS_API_KEY` and `ONLYWORLDS_API_PIN`, kept out of git), and `api` and `dev` build on it. To connect an agent to a world directly, see the [MCP server](/docs/development/mcp).
## Example Requests
[Section titled “Example Requests”](#example-requests)
```text
"Help me organize my worldbuilding notes"
"Turn my notes into a world folder"
"How should I model a magic system?"
"What fields does Character have?"
"Find orphaned elements in my world"
"My AI keeps forgetting my campaign's canon"
"Turn last session's notes into world updates"
"I'm building a game with world data"
```
# TypeScript SDK
> The @onlyworlds/sdk package, a typed client for the v2 API and the schema's constants, generated from schema-dist.
`@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](https://github.com/OnlyWorlds/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”](#install)
```bash
npm install @onlyworlds/sdk
```
## Read a world
[Section titled “Read a world”](#read-a-world)
```typescript
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](/docs/getting-started/keys) for the key types.
## Write
[Section titled “Write”](#write)
```typescript
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 `_id` and `_ids` suffixes belong to v1.
* Never send a `world` field: the key decides the world, and the client strips the field.
* `name` is the one required field. `''` and `null` are accepted and stored as `''`.
* An `id` is minted on the client when you leave it out, so a retried create stays idempotent. (A plain REST request without an `id` gets one from the server instead.)
* `patch` replaces every field it sends, and an array replaces the whole list. To add or remove links without replacing them, use `editLinks`.
## Bulk writes
[Section titled “Bulk writes”](#bulk-writes)
A bulk write succeeds partly by default: HTTP 200 with a status per slot. Check `errors` every time.
```typescript
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.wasReplay` is true when the server answered from that store.
## Images
[Section titled “Images”](#images)
```typescript
const image = await writer.uploadImage(file); // a Blob, File, ArrayBuffer or Uint8Array
await 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 `.
## Follow changes
[Section titled “Follow changes”](#follow-changes)
```typescript
let cursor; // opaque and never expires: persist it
for 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](/docs/development/api/changes).
## Errors
[Section titled “Errors”](#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](/api/errors). Show `docUrl` to your users. Transport failures throw `OwNetworkError`. `err.isValidationError` covers the common case.
## Schema constants
[Section titled “Schema constants”](#schema-constants)
```typescript
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?”](#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](/docs/development/mcp) at `https://www.onlyworlds.com/mcp`. It takes the same `API-Key` and `API-Pin` headers.
## Links
[Section titled “Links”](#links)
* [npm](https://www.npmjs.com/package/@onlyworlds/sdk) · [Source](https://github.com/OnlyWorlds/sdk) · [Changelog](https://github.com/OnlyWorlds/sdk/blob/main/CHANGELOG.md) · [Migrating from 3.x to 4.x](https://github.com/OnlyWorlds/sdk/blob/main/docs/migrating-3-to-4.md)
* [Interactive API reference](https://www.onlyworlds.com/api/docs)
## Reference
[Section titled “Reference”](#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”](#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 . |
| `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”](#methods)
| Method | Route | What it does | Returns |
| ---------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| `health()` | `GET /health` | unauthenticated liveness pulse. | `Promise` |
| `getWorld()` | `GET /world` | world meta (name, calendar/time fields, public_read). | `Promise` |
| `patchWorld(partial)` | `PATCH /world` | partial world-meta update. | `Promise` |
| `list(type, params?)` | `GET /{type}/` | one cursor page. | `Promise` |
| `listAll(type, params?)` | | Cursor-walk every page of a type. | `AsyncGenerator` |
| `get(type, id, opts?)` | `GET /{type}/{id}/` | optional one-level stub expansion / sparse fields. | `Promise` |
| `create(type, element, opts?)` | `POST /{type}/` | create one element, minting a UUIDv7 id on the client when you leave id out. | `Promise` |
| `upsert(type, id, element)` | `PUT /{type}/{id}/` | The local-first write primitive. | `Promise` |
| `patch(type, id, partial)` | `PATCH /{type}/{id}/` | DESTRUCTIVE on sent fields: arrays replace wholesale, omitted fields stay untouched. | `Promise` |
| `delete(type, id)` | `DELETE /{type}/{id}/` | idempotent (204 on absent). | `Promise` |
| `editLinks(type, id, field, edit)` | `POST /{type}/{id}/links/{field} with {add, remove}` | atomic link merge. | `Promise` |
| `bulk(items, opts?)` | `POST /bulk` | up to \~1000 items. | `Promise` |
| `changes(opts?)` | `GET /changes` | one page of the world’s ordered change feed. | `Promise` |
| `changesAll(since?)` | | Walk the feed from `since` (or from zero = full export) to the current tail, yielding ops in order. | `AsyncGenerator` |
| `createMediaTicket()` | `POST /media/ticket` | permission to upload ONE image into this world (a write key and its PIN; no body). | `Promise` |
| `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` |
| `request(method, path, opts?)` | | Raw authenticated request against this client’s baseUrl. | `Promise` |
# Unity SDK
> The OnlyWorlds Unity SDK: typed C# models for the 22 element types, a client for the v2 API, and a world cache that lives in your project as an asset.
OnlyWorlds is an open schema for world data: characters, creatures, places, items, factions and events as 22 element types with typed links between them. Worlds are hosted on [onlyworlds.com](https://www.onlyworlds.com) and edited there or in any tool built on the schema. For what that gives a game, see [Games](/docs/development/games).
`com.onlyworlds.sdk` reads and writes those worlds from Unity. It has three parts:
| Part | Assembly | What it is |
| ---------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Bridge** | `OnlyWorlds.Sdk` | Typed models for the 22 element types, a client for the REST API v2, and sync from the changes feed. This is what game code links against. |
| **Cache** | `OnlyWorlds.Sdk` | A world as a `ScriptableObject` asset: inspectable, offline, and kept across domain reloads. Filled from the API or from a world folder on disk. |
| **Viewer** | `OnlyWorlds.Sdk.Editor` | A world browser in the Editor: **Window → OnlyWorlds → World Browser**. |
The models are generated from [schema-dist](https://github.com/OnlyWorlds/schema-dist) at a pinned tag, never written by hand. The source is [OnlyWorlds/unity-sdk](https://github.com/OnlyWorlds/unity-sdk), under the MIT licence.
Early
The package is public and in use, but it carries no compatibility promise yet, so expect the interface to change between releases. The [changelog](https://github.com/OnlyWorlds/unity-sdk/blob/main/Packages/com.onlyworlds.sdk/CHANGELOG.md) says what each version holds.
## Install
[Section titled “Install”](#install)
Unity 6 (6000.0) or later, with [Git](https://git-scm.com) installed: the Package Manager needs it to add a package from a git URL. In the Package Manager: **+ → Add package from git URL**, and paste:
```plaintext
https://github.com/OnlyWorlds/unity-sdk.git?path=/Packages/com.onlyworlds.sdk
```
To pin a release, add a tag from the [repository’s tags](https://github.com/OnlyWorlds/unity-sdk/tags) to the end of the URL (`…com.onlyworlds.sdk#v`). The package depends on Newtonsoft JSON (`com.unity.nuget.newtonsoft-json`), which the Package Manager resolves for you.
**Platforms**: the requests go through `UnityWebRequest`, and the JSON converters are marked to survive IL2CPP code stripping, which was checked by inspecting a stripped Android build. The package has not yet been run on a device, or in a WebGL build.
The package ships a **Quick Start** sample (Package Manager → OnlyWorlds SDK → Samples). It reads a world at runtime, from the API or from a cache asset, and shows nullable fields, link resolution and error handling.
## Read a World
[Section titled “Read a World”](#read-a-world)
No account yet? The demo key `0000000001` reads Moppetopia, a public sample world, with no PIN. It’s the same key the other SDKs start with, so the output matches theirs.
```csharp
using OnlyWorlds.Sdk;
using UnityEngine;
public class ReadWorld : MonoBehaviour
{
// A read key (ow_r_) needs no PIN. A key in a build can be extracted from it,
// so ship only a read key, and only for a world your players may read.
[SerializeField] private string apiKey = "0000000001";
private async void Start()
{
var client = new OWClient(new OWClientConfig
{
ApiKey = apiKey,
Transport = new UnityWebRequestTransport(),
});
var characters = await client.ListAllAsync("character");
foreach (var c in characters)
{
var level = c.Level.HasValue ? c.Level.Value.ToString() : "unset";
Debug.Log($"{c.Name}: level {level}");
}
}
}
```
`ListAllAsync` follows the cursor through every page. `ListAsync` returns one page, and `GetAsync` one element by id. See [Keys and PINs](/docs/getting-started/keys) for the kinds of key.
**Links are ids.** `c.Location` is a location’s id, or `null` when unset, and `c.Species` is a list of ids. A cache resolves them: `cache.Get(c.Location)`, `cache.Resolve(c.Species)`. Without a cache, you fetch each by id.
**Errors come in two kinds.** `OWApiError` means the server answered and refused: it carries `StatusCode`, `Code`, `Param`, `DocUrl` (a link into [Errors](/api/errors/)) and `IsRetryable`. `OWTransportError` means no answer arrived at all.
## Ship a World With Your Game
[Section titled “Ship a World With Your Game”](#ship-a-world-with-your-game)
A cache is a world stored as an asset in your project. Fill it in the Editor, and the build carries it: no network, no key and no waiting at runtime.
1. Set a key in **Window → OnlyWorlds → World Browser → Settings**. It is stored per machine in `EditorPrefs`, never in the project or in version control.
2. **Connect**, then **Sync to Cache**. The cache asset is written under `Assets/OnlyWorlds/`. **Open Folder…** fills a cache from a world folder on disk instead.
3. Reference the asset from your scripts:
```csharp
[SerializeField] private OWWorldCache world;
void Start()
{
foreach (var c in world.All("character"))
Debug.Log($"{c.Name} lives in {world.Get(c.Location)?.Name}");
}
```
The same steps run from code. `OWSync.BaselineAsync(client, cache)` fetches the whole world. `OWSync.IncrementalAsync(client, cache)` applies only what changed since the cache’s cursor, from the [changes feed](/docs/development/api/changes), and falls back to a full baseline when the cursor can’t be trusted. Edits to world metadata are not in the feed, so poll `GetWorldAsync` when those matter. `OWFolderLoader.LoadInto(cache, path)` reads a world folder and never writes to it.
## Write
[Section titled “Write”](#write)
Writes need a write key (`ow_w_`) and a secret in `OWClientConfig.ApiPin`: best an [agent seat](/docs/development/agents)’s own `ow_s_` secret, which works in one world and can be removed, or else the account PIN. Both can be extracted from a build, so write from the Editor, a server or a tool you control, never from a game you ship to players.
**Send only what changed.** A `PATCH` replaces every field it carries, so sending back an element fetched an hour ago undoes an hour of someone else’s edits. `OWEdit` snapshots an element and sends the difference:
```csharp
var edit = OWEdit.Begin(character);
character.Level = 12;
await edit.CommitAsync(client, "character"); // sends { "level": 12 } and nothing else
```
**Change link lists with `EditLinksAsync`.** A `PATCH` with a link list replaces the whole list. `EditLinksAsync("character", id, "friends", add: new[] { friendId })` adds and removes on the server, in one atomic step.
**Check every bulk write.** `BulkAsync` answers HTTP 200 even when some items failed. Read the result’s `Errors` and `Failed`, or call `ThrowIfAnyFailed()`.
**Other tools’ data survives.** Extension fields (`x_*`) that the models don’t know are kept verbatim through a read, an edit and a write, in the cache, and through Unity’s serializer. The five fields the server owns (`world`, `type`, `created_at`, `updated_at`, `change_seq`) are stripped from every write, so a body you read can be written straight back.
`OWFolderWriter` writes elements and the world into a world folder, byte for byte as the format specifies, so a folder kept in git doesn’t change from one machine to the next.
## Common Pitfalls
[Section titled “Common Pitfalls”](#common-pitfalls)
* **`""` is how a string field is unset.** The API never sends `null` for text. Test with `string.IsNullOrEmpty`, never `== null`. A single link is different: its unset value is `null`.
* **`null` is not `0`.** Nullable numbers are `SerializableNullable`, which keeps unset, a deliberate zero and absent apart. You can assign a plain `T`, but reading one makes you handle the unset case. The Inspector shows unset as `--`, never as `0`.
* **`UnityWebRequest` runs on the main thread only.** The client routes every request there, including the pages it fetches after an `await`. Never block on a request with `.Result` or `.Wait()` in the Editor: the completion arrives on the update loop, so blocking it deadlocks.
* **Only `name` is required.** Every other field can be empty, and the API sends the empty value (`null`, `""` or `[]`) rather than leaving the key out.
## Status
[Section titled “Status”](#status)
The package is early: public and in use, with no compatibility promise yet. All 22 element models are generated, and a drift check in the repository keeps them in step with the pinned schema. The package README holds more on each rule above, and the repository’s README explains how to work on the SDK itself and run its tests.
# Getting Started
> What OnlyWorlds is, and how to make a first read and write against a world.
This page is for developers and AI agents that read and write worlds through the API. To build a world without code, start at [onlyworlds.com/start](https://www.onlyworlds.com/start) or open [Atlas](https://atlas.onlyworlds.com).
OnlyWorlds is an open standard for world data. It structures a world into 22 element categories (Characters, Locations, Events and more) with fields and typed links between them, so any tool or AI agent that speaks the schema can read and write the same worlds.
It has four layers:
| Layer | What it is |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Standard** | The schema: 22 element categories defined in YAML, governed in public by the [Council](https://council.onlyworlds.com). See [Schema](/docs/schema/). |
| **Platform** | [onlyworlds.com](https://www.onlyworlds.com) stores worlds, their members and roles, and issues per-world keys. |
| **Interfaces** | The REST API, the change feed, the MCP server and agent join links. |
| **Clients** | Apps and tools (Atlas, the Obsidian plugin, converters), SDKs, and AI agents. |
A [video introduction](https://youtu.be/1IEazx8wg4I) covers the project as a whole.
## Your First Call
[Section titled “Your First Call”](#your-first-call)
### 1. Get a Key
[Section titled “1. Get a Key”](#1-get-a-key)
Sign up at [onlyworlds.com](https://www.onlyworlds.com/accounts/signup/) and create a world. Then mint a key for it in the [account portal](https://www.onlyworlds.com/account/):
| Key | Scope |
| -------- | ----------------------------------------------------- |
| `ow_w_…` | One world, read and write. Writes also need your PIN. |
| `ow_r_…` | One world, read only. No PIN, safe to share. |
Each key is scoped to one world: the key decides which world a request reads or writes, so you never send a world id. Older 10-digit keys still work and never expire, but new ones are no longer issued. [Keys and PINs](/docs/getting-started/keys) covers every key kind, the PIN and account tokens.
Send the key as the `API-Key` header, and the PIN as `API-Pin` on writes. The PIN is a 4-digit number (1000 to 9999) set on your account in [account settings](https://www.onlyworlds.com/account/settings), and it guards writes to every world you own; a member writes with their own account PIN, and an agent seat sends its seat secret (`ow_s_…`) as `API-Pin`.
### 2. Read
[Section titled “2. Read”](#2-read)
List the Characters in a world:
```bash
curl -H "API-Key: ow_r_your_key" \
"https://www.onlyworlds.com/api/v2/character/"
```
Lists come in an envelope: `{"data": [...], "has_more": false, "next_cursor": null}`. When `has_more` is true, pass `next_cursor` back as `?cursor=` for the next page. To try this before you have a world, use Hyperion’s demo key `0000000000`, which reads without a PIN ([Demo Keys](/docs/getting-started/keys#demo-keys) lists the others).
Every one of the 22 categories has the same routes at its singular name: `/api/v2/location/`, `/api/v2/event/`, and so on. [Reads and Pagination](/docs/development/api/reads) covers filters, sparse fields and expansion.
### 3. Write
[Section titled “3. Write”](#3-write)
Create a Character with a write key and the PIN. Only `name` is required: the key must be present, and an empty string is accepted.
```bash
curl -X POST "https://www.onlyworlds.com/api/v2/character/" \
-H "API-Key: ow_w_your_key" \
-H "API-Pin: 1234" \
-H "Content-Type: application/json" \
-d '{"name": "The Consul"}'
```
The response is `201` with the full element, including its server-assigned `id`. Change it with `PATCH`, sending only the fields you change. A link is the id of the element it points to:
```bash
curl -X PATCH "https://www.onlyworlds.com/api/v2/character//" \
-H "API-Key: ow_w_your_key" \
-H "API-Pin: 1234" \
-H "Content-Type: application/json" \
-d '{"description": "Diplomat of the Hegemony.", "location": ""}'
```
A read key on a write route answers `403` ([`permission_error`](/api/errors/#permission_error)). [Writes and Bulk](/docs/development/api/writes) covers upserts, deletes and clearing fields; [Link Fields](/docs/development/api/links) covers adding and removing links without reading first.
## Where to Go Next
[Section titled “Where to Go Next”](#where-to-go-next)
* **Build an app or script**: [SDKs](/docs/development/packages) for TypeScript, Python and Unity, or the [API reference](/docs/development/api-reference).
* **Connect an AI agent**: the [MCP server](/docs/development/mcp), agent seats, the LLM guide and the toolkit, under [AI Agents](/docs/development/ai).
* **Learn the data model**: [Schema](/docs/schema/), [Fields](/docs/schema/fields) and [Conventions](/docs/schema/conventions).
* **Build a world without code**: the guide at [onlyworlds.com/start](https://www.onlyworlds.com/start) covers which tool fits how you work and how to bring in existing material. [Atlas](https://atlas.onlyworlds.com) is the recommended workspace and keeps your world as plain files on your own disk; the [Obsidian plugin](https://github.com/OnlyWorlds/obsidian-plugin) syncs a world as markdown notes. Every tool is listed under [Tools](/docs/tools/).
* **Shape the schema**: motions and votes happen at [council.onlyworlds.com](https://council.onlyworlds.com).
# Keys and PINs
> The credentials the OnlyWorlds API accepts, which headers carry them, and when the PIN is needed.
A **world key** names one world and says what the caller may do in it. The **PIN** proves the caller may write. Both are managed in the [account portal](https://www.onlyworlds.com/account/), where keys are minted and revoked.
## Headers
[Section titled “Headers”](#headers)
```http
API-Key: {key}
API-Pin: {pin}
```
The header names are exactly `API-Key` and `API-Pin`. The key alone determines the world, so no request passes a world id.
## Key Types
[Section titled “Key Types”](#key-types)
| Key | What it is | Reads | Writes |
| ------------------- | ---------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `ow_w_…` | World write key | Key alone | Key and PIN |
| `ow_r_…` | World read key | Key alone | Refused: `403` [`permission_error`](/api/errors/#permission_error) |
| 10-digit legacy key | The original world key | Key alone; a private world also needs the PIN | Key and PIN |
| `ow_a_…` | Account token | Acts on your account, not on one world: see [Account Tokens](#account-tokens) | |
* Each world key (`ow_w_`, `ow_r_`, legacy) is scoped to one world.
* Legacy 10-digit keys keep working and stay valid. New ones are no longer issued: mint a prefixed key instead.
* A new key is shown once, when it is minted. Key lists afterwards show only its last four characters.
* `ow_r_` keys are made for sharing: hand one to players or readers and they can read the world without any second secret.
* Both the current API and the [Classic API](/docs/development/api/classic) accept prefixed and legacy keys.
## Demo Keys
[Section titled “Demo Keys”](#demo-keys)
The legacy keys `0000000000` to `0000000009` are reserved for demo worlds. They are read-only on every route (a write is `403` [`permission_error`](/api/errors/#permission_error)) and read without a PIN. These answer today:
| Key | World | Try |
| ------------ | ---------------------------------- | ----------------------------------------------- |
| `0000000000` | Hyperion, the public example world | `GET /api/v2/character?fields=id,name` |
| `0000000001` | Moppetopia | `GET /api/v2/character?name__icontains=admiral` |
The rest of the range is not for public use; unassigned keys answer `401` [`invalid_credentials`](/api/errors/#invalid_credentials).
## The PIN
[Section titled “The PIN”](#the-pin)
The PIN is a 4-digit number (1000 to 9999) set on your account in [account settings](https://www.onlyworlds.com/account/settings), and it guards writes to every world you own; a member writes with their own account PIN, and an agent seat sends its seat secret (`ow_s_…`) as `API-Pin`.
* **A world with a PIN requires it on every write.** New worlds always have one. An account without a PIN chooses it when creating its first world, and changing it in account settings changes it on every world the account owns.
* **Reads with a prefixed key (`ow_w_`, `ow_r_`) never need the PIN.** Only a legacy 10-digit key reading a private world must send it.
* Failed PIN attempts are throttled. Too many answer `429` [`rate_limited`](/api/errors/#rate_limited) with a `Retry-After` header, and repeated failures escalate to a temporary lockout.
Creating a world through the API with an account token: an account without a PIN sends `pin` (4 digits, 1000 to 9999) with its first world; an account that has one sends `name` only, and a `pin` alongside it is a `422`.
## Member Keys
[Section titled “Member Keys”](#member-keys)
A world can have members besides its owner (see [Members and Sharing](/docs/development/api/members)). A member mints their own keys for the world in their own account, and **a member’s key writes with the member’s own account PIN**, never the owner’s. A member without an account PIN cannot accept an invite or mint a write key: `409` [`pin_required`](/api/errors/#pin_required).
Removing a member, or a member leaving, deletes that member’s keys for the world.
## Agent Seat Keys
[Section titled “Agent Seat Keys”](#agent-seat-keys)
An AI agent that joins a world through an agent link receives, once, an `ow_w_` key and a seat secret (`ow_s_…`). The agent sends the secret as `API-Pin` on writes. It is never the world’s PIN or any account’s PIN, and reads take the key alone. Joining is described on [AI Agents](/docs/development/agents).
## Checking a Key
[Section titled “Checking a Key”](#checking-a-key)
`GET /api/v2/me` answers who the calling key is, for any key and without a PIN:
```bash
curl -s "https://www.onlyworlds.com/api/v2/me" -H "API-Key: {key}"
```
```json
{ "world": { "id": "0695…", "name": "Hyperion" }, "scope": "write", "kind": "owner",
"role": "owner", "membership": null, "character": "0698…" }
```
`GET /api/v2/world` also validates a key: a `200` means it is accepted.
### Checking the PIN
[Section titled “Checking the PIN”](#checking-the-pin)
No route checks a PIN without a write. `/me` and every read take the key alone (except a legacy key reading a private world), so a `200` there confirms the key, not the PIN. The PIN is checked on the first write, and a wrong PIN answers the same `401` [`invalid_credentials`](/api/errors/#invalid_credentials) as a bad key. The `message` tells them apart:
| Answer | `message` | Meaning |
| --------------------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------- |
| `401` `invalid_credentials` | `No valid API-Key.` | The key is missing or unknown |
| `401` `invalid_credentials` | `Incorrect PIN.` | The key is valid; the PIN is missing or wrong |
| `401` [`key_revoked`](/api/errors/#key_revoked) | | The key was recognized but revoked |
| `403` [`permission_error`](/api/errors/#permission_error) | `This key is read-only and cannot write to this world.` | The key is genuine but lacks the scope, such as a read key on a write route |
| `429` [`rate_limited`](/api/errors/#rate_limited) | `Too many failed PIN attempts. Try again later.` | Too many wrong PINs: wait the `Retry-After` seconds |
Message wording may change: branch on `code`, and use the message only to tell a person what to fix.
## Keeping Credentials Safe
[Section titled “Keeping Credentials Safe”](#keeping-credentials-safe)
* A key and PIN in browser code are visible to anyone who opens the page. Ship only an `ow_r_` read key in a public page, or ask each visitor for their own credentials at runtime. See [CORS](/docs/development/api/cors).
* Keep keys and PINs in a `.env` file that git ignores, never in a commit or a chat.
* An MCP client stores the `API-Key` and `API-Pin` headers in its own configuration as written. Use a key you can revoke on its own. See [MCP Server](/docs/development/mcp).
* An agent seat’s secret is shown once, at join. See [AI Agents](/docs/development/agents).
* A leaked key is revoked in the account portal; mint a new one in its place.
## Account Tokens
[Section titled “Account Tokens”](#account-tokens)
An `ow_a_` token acts on your account: it lists your worlds, creates worlds, mints and revokes world keys, and manages invites, members and watched worlds. Mint one in the account portal under **Settings → Account tokens**, or with `POST /api/v2/account/tokens`. Like a key, it is shown once.
It is sent as a bearer token, not as `API-Key`:
```bash
curl -s "https://www.onlyworlds.com/api/v2/account/worlds" \
-H "Authorization: Bearer ow_a_…"
```
| Method | Route | Purpose |
| ---------- | ------------------------------------------------- | ---------------------------------------------------------------------- |
| GET, PATCH | `/api/v2/account` | Your profile |
| GET | `/api/v2/account/worlds` | The worlds you own and the worlds shared with you |
| POST | `/api/v2/account/worlds` | Create a world: `201` with the world and a new `ow_w_` key, shown once |
| POST | `/api/v2/account/worlds/{world_id}/keys` | Mint a world key, shown once |
| GET | `/api/v2/account/worlds/{world_id}/keys` | List a world’s keys (last four characters only) |
| DELETE | `/api/v2/account/worlds/{world_id}/keys/{key_id}` | Revoke a world key |
| POST | `/api/v2/account/tokens` | Mint an account token, shown once |
| GET | `/api/v2/account/tokens` | List your account tokens (last four characters only) |
| DELETE | `/api/v2/account/tokens/{token_id}` | Revoke an account token |
Minting a world key takes `scope` (`"read"` or `"write"`, default `"write"`) and an optional `name`, a label such as `"atlas · my laptop"` that is echoed back in the key list. A `name` over 255 characters is a `422`; the same limit applies to account token names.
```bash
curl -s -X POST "https://www.onlyworlds.com/api/v2/account/worlds/{world_id}/keys" \
-H "Authorization: Bearer ow_a_…" -H "Content-Type: application/json" \
-d '{ "scope": "read", "name": "players" }'
```
```json
{ "id": "…", "name": "players", "last4": "…", "scope": "READ",
"last_used_at": null, "revoked": false, "key": "ow_r_…" }
```
`key` appears in this response only. Creating a world answers its summary with a new write key the same way:
```bash
curl -s -X POST "https://www.onlyworlds.com/api/v2/account/worlds" \
-H "Authorization: Bearer ow_a_…" -H "Content-Type: application/json" \
-d '{ "name": "Hyperion" }'
```
```json
{ "id": "…", "name": "Hyperion", "role": "owner", "public_read": false,
"keys": [ { "name": "", "last4": "…", "scope": "WRITE", "revoked": false } ],
"key": "ow_w_…" }
```
The routes for invites, members and watched worlds are on [Members and Sharing](/docs/development/api/members#managing-members). A missing or invalid account credential answers `401`.
# Schema
> The OnlyWorlds standard, its 22 element categories, typed links, and where the schema is defined and governed.
The OnlyWorlds schema is the standard every OnlyWorlds tool and world shares. It defines 22 element **categories**, the fields each one carries, and typed links between them. An **element** is one record in a world: a Character, a Location, a Law.
Use as much of it as you need. Only `name` is required: the key must be present, and an empty string is accepted, on every category, Pins and Markers included. So a world of nothing but Objects is a valid world. Supertype and subtype add a world’s own categories, `x_` fields carry data the schema doesn’t model, and high-volume data can live in your own database beside the world, keyed by element id.
## The 22 Categories
[Section titled “The 22 Categories”](#the-22-categories)
| Category | Definition |
| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Character](/docs/schema/element_categories/character) | A Character represents an individual with agency and the capacity to make choices that affect their world. |
| [Creature](/docs/schema/element_categories/creature) | Creatures are living entities within a world that exhibit behavior and agency but lack the strategic reasoning, narrative focus, or social complexity of Characters. |
| [Species](/docs/schema/element_categories/species) | A Species defines a distinct biological or cultural form of life. |
| [Family](/docs/schema/element_categories/family) | A Family is a group tied together by lineage, heritage or community. |
| [Collective](/docs/schema/element_categories/collective) | A Collective is a group of individuals that acts as a unit but lacks formal governance or hierarchical structure. |
| [Institution](/docs/schema/element_categories/institution) | Institutions are organized bodies with purpose and structure. |
| [Location](/docs/schema/element_categories/location) | A Location represents a distinct place within the world where activities occur and elements converge. |
| [Object](/docs/schema/element_categories/object) | Objects are tangible, non-living things that can be made, owned, traded or destroyed. |
| [Construct](/docs/schema/element_categories/construct) | Constructs are abstract or conceptual structures that exist within a world and can have causal, symbolic, or systemic roles. |
| [Ability](/docs/schema/element_categories/ability) | An Ability represents something an entity in the world can do: a defined action, power, skill, or effect that can change the world or its perception. |
| [Trait](/docs/schema/element_categories/trait) | Traits describe qualities that shape how a character or creature acts, responds, or is perceived. |
| [Title](/docs/schema/element_categories/title) | A Title is a formal designation that confers identity, standing, or power within a world. |
| [Language](/docs/schema/element_categories/language) | Languages are systems of shared meaning, whether natural, constructed, or symbolic. |
| [Law](/docs/schema/element_categories/law) | A Law represents a formalized rule or set of guidelines that governs the actions of individuals or groups within a specific jurisdiction. |
| [Event](/docs/schema/element_categories/event) | An Event represents a time-bound happening within the world. |
| [Narrative](/docs/schema/element_categories/narrative) | Narratives represent stories told in your world, and can involve the organization or reinterpretation of Events. |
| [Phenomenon](/docs/schema/element_categories/phenomenon) | Phenomena are ongoing or emergent conditions that act in or upon the world. |
| [Relation](/docs/schema/element_categories/relation) | Relations are non-material and capture meaningful connections between world elements. |
| [Map](/docs/schema/element_categories/map) | A Map represents a spatial template that defines a coordinate system for placing elements within your world. |
| [Pin](/docs/schema/element_categories/pin) | Pins represent a single element on a single Map, indicating its position in that particular world view. |
| [Marker](/docs/schema/element_categories/marker) | Groups of Markers, each at a specific coordinate, together designate a Zone in the world. |
| [Zone](/docs/schema/element_categories/zone) | Zones represent abstract or meaningful areas within the world that hold significance due to cultural, political, environmental, or narrative reasons. |
Every element also carries the same base fields (name, description, supertype, subtype, image and more). [Fields](/docs/schema/fields) lists them, with the field types and what is required. [Worlds](/docs/schema/worlds) covers the container that holds the elements and its timeline. [A Worked Example](/docs/schema/example) reads one small world through the API, link by link.
## Typed Links
[Section titled “Typed Links”](#typed-links)
Links are typed fields. A Character’s `location` is a single link that holds one Location; its `friends` is a multi link that holds any number of Characters. Each link field names the category it points to, so a tool always knows what kind of element is on the other end. One field, the Pin’s `element`, is a generic link that can point to an element of any category.
Links point one way, from the element that holds the field. For connections that carry their own history, span or intensity, the schema has a category of its own: [Relation](/docs/schema/element_categories/relation). [Conventions](/docs/schema/conventions) covers when to use which.
## Where the Schema Lives
[Section titled “Where the Schema Lives”](#where-the-schema-lives)
| What | Where |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The standard, as YAML | [github.com/OnlyWorlds/OnlyWorlds](https://github.com/OnlyWorlds/OnlyWorlds/tree/main/schema): one file per category, plus `base_properties.yaml` for the fields every element shares and a `VERSION` file |
| Governance | [council.onlyworlds.com](https://council.onlyworlds.com): the schema changes in public, by motion and vote. Take part on the site or through the toolkit. |
| For tool builders | [schema-dist](https://github.com/OnlyWorlds/schema-dist): the generated distribution, its decoder (the walk), and the ruling table. Vendor it rather than writing a parser of your own. |
# Conventions
> How to model a world with the categories and fields the schema already has.
The schema covers most worlds with its 22 categories and their fields. Before reaching for a new field or category, check whether one of these conventions carries the case. When none does, the [Council](https://council.onlyworlds.com) is where the schema changes.
## Use Only What You Need
[Section titled “Use Only What You Need”](#use-only-what-you-need)
A world can use any subset of the categories. Only `name` is required on an element, so a world of nothing but Objects is a valid world, and most tools work with two to four categories.
## Supertype and Subtype Are the World’s Own Categories
[Section titled “Supertype and Subtype Are the World’s Own Categories”](#supertype-and-subtype-are-the-worlds-own-categories)
Every element has two free-text classification fields: `supertype`, the top-level category the element belongs to, and `subtype`, a further classification within it. The schema fixes no values, so each world defines its own: a Location with supertype `City` and subtype `Port`, an Institution with supertype `Guild`.
The API filters on both by exact value (`?supertype=City`), so keep the vocabulary consistent within a world. Some platform features use supertypes too: an agent’s own Character has supertype `Agent`, and in-world messages are Narratives with supertype `Message` (see below).
## Links and Relations
[Section titled “Links and Relations”](#links-and-relations)
A link field states that a connection exists: a Character’s `location`, an Event’s `characters`. Links point one way, from the element that holds the field.
When the connection itself has content, make it a [Relation](/docs/schema/element_categories/relation). A Relation is an element of its own, so it can carry what a link cannot:
| Relation field | Holds |
| -------------------------------------------- | ------------------------------------------------ |
| `actor` | The primary Character defining the relation |
| `background` | History and origin of the relation |
| `start_date`, `end_date` | When it began and ended, in world time units |
| `intensity` | Significance, 0 to 100 |
| `events` | Events where the relation is involved |
| `characters`, `institutions`, `locations`, … | The elements it connects, across most categories |
If the Consul’s ties to the Hegemony have a history and a span worth recording, they are a Relation; the Consul’s current location is a link.
## Choosing Between Neighbouring Categories
[Section titled “Choosing Between Neighbouring Categories”](#choosing-between-neighbouring-categories)
The category definitions draw these lines themselves:
| Pair | The line |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Character / Creature | A Character is “an individual with agency and the capacity to make choices”; Creatures “lack the strategic reasoning, narrative focus, or social complexity of Characters.” |
| Institution / Collective | Institutions are “organized bodies with purpose and structure”; a Collective “acts as a unit but lacks formal governance or hierarchical structure.” |
| Species / Collective | A Species is “a distinct biological or cultural form of life”; a Collective is “unified by shared traits, contexts, or purpose.” |
| Object / Construct | Objects are “tangible, non-living things that can be made, owned, traded or destroyed”; Constructs are “non-physical, non-character entities that help explain or organize how the world works.” |
| Phenomenon / Construct | Phenomena are “ongoing or emergent conditions… not defined by intent or agency”; Constructs are “frameworks that are made, sustained and lost over time.” |
| Trait / Ability | Traits are “not active powers or learned abilities, but underlying aspects of identity”; an Ability is “a defined action, power, skill, or effect.” |
| Event / Narrative | An Event is “a time-bound happening within the world”; Narratives are “stories told in your world, and can involve the organization or reinterpretation of Events.” |
| Location / Zone | A Location is “a distinct place within the world where activities occur and elements converge”; Zones “are not defined by geometry themselves but gain spatial presence through linked map markers.” |
## Zones Take Their Shape From Markers
[Section titled “Zones Take Their Shape From Markers”](#zones-take-their-shape-from-markers)
A Zone has no geometry of its own. Its shape on a [Map](/docs/schema/element_categories/map) is a set of [Markers](/docs/schema/element_categories/marker), each linked to the Map and the Zone, at an `x`/`y` coordinate, with an `order` that sequences them into a polygon or line (`0` is the first point). Markers are boundary points and usually carry an empty name. A [Pin](/docs/schema/element_categories/pin) places one element of any category at one point on one Map.
## Time and Units
[Section titled “Time and Units”](#time-and-units)
Dates are integers in the world’s own time units, set on the [world](/docs/schema/worlds): a Character’s `birth_date`, an Event’s `start_date` and `end_date`, a Relation’s span. Height and weight use the world’s length and mass units. All numbers are whole, and the API truncates decimals, so choose units small enough that values come out whole.
## Story and Description
[Section titled “Story and Description”](#story-and-description)
On a [Narrative](/docs/schema/element_categories/narrative), `story` holds the content of the narrative, as told or remembered. `description`, the base field every element has, holds details about it. Put the text of a tale, a chronicle or a message in `story`, and an account of it in `description`.
## Messages Between Members
[Section titled “Messages Between Members”](#messages-between-members)
In-world messages are [Narratives](/docs/schema/element_categories/narrative) with supertype `Message`. The `narrator` is the Character credited with the message, and `characters` names its recipients, so a member’s inbox is one filtered read:
```http
GET /api/v2/narrative/?supertype=Message&characters=
```
`narrator` is what a message claims; the element’s `created_by` is what the server recorded. [Members](/docs/development/api/members) covers the roster that joins the two.
## Data the Schema Does Not Model
[Section titled “Data the Schema Does Not Model”](#data-the-schema-does-not-model)
Two places hold what the categories have no field for:
* **Extension fields** (`x_*`) on the element itself, for small amounts of tool-specific data. They are stored and returned verbatim, up to 64 KB per element. See [Fields](/docs/schema/fields#extension-fields).
* **Your own database**, beside the world, for high-volume data such as logs, statistics or simulation state. Key each row by the element’s `id`.
Data in either place travels only to the tools that read it. Anything another tool should understand belongs in the schema’s own fields.
# A Worked Example
> One small world, Moppetopia, read through the API to show how the categories, links and timeline fit together.
Moppetopia is a demo world: a civilization of felt creatures called moppets, a navy that fights in puddles and in space, and an old war its people tell in different ways. Anyone can read it with its demo key `0000000001` (read-only, no PIN). Every call on this page runs as written.
```bash
curl -s "https://www.onlyworlds.com/api/v2/world" -H "API-Key: 0000000001"
```
## One Element
[Section titled “One Element”](#one-element)
Admiral Fluffington is a Character: an individual with agency. His text fields (`description`, `physicality`, `mentality`, `background`) hold prose. His link fields hold the ids of other elements.
```bash
curl -s "https://www.onlyworlds.com/api/v2/character?name__icontains=fluffington" -H "API-Key: 0000000001"
```
The answer is a page of results, trimmed here to the link fields:
```json
{
"data": [{
"id": "0695db83-afd7-76ee-8000-00f4295f8866",
"name": "Admiral Fluffington",
"birth_date": 255,
"location": "0695db83-972a-74da-8000-262f1559a7f1",
"species": ["0695db8a-1642-7449-8000-c896529a4537", "0698c9df-422f-7255-8000-624e6f5e0a47"],
"family": ["069945d9-d873-7076-8000-96d538f41a22"],
"rivals": ["0695db83-b69a-772b-8000-557e9ba0dcc3"]
}],
"has_more": false,
"next_cursor": null
}
```
`location` is a single link: one Location. `species`, `family` and `rivals` are multi links: lists. Fetch him by his `id` and add `?expand=` to read the names behind the ids:
```bash
curl -s "https://www.onlyworlds.com/api/v2/character/0695db83-afd7-76ee-8000-00f4295f8866?expand=location,family,rivals&fields=name,location,family,rivals" -H "API-Key: 0000000001"
```
He is in Feltropolis, an orbital station; his family is House Stuffington, a naval dynasty; his rival is Captain Snoot.
## Links Point One Way
[Section titled “Links Point One Way”](#links-point-one-way)
A Character has no field for titles. The link lives on the Title: Admiral is a Title whose `holders` are three Characters and whose `body` is the Puddle Navy, an Institution.
```bash
curl -s "https://www.onlyworlds.com/api/v2/title?name=Admiral&expand=holders,body&fields=name,body,holders" -H "API-Key: 0000000001"
```
So to find what a Character holds, read the Titles and check their `holders`: there is no reverse lookup, and a filter on a link field is a `422` ([Filters](/docs/development/api/reads#filters)). Each link sits on the element that holds the field; the element pages ([Title](/docs/schema/element_categories/title), [Character](/docs/schema/element_categories/character)) show which side that is.
## Places Within Places
[Section titled “Places Within Places”](#places-within-places)
Locations nest through `parent_location`, the wider Location a place is part of. Feltropolis has seven levels, each its own Location whose parent is Feltropolis; the places on a level name that level as theirs, so The Dry Room and Whisper Gallery sit under Level 3, The Squeeze. A tool can draw the station as a tree from that one field.
## Same Name, Two Categories
[Section titled “Same Name, Two Categories”](#same-name-two-categories)
The Treaty of Soft Landings ended the war, and Moppetopia holds it twice. As a **Law** it is the document: its `declaration` holds the wording, its `purpose` why it was made. As an **Event** it is the signing: a `start_date`, the Event that triggered it (The Great Puddle War), and the Locations where it happened. The name is shared; what each element can carry is set by its category.
## One Event, Two Stories
[Section titled “One Event, Two Stories”](#one-event-two-stories)
The Moistened Valley Massacre is an Event: a time-bound happening, with `triggers` (the Events that led to it), `consequences` (a text field) and the Characters involved. Three of the world’s Narratives tell it:
| Narrative | Told by | Events |
| ---------------------------- | --------------------------------- | ------------------------------------------------------------------------ |
| The Moistened Valley Account | `conservator`: the Treaty Council | The Great Puddle War, Treaty of Soft Landings, Moistened Valley Massacre |
| The Ballad of Splashworth | `conservator`: the Puddle Navy | Moistened Valley Massacre |
| Snoot’s Counter-History | `narrator`: Captain Snoot | Moistened Valley Massacre, The Incident |
```bash
curl -s "https://www.onlyworlds.com/api/v2/narrative?expand=narrator,conservator,events&fields=name,narrator,conservator,events" -H "API-Key: 0000000001"
```
The call lists all of the world’s Narratives, these three among them. The Event records what happened; each Narrative is one account of it, with a teller or a keeper and its own selection of Events.
## A Connection With Its Own History
[Section titled “A Connection With Its Own History”](#a-connection-with-its-own-history)
Fluffington’s `rivals` field says he opposes Snoot, and nothing more. The Fluffington-Snoot Rivalry is a Relation: it has an `actor` (Fluffington), both Characters, an `intensity` of 85 on a scale of 0 to 100, a `start_date`, a Location (the Naval Academy), and a `background` explaining why. Use a plain link for the fact of a connection; use a Relation when the connection itself has a story. [Conventions](/docs/schema/conventions) covers the choice.
## Time
[Section titled “Time”](#time)
Dates are integers on the world’s own timeline. Moppetopia counts in years (`time_basic_unit`), from 0 to 500, and its present is 500 (`time_range_current`). The massacre’s `start_date` is 248, the signing’s 250, Fluffington’s `birth_date` 255. [Worlds](/docs/schema/worlds) covers the timeline fields.
## What It Leaves Out
[Section titled “What It Leaves Out”](#what-it-leaves-out)
Moppetopia uses 18 of the 22 categories: it has no Creatures, and its one Map has no Pins, Markers or Zones yet. Most of its elements leave most fields empty, and the world is still valid: [Fields](/docs/schema/fields) lists what is required.
# Fields
> The base fields every element carries, the field types, what is required, and extension fields.
Every element carries the same base fields, then the fields of its own category. Category fields are listed on each category’s page, for example [Character](/docs/schema/element_categories/character).
## Base Fields
[Section titled “Base Fields”](#base-fields)
| Field | Type | Required | Description |
| ------------- | ------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `id` | string (uuid) | No (server-assigned if omitted) | Unique identifier, UUIDv7 |
| `name` | string | Yes: the key must be present; an empty string is accepted | Display name, up to 255 characters |
| `world` | string (uuid) | No (set by the API key; never sent in a body) | The world this element belongs to |
| `description` | string | No | Any kind of details about the element |
| `supertype` | string | No | The top-level category the element belongs to in its world, up to 128 characters |
| `subtype` | string | No | A further classification within the supertype, up to 128 characters |
| `image_url` | string (url) | No | Link to a representative image, up to 1024 characters |
Ids are UUIDv7 when the server mints them, which makes them time-sortable. A client may supply its own `id` on create: any RFC 4122 UUID is accepted.
Supertype and subtype are free text, so each world defines its own. [Conventions](/docs/schema/conventions) covers how to use them.
### Server-Kept Fields
[Section titled “Server-Kept Fields”](#server-kept-fields)
The API adds these to every element it returns. They are platform bookkeeping, not part of the standard.
| Field | Description |
| -------------------------- | ---------------------------------------------------------------------------------- |
| `type` | The element’s category, as a lowercase slug (`character`), first key in every body |
| `created_at`, `updated_at` | Timestamps |
| `change_seq` | The world’s change sequence at the element’s last write |
| `created_by` | The world membership that created the element, or null when the world’s owner did |
A write body that carries `type`, `created_at`, `updated_at` or `change_seq` is rejected with `422`. `world` and `created_by` are tolerated and ignored.
## Field Types
[Section titled “Field Types”](#field-types)
| Type | What it holds | On the API | Example |
| ------------ | ------------------------------------------ | ------------------------------------------- | ------------------------------------- |
| text | A string | `"…"`; empty is `""` | A Character’s `background` |
| number | A whole number (integer); some are bounded | an integer or `null` | A Character’s `height` |
| single link | One element of a named category | a uuid or `null` | A Character’s `location` (a Location) |
| multi link | Any number of elements of a named category | an array of uuids; empty is `[]` | A Character’s `friends` (Characters) |
| generic link | One element of any category | two fields, `element_type` and `element_id` | A Pin’s `element` |
| array | A list of plain values | a JSON array | A world’s `time_format_names` |
Link fields use their bare names in both directions: `location`, `friends`. There is no `_id` or `_ids` suffix on the v2 API.
**Bounded numbers.** Most numbers are open. These are bounded:
| Range | Fields |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0 to 100 | Character `charisma`, `coercion`, `competence`, `compassion`, `creativity`, `courage` · Ability `potency` · Relation `intensity` · Species `aggression` |
| -100 to 100 | Trait `charisma`, `coercion`, `competence`, `compassion`, `creativity`, `courage` |
Numbers are integers. The API truncates a decimal instead of rejecting it (`7.9` is stored as `7`), so scale values to whole units before writing.
**Units.** Dates are integers in the world’s time units (see [Worlds](/docs/schema/worlds)). Some measurements name their unit kind in the schema: a Character’s `height` uses the world’s length units, `weight` its mass units.
## What Is Required
[Section titled “What Is Required”](#what-is-required)
On every element, only `name` is required: the key must be present on create and full replace, and an empty string is accepted. Markers, which mark the boundary points of a Zone, are often unnamed.
That includes the map categories. A [Pin](/docs/schema/element_categories/pin) or [Marker](/docs/schema/element_categories/marker) without a map or a position is valid, so an app can keep pins that are not on any map yet. Every other field accepts `null`.
Coordinates `x` and `y` are integers measured from the bottom left of the map; `z` is optional, for depth. A Marker’s `order` is its position in the sequence when Markers define a polygon or line (`0` is the first point); without it, Markers keep the order they were made in.
## Extension Fields
[Section titled “Extension Fields”](#extension-fields)
Fields whose names start with a reserved prefix are accepted on write, stored as they are, and returned verbatim:
| Prefix | For |
| ---------- | ------------------------------------------ |
| `x_*` | The open namespace, for any tool or person |
| `atlas_*` | Atlas |
| `shadow_*` | The world folder format |
An extension field can hold any JSON value. The server does not validate, index or filter extensions. Each element can carry at most 64 KB of them, counted as compact UTF-8 JSON. A `PATCH` merges extension fields by key.
Any other unknown field is rejected with `422` naming the field, so a typo such as `freinds` fails loudly instead of being stored.
The base schema is what makes a world portable between tools. Data held in extension fields is read only by the tools that know those fields.
# World Folders
> The folder format a world can live in on disk, and the rules a tool follows to read and write one.
A world folder is one OnlyWorlds world stored as plain JSON files: one file for the world, one per element. A text editor opens it, a copy backs it up, and any tool reads it with no account, key or network. Under git, each commit is a point in the world’s history and a branch is a what-if; element ids stay the same on every branch.
## Layout
[Section titled “Layout”](#layout)
```text
moppetopia/
world.json the world
elements/
character/
admiral-fluffington--295f8866.json
location/
... one folder per category present, lowercase singular
media/ optional; its format is not yet specified
.atlas/ a tool's private state; never needed to read the world
```
A folder holds one world. All 22 categories, `map`, `pin`, `zone` and `marker` included, live under `elements//`. A missing category folder means no elements of that category.
The `id` inside a file is the element’s identity. The filename is for people: rename a file and nothing breaks. The recommended name is `--.json`. The slug is the name in lowercase ASCII, each run of other characters as one hyphen, cut to 40 characters, any trailing hyphen removed. The tail is the id’s last 8 characters. An element with no usable name gets `.json`.
## world.json
[Section titled “world.json”](#worldjson)
Two keys are required: `id` (a non-empty string) and `name` (a string, which may be empty). The rest are optional.
| Key | Holds |
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `description`, `image_url`, timeline fields | As on [Worlds](/docs/schema/worlds). The current time is `time_current` on disk, the standard’s name; the API calls it `time_range_current`. |
| `format_version` | The version of this format the folder was written against |
| `api` | The link to a world on onlyworlds.com (`world_id`, `api_key`). Present: the folder syncs with it. Absent: a local world. |
| `snapshot_*`, `writable` | Only on a [snapshot](#snapshots) |
```json
{
"id": "0695db52-5433-7332-8000-db7fe10da375",
"name": "Moppetopia",
"time_basic_unit": "Year",
"time_range_min": 0,
"time_range_max": 500,
"time_current": 500
}
```
## Element Files
[Section titled “Element Files”](#element-files)
An element file must hold a non-empty string `id`. The rest are the category’s [fields](/docs/schema/fields), link fields under bare names holding an id or a list of ids, as in the API. Trimmed:
```json
{
"id": "0695db83-afd7-76ee-8000-00f4295f8866",
"name": "Admiral Fluffington",
"location": "0695db83-972a-74da-8000-262f1559a7f1",
"rivals": ["0695db83-b69a-772b-8000-557e9ba0dcc3"]
}
```
A reader fills in `type` from the folder name unless the file declares one. A file without an `id` is skipped and left as it is. Tools may add their own fields under a prefix (`atlas_*`, `x_*`). An element named in prose is `[Label](ow:///)`. Map coordinates run from the bottom left, y upward.
## Folders and onlyworlds.com
[Section titled “Folders and onlyworlds.com”](#folders-and-onlyworldscom)
* **Linked**: a folder with an `api` block is that account world on disk. To find the server’s world, read `api.world_id` first and fall back to `id` (older folders can differ).
* **Account to folder**: let [Atlas](/docs/tools/atlas) download the world (a linked folder), or by script read [changes](/docs/development/api/changes) from the start until `has_more` is false and write `world.json` from `GET /api/v2/world`, renaming `time_range_current` to `time_current`.
* **Folder to account**: Atlas can take a local folder online. Or send the files to an empty world through [`/bulk`](/docs/development/api/writes#bulk), stripping `local_updated_at`, `server_updated_at`, `image_media_id`, `created_at`, `updated_at`, `created_by`, `type` and `change_seq`, and renaming `map_id` to `map`, `zone_id` to `zone`.
* **A copy to share** may drop the `api` block, which holds a key, and nothing else. It keeps its `id`. If a key was ever pushed somewhere public, revoke it in your account.
* **Archives**: one world per zip, as one top-level folder; readers also accept `world.json` at the root.
## Snapshots
[Section titled “Snapshots”](#snapshots)
A snapshot is a frozen copy of a world at one moment, such as a chapter’s end.
| Key | Holds |
| --------------------- | ------------------------------------------------------------------------- |
| `snapshot_of` | The source world’s id |
| `snapshot_label` | A label, such as `"ch05"` |
| `snapshot_at` | Capture time (ISO 8601) |
| `snapshot_change_seq` | The source’s change cursor at capture |
| `snapshot_counts` | Optional: files written per category. A receipt; the files are the truth. |
| `snapshot_torn` | `true` only on a capture known to be inconsistent |
| `writable` | `false`. Advisory; no tool is known to honor it. |
The writer mints a fresh world id and keeps every element id; a re-capture under the same label keeps the snapshot’s id. It copies `name` exactly and every `world.json` key the source had, keeps `change_seq`, `created_at`, `updated_at` and `type` as received, and writes no `./` folder. If the change cursor moved during the walk it fails, or on an explicit override sets `snapshot_torn: true`.
## Who Reads and Writes Folders
[Section titled “Who Reads and Writes Folders”](#who-reads-and-writes-folders)
| Tool | With folders |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [Atlas](/docs/tools/atlas) | Reference implementation; stores every world as a folder. Folder mode needs a Chromium browser. Worlds sit inside `onlyworlds-atlas/-/`: point other tools at the world’s own folder. |
| [Obsidian plugin](/docs/tools/obsidian-plugin) | Exports a world as a folder; imports a folder into notes |
| [Toolkit](/docs/development/toolkit) | Reads a folder in place of the API; parsing writes new ones |
| [Azgaar converter](https://github.com/OnlyWorlds/azgaar-converter) | Azgaar map in, as a folder |
| [Foundry converter](https://github.com/OnlyWorlds/foundry-converter) | Folder out, to Foundry VTT |
## Rules for Tools
[Section titled “Rules for Tools”](#rules-for-tools)
**Reading**
* MUST read every `*.json` in a category folder and key on the inner `id`; MUST NOT parse filenames.
* MUST treat a missing category folder as empty, and MUST NOT require any `./` folder.
* SHOULD also read `spatial/{map,pin,zone,marker}/` (older Atlas).
* Accept both spellings: `map`/`map_id`, `zone`/`zone_id`, `time_current`/`time_range_current`.
* MUST NOT rewrite a file’s key spellings as a side effect of opening it.
* A tool holding several worlds MUST key storage by source and world id, never world id alone.
**Writing**
* UTF-8 without BOM, LF endings, 2-space indent, one trailing newline, keys in received order (never sorted), `1` not `1.0`.
* Write under `elements//`, never `spatial/`; SHOULD omit empty category folders.
* Two elements with the same filename: MUST use `.json`, ties broken by ascending id.
* An id, once minted, MUST be kept and MUST NOT be re-derived (from a new name, say).
* MUST NOT invent data it lacks, such as timestamps, nor drop data it has.
* MUST preserve unknown fields and MUST NOT change another tool’s prefixed field at any depth: copy it through byte for byte.
* `format_version` only when creating a folder. MUST NOT create or edit a `.gitignore` in a folder it did not create (in your own repo, `.*/` keeps every tool folder out).
* One writer per folder at a time; never write into another tool’s `./`.
* In a linked folder, every changed element file MUST get `local_updated_at` set to now (UTC, ISO 8601), `server_updated_at` left alone; otherwise Atlas never sends the edit. New files carry neither.
# Worlds
> The world container, its fields, and how it configures a world's timeline.
A world is the top-level container for elements, representing a complete setting at any scope you define. Every element belongs to exactly one world, and a world key reads or writes exactly one world.
## Core
[Section titled “Core”](#core)
| Field | Type | Required | Description |
| ------------- | ------------- | -------------------- | ------------------------------------ |
| `id` | string (uuid) | No (server-assigned) | Unique world identifier (UUIDv7) |
| `name` | string | Yes | Display name; it cannot be empty |
| `description` | string | No | Text description |
| `image_url` | string (url) | No | Cover image or representative visual |
## Timeline
[Section titled “Timeline”](#timeline)
| Field | Type | Required | Description |
| ------------------------- | ---------------- | -------- | --------------------------------------------------------------------------------------------------------------------- |
| `time_format_names` | array of strings | No | Names of each time step: `["Day", "Week", "Month", "Year"]`, or custom names like `["Sol", "Cycle", "Season", "Era"]` |
| `time_format_equivalents` | array of strings | No | Basic units per step, e.g. `["1", "7", "30", "365"]` |
| `time_basic_unit` | string | No | Smallest time unit (e.g. `"Day"` or `"Hour"`) |
| `time_range_min` | integer | No | Earliest tracked time point |
| `time_range_max` | integer | No | Latest tracked time point |
| `time_range_current` | integer | No | Current time in your world. The standard’s YAML names this field `time_current`. |
Element fields that hold a moment, such as a Character’s `birth_date` or an Event’s `start_date`, are integers in the world’s time units.
### Timeline Examples
[Section titled “Timeline Examples”](#timeline-examples)
**Fantasy world with a custom calendar:**
* `time_format_names`: `["Sun", "Tenday", "Moon", "Turning"]`
* `time_basic_unit`: `"Sun"`
* `time_range_current`: `1247` (year 1247 of the Third Age)
**Science fiction setting with stardates:**
* `time_format_names`: `["Cycle", "Rotation", "Orbit", "Epoch"]`
* `time_basic_unit`: `"Cycle"`
* `time_range_min`: `0`
* `time_range_max`: `128256`
Timeline fields work with [Events](/docs/schema/element_categories/event) and [Narratives](/docs/schema/element_categories/narrative).
## Platform Fields
[Section titled “Platform Fields”](#platform-fields)
onlyworlds.com adds fields of its own to the world it serves. They are part of the platform, not the standard.
| Field | Type | Description |
| ----------------- | --------------------- | ------------------------------------------------------------------------- |
| `public_read` | boolean | Whether the world is open to read |
| `owner_character` | string (uuid) or null | The owner’s own Character in this world (“this Character is me”), or null |
| `created_at` | string (date-time) | When the world was created |
| `updated_at` | string (date-time) | When the world last changed |
The world named by a key is `GET /api/v2/world/`. Only the world’s owner can change it, with `PATCH`; the writable fields are `name`, `description`, `image_url`, the timeline fields and `owner_character`.
## Legacy and Account Fields
[Section titled “Legacy and Account Fields”](#legacy-and-account-fields)
The standard also defines these. The v2 API does not return them.
| Field | Type | Description |
| --------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `api_key` | string | Legacy 10-character key, not issued for new worlds. Use world keys (`ow_w_` / `ow_r_`) minted in the [account portal](https://www.onlyworlds.com/account/). |
| `version` | string | The OnlyWorlds format version the world conforms to |
| `user` | string (uuid) | Owner’s account identifier (not exposed on the API) |