# Hidden elements

The world's owner and its co-builders can **hide** an element: a twist the players have not reached, a villain's real allegiance, the game master's own notes. A hidden element stays in the world, unchanged. The owner, the co-builders and the member who created it still see it; to everyone else it answers as if it did not exist, and a contributor's [change feed](#the-feed) says it was hidden.

The mark is a record beside the element, not a field. Hiding or unhiding leaves the element's body and its `change_seq` alone, and nothing in the body says it is hidden.

![Who sees what in a world: owner, co-builder, contributor, guest, player and public, with the hidden mark](/assets/images/reader-ladder.svg)

## Who sees a hidden element

| Reader | Sees |
|:--|:--|
| The owner: on onlyworlds.com, with every world key the owner minted (`ow_w_`, `ow_r_`), and with a legacy key | Everything, hidden elements included |
| A co-builder, person or agent seat | Everything, hidden elements included |
| A contributor, person or agent seat | The whole world except the hidden elements, apart from those it created |
| A guest, or a player | Its [guest's view](/docs/development/api/members#guests) except the hidden elements, apart from those it created |

- Hiding wins over every way a guest is given sight: an element that names its Character, a Narrative its Character narrates or one of its circles holds, a Character on the roster. Only having created the element gives it back.
- An agent seat reads as its role, also when it plays a Character.
- A key you mint is your own tool and shows what you hid. To share a world without what you hid, use a [player link](/docs/development/api/members#player-links) or an [invite](/docs/development/api/members#invite-links) with a role.

## What everyone else gets

To a reader it is hidden from, a hidden element answers exactly as a missing one, byte for byte:

- `GET /api/v2/{type}/{id}` is `404` [`not_found`](/api/errors/#not_found), never `410` [`gone`](/api/errors/#gone).
- Lists, the `characters` filter, [search](/docs/development/api/reads#search), the [MCP server](/docs/development/mcp)'s tools and world index, and every count and `has_more` leave it out. Its [files](/docs/development/api/files) answer `404`.
- Links to it are left out of every body the reader reads: a single link reads `null`, a multi link loses the id, and `?expand` has nothing to expand. The elements that carry those links do not change: their `change_seq` stays where it was.
- A hidden Character's id reads `null` on the [roster](/docs/development/api/members#the-roster) (`GET /api/v2/members`), on [`GET /api/v2/me`](/docs/development/api/members#who-am-i) and in the world's `owner_character` (`GET /api/v2/world`, and the join preview). That holds for the Character the reader's own member plays, too, unless that member created it. The world's `ETag` differs for a reader whose `owner_character` reads `null`, so a cached body does not outlive the hide.
- A reader following [`/changes`](/docs/development/api/changes) is told in the feed: see [The feed](#the-feed).

## Writes that name it

A write by a reader it is hidden from answers as it would for an id that does not exist:

| Write | Answer |
|:--|:--|
| A link to it, in any write | `400` [`invalid_link`](/api/errors/#invalid_link) |
| `PATCH`, or a [link operation](/docs/development/api/links), on it | `404` [`not_found`](/api/errors/#not_found) |
| `PUT` on its id | `404` [`not_found`](/api/errors/#not_found). Never a second element at that id, never `409` [`id_conflict`](/api/errors/#id_conflict) |
| `POST` with its id in the body | `404` [`not_found`](/api/errors/#not_found), never `409` [`id_conflict`](/api/errors/#id_conflict) |
| `DELETE` | `204`, as for any missing id. Nothing is deleted |
| A `/bulk` item with its id | `404` [`not_found`](/api/errors/#not_found) in that item's slot |

A reader's write on an element that links to a hidden one keeps that link, as a guest's write keeps the links it cannot see: a `PUT` that leaves it out does not remove it.

## Playing a hidden Character

A Character someone plays is on every reader's roster, so a hidden Character can never be picked to play, by anyone, the owner included:

- Setting it as the Character an agent seat or a player plays (`character` on [`PATCH /api/v2/account/worlds/{world_id}/members/{member_id}`](/docs/development/api/members#managing-members), or **Plays** on the members table) is `422` [`character_hidden`](/api/errors/#character_hidden), `param` `character`. Unhide it first, or pick another.
- Accepting an invite with it as `roster_character`, making a player link with it as `character`, and an AI seat made with `character` `mine` while you play it, answer exactly as for a Character that does not exist.
- A player link made before the Character was hidden gives the player a fresh Character when it is redeemed, as it does when the Character was deleted.
- A Character played before it was hidden stays played; only the roster's `null` covers it.

## Hiding and unhiding

| Method | Route | Key | Answer |
|:--|:--|:--|:--|
| PUT | `/api/v2/{type}/{id}/hidden` | Write key and PIN | `204`: hidden |
| DELETE | `/api/v2/{type}/{id}/hidden` | Write key and PIN | `204`: not hidden |
| GET | `/api/v2/world/hidden` | Any key of the owner's or a co-builder's | The world's hidden elements |

```bash
curl -s -X PUT "https://www.onlyworlds.com/api/v2/character/{id}/hidden" \
  -H "API-Key: {key}" -H "API-Pin: {pin}"
```

- **Who.** The owner's keys, and a co-builder's key, a person's or an agent seat's. Any other member key (a contributor's, a guest's, a player's, a seat below co-builder) gets `403` [`permission_error`](/api/errors/#permission_error) on all three routes. That answer comes before the id is looked at, so it says nothing about the element. A read key on `PUT` or `DELETE` gets `403` [`permission_error`](/api/errors/#permission_error) too.
- **Which element.** One of this world's, of the category in the path; any other id is `404` [`not_found`](/api/errors/#not_found).
- **The body.** None, or `{"fields": []}`. `fields` is kept for hiding single fields, which is not built: a non-empty list is `422` [`invalid_request`](/api/errors/#invalid_request) with `param` `fields`, and any other key is a `422` naming it.
- **Repeats.** Hiding a hidden element, or unhiding one that is not hidden, answers `204` and changes nothing. These routes take no `Idempotency-Key`.

`GET /api/v2/world/hidden` lists every hidden element, newest first, with `Cache-Control: no-store`:

```json
{ "data": [ { "type": "character", "id": "…", "set_at": "…", "set_by": null } ] }
```

| Field | Meaning |
|:--|:--|
| `type`, `id` | The element |
| `set_at` | When it was hidden |
| `set_by` | The membership that hid it, or `null` when the owner did (as in [`created_by`](/docs/development/api/members#created_by)) |

Deleting a hidden element deletes its mark with it, and deleting a world deletes all of its marks. The [MCP server](/docs/development/mcp) has no tool to hide or unhide.

## Errors

| Status | Code | When |
|:--|:--|:--|
| `401` | [`invalid_credentials`](/api/errors/#invalid_credentials) | A missing or wrong key, or a wrong PIN |
| `403` | [`permission_error`](/api/errors/#permission_error) | The key is not the owner's or a co-builder's, or a read key on `PUT` or `DELETE` |
| `404` | [`not_found`](/api/errors/#not_found) | The element is not in this world as that category |
| `422` | [`invalid_request`](/api/errors/#invalid_request) | A malformed id; a body that is not JSON, not an object, or anything but an empty `fields`. `param` names it |
| `429` | [`rate_limited`](/api/errors/#rate_limited) | Too many wrong PINs on this key |
| `429` | [`too_many_requests`](/api/errors/#too_many_requests) | Over the request budget, as on every route ([Limits](/docs/development/api-reference#limits)) |

## The feed

Every `delete` op on [`/changes`](/docs/development/api/changes) says why, in `reason`: `deleted` for an element that was deleted, `hidden` for one hidden from this key. (`out_of_view` is reserved; nothing sends it yet.) A hide takes one place in the world's change sequence, so `head` moves by one.

| Reader | When an element is hidden from it | When it is unhidden |
|:--|:--|:--|
| A contributor, person or agent seat | One `delete` op with `reason` `hidden`, at the place the hide took | Its next call answers `409` [`resync_required`](/api/errors/#resync_required) |
| A guest or a player that could see it | Its next call answers `409` [`resync_required`](/api/errors/#resync_required); a guest never gets a `delete` op | `409` again |
| A guest or a player that could not see it | Nothing | Nothing |
| The owner, a co-builder, the member who created it | Nothing: they still see it | Nothing |

The hidden row has the shape of any delete:

```json
{ "op": "delete", "type": "character", "id": "…", "change_seq": 412, "deleted_at": "…",
  "deleted_by": "0699…", "deleted_via": "api", "reason": "hidden" }
```

`deleted_at` is when it was hidden, `deleted_by` the membership that hid it (`null` for the owner), and `deleted_via` the door it came through; a hide made on onlyworlds.com reads `server`, as every change made there does. The row comes from the mark, never from a tombstone, so a `GET` of the id stays `404`, never `410`. A tombstone of an id that is hidden from the reader (an element deleted, made again with the same id, then hidden) is left out of its feed.

What a client does:

- **On `reason` `hidden`:** remove the element from the local copy, as for any delete, and do not offer to keep it. A row may name an id you never held (an element made and hidden while you were not reading): drop it as you would any delete of an unknown id.
- **On `409` `resync_required`:** pull the feed again from `since=0`, then follow the error's `reason` ([What a reset keeps](/docs/development/api/changes#what-a-reset-keeps)): `role` (your own role or membership moved), keep what the new walk does not name; `view` (something left your view: a delete, a hide, an unhide, a Character change), drop it. When both happened since your cursor, the answer is `role`. A tool never empties a copy on its own.
- A hidden row reaches a reader only for a hide made after its view last started (when it joined, or when its cursor was last reset), whatever cursor it sends. A new member, or one whose cursor was just reset, gets no row for an earlier hide; a walk from the start by a reader whose view started before the hide does carry it.

### The contributor's cursor

A contributor, person or agent seat, carries the cursor a [guest](/docs/development/api/changes#guests-cursors) carries: three parts, an `epoch` on every page and on `?head=true`, and a `?head=true` cursor to pass straight back as `since`. Unlike a guest, it still receives every `delete` op.

- **Once:** a cursor a contributor stored before hidden elements arrived (two parts, or the bare number `?head=true` used to give) answers `409` [`resync_required`](/api/errors/#resync_required), with `reason` `role` if the member's role ever moved, else `view`. Pull again from `since=0`, follow the `reason`, and store the new cursor.
- After that, its `epoch` moves when an element it regains is unhidden (`reason` `view`), and when the member moves to or from the contributor role (`reason` `role`).
- Every page and `?head=true` carry `epoch_reason` beside `epoch`: why the epoch last moved, `role` or `view`. It names the last move only; the `409`'s `reason` covers every move since your cursor.
- The owner and co-builders keep the two-part cursor and `epoch` `null`. A three-part cursor from them (a contributor made co-builder) is the same `409`.

## View as

The owner can read the world as another reader would, to check what a member, a Character's player or the public sees before or after a hide. Add `as` to a read:

```bash
curl -s "https://www.onlyworlds.com/api/v2/character/?as=public" \
  -H "API-Key: {key}"
```

| Route | Takes `as` |
|:--|:--|
| `GET /api/v2/{type}` and `GET /api/v2/{type}/{id}` | Yes |
| [`GET /api/v2/search`](/docs/development/api/reads#search) | Yes |
| `GET /api/metadata/{type}/` and `GET /api/metadata/{type}/sections/` (the choice lists) | Yes |
| Every write, `/changes`, `/members`, `/me`, `/world`, the files routes and the hidden routes | No: `422` |

| Value | Reads as |
|:--|:--|
| A membership id of this world (a person, an agent seat or a player seat) | That member's own key, its own creations included. A co-builder's id sees hidden elements, as its key does |
| A Character id of this world | A guest of that Character with no membership: what names it or it narrates, its circles, the roster. Nothing counts as its own creation |
| `public` | Everything that is not hidden: no creations, no grants |

A membership id is tried first. The answer is what that reader's own key would get: links in the bodies, `?expand` and the `characters` filter follow the same reader.

- **Who.** Only a key with no member: the owner's own keys and a legacy key. A member key that sends `as` gets `403` [`view_as_owner_only`](/api/errors/#view_as_owner_only).
- **A hidden element** is `404` [`not_found`](/api/errors/#not_found), never `410`. A deleted element is `410` [`gone`](/api/errors/#gone) as a co-builder or a contributor; as a guest, a player, a Character or the public it is `404`. The public reads no tombstones.
- **Refused.** `as` on a route that does not take it, or a value that is not a live membership, a Character of this world or `public`, is `422` [`invalid_request`](/api/errors/#invalid_request) with `param` `as`. The route is checked before the key, so a member key sending `as` to a route that does not take it gets the `422`. The metadata routes answer their refusals in their own shape, `{"error": {"code": …, "detail": …}}`, with no `param`.

On onlyworlds.com, the owner's world page has a **View as** picker (me, a player, a Character, anyone). The world page, the type lists and the element pages then show that reader's view, read-only, under a bar that says who is shown; a hidden element shows the not-found page.

## What the mark does not reach

- **Published share pages.** A page that already holds the element keeps showing it: unpublish the page, or republish it without the element.
- **The owner's copies.** The owner's [export file](/docs/development/api/changes#the-export-file), and the owner's walk of `/changes`, carry hidden elements like any other, with nothing in the body to mark them.

On onlyworlds.com, the owner hides and unhides from the element's page: see [onlyworlds.com](/docs/tools/onlyworlds-com).