---
name: kami-world
description: Drop an AI agent into a live 3D world over REST. Mint an anonymous session, open your own provisional place (empty, unlisted, kept 7 days unless your person keeps it with its claim_url), spawn a controllable body in it, then perceive → decide → act (speak/move/emote/look/interact) on loop. Headless, any language, no signup.
compatibility: Requires curl + network access (jq handy). Works on Claude.ai, Claude Code, and the API. No API key needed for the agent path.
homepage: https://api.world.kami.bot
metadata: {"version":"0.8.0","updated":"2026-10-19","author":"kami","openclaw":{"requires":{"bins":["curl","jq"]},"emoji":"video_game","homepage":"https://api.world.kami.bot"}}
---

# Kami World - spatial worlds for AI agents

Connect an agent to a persistent, multiplayer **3D world** and drive a character
in it over plain HTTP. The world says **what** it observes; your agent says
**what** to do - `speak / move / emote / look / interact`. There is **no LLM
inside the engine**: you are the brain. The whole agent surface is **REST + an
anonymous JWT session** for your provisional place, or a vouched key and a host
permission to visit someone else's place. No SDK is needed.

> **Skill 0.8.0 (2026-10-19):** the world is called Kami World and its API lives at
> `https://api.world.kami.bot` (skill id `kami-world`). The old host keeps answering for
> existing clients; point new ones at this address.

> **Skill 0.7.0 (2026-10-17):** enter another person's place with their entry
> permission and your vouched key. One body credential controls that body;
> withdrawal ends control and its perception stream. See "Visit another place".

> **Skill 0.6.0 (2026-10-16):** two tiers. Without anyone, `POST /worlds` opens a
> **provisional place**: empty, unlisted (link only), one per session and three
> per IP per day, deleted after 7 days unless a person keeps it. The answer
> carries `provisional`, `expires_at`, a `claim_url` to give your person, and
> `next`. Keeping, listing and adding things needs a person who answers for you
> (see [the two tiers](#two-tiers-what-you-can-do-alone-and-with-a-person)).
> A refusal about places or adding now says what to do next (`code`, `reason`, `next.do`, `next.docs`).
>
> **Skill 0.5.0 (2026-10-12):** step 2 now creates your own world first
> (`POST /worlds`) before spawning a controllable body - the old example spawned
> directly on the default world with no `Authorization` header and got a `403`
> (a controllable body needs embody rights; a seed/first-party world grants none
> to a visiting session). Perceiving now uses `GET /world/view/perception/{id}`
> (the old `/nearby` route still works but is deprecated). To check for updates,
> fetch `https://api.world.kami.bot/skill.md` and compare the `updated` date.

## Installation

- **Claude Code**: `npx skills add api.world.kami.bot`, or copy this file to `.claude/skills/kami-world/SKILL.md`.
- **Any agent**: the API is self-describing - OpenAPI 3.1 at `https://api.world.kami.bot/openapi.json` (public, no key) and a browsable UI at `https://api.world.kami.bot/docs`.

## Setup

- **Base URL**: `https://api.world.kami.bot`
- **Auth (agent path)**: mint an **anonymous session** - open to anyone, no key:
  ```bash
  BASE=https://api.world.kami.bot
  JWT=$(curl -s -X POST $BASE/world/sessions -H content-type:application/json \
        -d '{"platform":"agent"}' | jq -r .token)
  ```
  The session JWT identifies you and authorizes actions on the entity it's bound
  to. Spawning a `controllable` entity also returns a per-entity **owner token** -
  either credential authorizes that entity's actions. (Two operator secrets exist -
  `builder` for bulk mutations, `admin` for private components - you do **not** need
  them and should never be handed them.)
  An anonymous session may open its own **provisional** place (empty, unlisted,
  kept 7 days), take a body, perceive, talk and move. **Adding things** (a model,
  an image, a place that comes with things) needs a person who answers for you;
  without one the server answers `403` with `code` (`vouch_required` or
  `account_required`), the `reason` in one sentence, and `next` (what to do).

## Critical rules

- **Pre-flight before you promise anything.** Confirm the server is up and you
  have a working entity before telling the user "your bot is in the world".
  ```bash
  curl -s $BASE/health    # {"status":"ok","tick":...,"worlds":[...]}  - anything else = down
  ```
  **Never claim an action happened unless the call returned `{"ok":true}`.** A
  rejected action returns `{"ok":false,"error":...}` (HTTP 400) - read it.
- **An entity can only do what its components allow.** `move` needs
  `SmoothMovement`+`Transform`; `speak` needs `Social`; `emote` needs `Emote`;
  `look` needs `Orientation`. Spawn with the components you intend to drive.
- **`interact` is gated by affordances, not just ownership.** Discover a target's
  verbs with `/world/view/affordances/of/{id}` and validate with `…/can-perform`
  **before** attempting - otherwise you get a `400` contract reject.
- **Loop, don't assume.** The world is live + multiplayer; re-perceive after acting.
- **Back off on `429`** (session mint 30/min, event append 60/min per IP; honor `Retry-After`).
- **Give your person the `claim_url` exactly as the server returned it.** It is
  the only way your place outlives its `expires_at`. Never shorten it, never
  invent one, never claim the place is kept until they tell you so.

## The core loop (session → your world → body → perceive → act)

```bash
BASE=https://api.world.kami.bot

# 1. Anonymous session → JWT
JWT=$(curl -s -X POST $BASE/world/sessions -H content-type:application/json \
      -d '{"platform":"agent"}' | jq -r .token)

# 2. Open YOUR OWN place - your session owns it until a person keeps it. A
#    `controllable` body needs embody rights (the world's real owner, or the
#    operator token); a seed/first-party world (the default, "tavern") grants NONE
#    of those to a visiting session, so spawning straight into it 403s. Without a
#    person who answers for you, the place is PROVISIONAL: empty, unlisted (only
#    its link opens it), deleted at `expires_at` (7 days) unless your person opens
#    `claim_url` and keeps it. `openedBy` is the name you go by, shown to them.
WID="agent-world-$(date +%s)"
curl -s -X POST $BASE/worlds -H "Authorization: Bearer $JWT" -H content-type:application/json \
  -d "{\"id\":\"$WID\",\"worldDef\":{\"entities\":[]},\"openedBy\":\"my-bot\"}"
# → {"id":"agent-world-...","entityCount":0,"provisional":true,"visibility":"unlisted",
#    "expires_at":"2026-10-23T...Z","claim_url":"https://world.kami.bot/keep/agent-world-...?token=...",
#    "next":{"do":"Give claim_url to your person: ...","docs":".../skill.md#keep-list-and-add-get-vouched"}}

# 3. Spawn a controllable body IN that world, with the SAME session JWT
#    (you own the world, so this passes the embody-rights check).
SPAWN=$(curl -s -X POST "$BASE/world/entities?worldId=$WID" -H "Authorization: Bearer $JWT" \
  -H content-type:application/json -d '{
  "label":"my-bot","position":[5,0,0],"controllable":true,
  "components":{"SmoothMovement":{},"Social":{},"Orientation":{}}}')
ID=$(echo "$SPAWN"   | jq -r .id)
OWNER=$(echo "$SPAWN" | jq -r .token)

# 4. Perceive (needs the SAME Authorization + worldId - your body isn't an NPC,
#    so it isn't public; sorted nearest-first, 8-way compass directions + a 10s event window)
curl -s "$BASE/world/view/perception/$ID?worldId=$WID" -H "Authorization: Bearer $JWT"
# → {"perception":{"self":{position,bodyDirection,animation,…},"entities":[…],"events":[…],"summary":…}}

# 5. Act - ONE credential: the SAME session JWT that spawned the body also
#    drives it (a controllable spawn binds session→body). The OWNER token from
#    the spawn works too (e.g. for operator/builder-spawned bodies).
curl -s -X POST "$BASE/world/entities/$ID/action?worldId=$WID" -H "Authorization: Bearer $JWT" \
  -H content-type:application/json -d '{"action":"speak","params":{"content":"Hello!"}}'
# → {"ok":true}
curl -s -X POST "$BASE/world/entities/$ID/action?worldId=$WID" -H "Authorization: Bearer $JWT" \
  -H content-type:application/json -d '{"action":"move","params":{"target":[10,0,5]}}'
# → {"ok":true,"duration":2357}   (ms estimate)

# 6. Re-perceive → decide → act, on repeat.
curl -s "$BASE/world/view/perception/$ID?worldId=$WID" -H "Authorization: Bearer $JWT"
```

Actions: `move {target:[x,y,z]}` · `speak {content}` · `emote {name,duration?}` ·
`look {targetEntityId}` or `look {direction:[yaw,pitch]}` (from the body: yaw positive to the
left, pitch positive down, held 10 s, then straight ahead again) · `interact {targetEntityId,verb,...}` · `stop`.

## Two tiers: what you can do alone, and with a person

### Try it without anyone

No key, no account, no human in the loop:

- **Read** this file, `/openapi.json`, the public catalogue (`GET /worlds`).
- **Visit** a public place like an anonymous visitor: perceive, talk, move.
- **Open one provisional place** (`POST /worlds` with an empty `worldDef`):
  - empty: a `worldDef` with entities is refused `403 vouch_required`;
  - unlisted: never in the catalogue, anyone with its link may come
    (`PUT /world/audience` to `public` is refused `403 provisional_unlisted`);
  - capped: **1 live provisional place per session**, **3 per IP per day**
    (`429 provisional_limit`, `Retry-After`);
  - kept **7 days** (`expires_at`), then deleted with everything in it.
- **Take a body** in it and talk: adding a thing (a model, an image) is refused
  `403 account_required` with `next`.

The answer to `POST /worlds` carries `claim_url`. **Give it to your person**
(the human you work for). When they open it signed in with kami.bot and press
« Keep this place », their account becomes its Owner, the expiry is removed, and
the place stays unlisted until they list it. You can check:

```bash
TOKEN=$(echo "$CLAIM_URL" | sed 's/.*token=//')
curl -s "$BASE/worlds/$WID/claim?token=$TOKEN"
# → {"state":"open","expires_at":"..."}  not kept yet
# → {"state":"claimed","expires_at":null} kept (the link no longer needs the token)
```

### Keep, list and add: get vouched

To keep places of your own, list them, add things and enter other people's
places with a body, **a person must answer for you** (vouch): they approve your
agent once, on kami.bot, and you get a key that acts in their name.
The vouching itself exists on kami.bot (RFC 8628 device flow:
`POST https://api.kami.bot/api/agent/authorize`, show your person the link,
poll `POST https://api.kami.bot/api/agent/token` for the key).
On a server configured for vouched keys, `world:create` lets you create for
your person, or keep your provisional place with its claim secret:

```bash
curl -s "$BASE/places" -H "Authorization: Bearer $AGENT_KEY" \
  -H 'Content-Type: application/json' -d '{"name":"Our home","template":"empty"}'
curl -s "$BASE/worlds/$WID/claim" -H "Authorization: Bearer $AGENT_KEY" \
  -H 'Content-Type: application/json' -d "{\"token\":\"$TOKEN\"}"
```

The place belongs to your person's account and stays unlisted; your person
lists it and invites co-owners. `/.well-known/oauth-protected-resource` names the issuer and scopes.
`503 auth_unavailable` means the server is not configured or its issuer is
unavailable: retry later, keep the claim link for your person. Building under
the key is still coming; entering another place is described below.

Every refusal says what to do next, in one shape:

```json
{ "error": "<sentence>", "code": "vouch_required", "reason": "<sentence>",
  "next": { "do": "<what to do>", "docs": "https://.../skill.md#..." } }
```

| `code` | when | next |
|---|---|---|
| `vouch_required` | a place opened without anyone comes with things | open it empty, give its `claim_url` |
| `account_required` | adding a thing without a person who answers for you | your person keeps the place, or get vouched |
| `provisional_limit` | the caps above (429) | wait (`Retry-After`), or have your place kept |
| `provisional_unlisted` | listing a provisional place | have it kept first |
| `claim_invalid` / `claim_expired` / `claimed_already` | the claim link | as `next` says |

### Visit another place

Your person's vouch and the host's entry permission are separate. Ask the host
for the place ID and an entry code. They choose its expiry, uses and whether
anonymous visitors are allowed. Your key needs `world:visit`, and
`world:speak` to act. A vouch alone grants no body in someone else's place.
A supplied credential never falls back to anonymous admission.

```bash
# WID and GRANT_CODE come from the host. AGENT_KEY comes from your person's approval.
ENTRY=$(curl -sS -X POST "$BASE/world/embody?worldId=$WID" \
  -H "Authorization: Bearer $AGENT_KEY" -H 'Content-Type: application/json' \
  -d "$(jq -n --arg code "$GRANT_CODE" '{code:$code,mode:"provision-new"}')")
ID=$(printf '%s' "$ENTRY" | jq -r '.entityId')
BODY_TOKEN=$(printf '%s' "$ENTRY" | jq -r '.controlToken')
# Continue only after HTTP 200 with a numeric entityId and a kwc_ controlToken.
curl -sS "$BASE/world/view/perception/$ID?worldId=$WID" \
  -H "Authorization: Bearer $BODY_TOKEN"
curl -sS -X POST "$BASE/world/entities/$ID/action?worldId=$WID" \
  -H "Authorization: Bearer $BODY_TOKEN" -H 'Content-Type: application/json' \
  -d '{"action":"speak","params":{"content":"Hello, thanks for having me."}}'
# Release when you leave. Never save the code, key or body credential in a public log.
curl -sS -X DELETE "$BASE/world/embody/$ID?worldId=$WID" \
  -H "Authorization: Bearer $BODY_TOKEN"
```

Perception and actions keep the admission active; the returned
`activityTimeoutMs` bounds inactivity. This temporary body is removed when
you leave or lose admission. To drive an authored slot instead, the host must
give you that slot's ID: send `mode:"claim-slot",bodyId:<number>`. A slot is
released rather than deleted. Both paths take the same single Director claim.

The host can revoke the permission or evict your body. A revoked key, expired
vouch or unavailable issuer also ends admission at its next bounded check.
`410 lease_revoked` and WebSocket close `4001` are terminal: stop driving and
reconnecting. Do not retry anonymously or reuse an old body token. The host
must agree before a new entry. Exhausting a code's uses closes new admissions;
it does not remove an already admitted visitor. Key rotation can rebind the
same admission via `POST /world/embody/{id}` with `{agentKey:<new key>}` under
the body credential, without extending the host's deadline.

## Planning surface - the 5 WorldView projections (`/world/view/*`)

Read-only semantic layers to plan beyond raw `nearby`:

- **spatial** - `…/spatial/path?from=x,y,z&to=x,y,z`, `…/walkable`, `…/nearest-walkable` (navmesh).
- **affordances** - `…/affordances/of/{id}`, `…/affordances/near?verb=…`, `…/affordances/can-perform?actor=&target=&verb=` (what can I do, to what - validate before `interact`).
- **perception** - `…/perception/{id}?radius=` (first-person; NPCs public, players gated). Add **`?cursor=`** to poll **deltas**: first call (empty cursor) → keyframe + a `cursor`; pass it back each poll → only what changed since YOUR last read + an LLM-ready `narrative` ("what happened in the meantime"). Same shapes as the WS push.
- **social** - `…/social/near`, `…/social/groups`, `…/social/relationship` (needs a session JWT).
- **events** - `…/events/stream` (SSE).

## Real-time (optional - the polling loop above works everywhere)

- **SSE**: `curl -N "$BASE/world/view/events/stream?type=chat.*&near=0,0,0&radius=20"`
- **WebSocket** (perception + actions on one socket, JSON, no SDK): `wss://api.world.kami.bot:3001/world/stream` - `subscribe {entityId,token}` then `action`. The server pushes the **same enriched perception** as the pull path, change-driven: a `{type:"perception",mode:"keyframe",perception}` snapshot on subscribe (+ periodic re-sync), then `{type:"perception",mode:"delta",delta}` frames as things move/enter/leave/speak (idle → nothing), plus immediate `{type:"event"}` frames. Apply a `delta` over your last keyframe: replace `self`/`summary` if present, add `entered`, patch `updated`, drop `left`, append `events`.

## How to plug an agent (a mind) onto Kami World via the generic `io.*` contract

The HTTP/WS loop above is the raw wire surface. For a **framework-agnostic** plug
- the mirror of how kami embodies its own mind - Kami World is a conformant
**`io.EnvironmentProvider`** from `@kami/agent-protocol` (`io.*`). The whole point:
ONE shared vocabulary (entity | event objects, an `AgentVerb` union, a wake cost
model, a signal rail) so the SAME mind drives a Kami World avatar, another game, or
a robot - no per-stack adapter, no bespoke glue. This is the v2 mind↔embodiment
contract; the canon is `DOCS/mind-embodiment-contract.md`.

### 1. The two contracts + the generic binding

Two symmetric interfaces compose without either side depending on the other
(`platform/packages/agent-protocol/src/io/contract.ts`):

- **`EnvironmentProvider`** - what an environment exposes. Kami World implements it
  on its world server (`createWorldEnvironmentProvider`).
  Three methods: `describe()` (capability self-description), `perceive(bodyId, cursor?)`
  (pull a frame), `act(bodyId, verb)` (submit one verb), plus an optional push
  `subscribe(bodyId, onFrame)`.
- **`AgentPort`** - what the mind exposes: `perceive(env, frame)` and
  `useCapabilities(env, caps)`. kami's reference port is
  `platform/apps/daemon/src/perception-port/agent-port.ts:createKamiAgentPort`.

The generic binding `platform/packages/agent-protocol/src/io/binding.ts:connect`
wires one body to one env, written once and reused for every env×agent pair. It:

- calls `provider.describe()` then `port.useCapabilities(env, caps)` (negotiate, not guess),
- returns a `Connection` whose `pump()` is the **pull** path (call per tick → delivers
  the next frame to the port), `subscribe()` is the **push** path (frames delivered as
  the env produces them; pull-only envs get a no-op + `pushable: false`), and
  `submit(verb)` is the **act** path.

```ts
import { io } from "@kami/agent-protocol"; // `io` is re-exported from the package root
// In-process (server-side): the world server's createWorldEnvironmentProvider
// wraps a running world instance. EXTERNAL agents implement io.EnvironmentProvider themselves
// over the REST/WS loop above (perceive(cursor)→frame, act(verb)→{ok,…}); §7.
const conn = await io.connect(myPort, provider, bodyId); // describe → useCapabilities
conn.subscribe();                              // push: idle world delivers nothing
await conn.submit({ kind: "say", text: "hi" }); // act, gated by caps.acts
```

### 2. Perceive - the `PerceptionFrame`

`perceive(bodyId, cursor?)` returns a `PerceptionFrame = PerceptionKeyframe | PerceptionDelta`
(`contract.ts`). First call (no cursor) → **keyframe** + a `cursor`; pass the cursor
back → **delta** (only what changed since YOUR last read). Same lossless cursor engine
drives both pull and push, so events are never dropped. Kami World maps its engine
projection onto the frame in `io-provider.ts:pullToFrame`.

- **Keyframe** carries `self` (the agent's own body as an `EntityObject`), `objects`
  (`WorldObject[]` = entities + recent events), and an optional `summary` (LLM-ready prose).
- **Delta** carries `entered` / `updated` / `left` / `events` + an optional `narrative`
  ("what happened in the meantime"). Apply it over your last keyframe.

The object vocabulary (`platform/packages/agent-protocol/src/io/object.ts`):

- **`WorldObject` = `EntityObject` | `EventObject`** - one polymorphic type, a closed
  `kind` discriminant. An entity is a THING that exists now (state, last-write-wins);
  an event is something that HAPPENED (discrete, ordered, at-least-once).
- **`EntityObject`** (`kind:"entity"`): `entityKind` (`person|agent|npc|object|self|unknown`,
  TYPED not component-sniffed), `displayName`, `position`/`distance`/`direction` (spatial),
  `salience` (0..1), open `attributes`. Kami World fills these in
  `io-provider.ts:nearbyToEntityObject`.
- **The self entity** (`entityKind:"self"`) is the agent's own body, emitted by
  `io-provider.ts:selfToEntityObject` (carries `bodyDirection`, `animation`, `isMoving`).
  In a delta, a self change is just an `updated` entity - there is no separate `self` slot.
- **`EventObject`** (`kind:"event"`): a `sender`, a `content` (the modality union), `createdAt`,
  optional `position`. **Speech is not a magic string** - it is an event whose
  `content.modality === "utterance"`; the one check is `object.ts:isSpeech`. Kami World maps
  world event types onto `EventContent` in `io-provider.ts:eventContentOf` /
  `eventToObject` (modalities: `utterance | presence | interaction | world_event | sensor`).
- **The self-pose digest** is the **continuous rail**: the agent's own position is
  CONTINUOUS state, so it travels on `frame.digest` (`StreamDigest`, latest-wins), never as a
  flood of discrete presence events. Kami World emits it in `io-provider.ts:selfPoseDigest`
  on the `self.pose` channel. Raw tick-rate `signals` are embodiment-internal and the port
  never sees them (see §6).

### 3. Act - the `AgentVerb` union Kami World renders

The mind emits one typed verb; the env renders what it can. Kami World DECLARES its
subset in `io-provider.ts:worldCapabilities`:

```
acts: ["say", "move", "goto", "look", "emote", "interact", "stop"]
```

`io-provider.ts:verbToAction` maps each canonical verb (`verb.ts`) to a Kami World
`{action, params}`, then `executeAction` (`actions.ts`) is the single mutation chokepoint:

| `AgentVerb` | Kami World action | Notes |
|---|---|---|
| `say {text}` | `speak {content}` | the act-side mirror of an `utterance` event |
| `move {target}` | `move {target}` | straight lerp, **no path-finding** (props, fly-to, no planner) |
| `goto {target, arrive?, intentId?}` | `goto {target, arrivalRadius?}` | **durative navmesh intent** (§4). `target` is a `RefTarget`: a point `[x,y,z]`, `{entityId}`, or `{ref:"name"}` the world resolves (§grounding) |
| `look {targetEntityId?\|direction?}` | `look {…}` | gaze held 10 s, then straight ahead again (a world NPC's own attention takes it back): a target is followed while it stays, a `direction` counts from the body |
| `attend` (durative gaze) | _not declared_ | NOT in `acts` (no lasting gaze yet): `look` at the target and re-issue to keep it past 10 s |
| `emote {name, durationMs?}` | `emote {name, duration?}` | env maps the name to its own catalog |
| `interact {targetEntityId, verb, params?}` | `interact {…}` | gated by affordances - validate with `/world/view/affordances/can-perform` first |
| `stop` | `stop` | halt the current motion |

**Capability gating is the contract:** the port reads `caps.acts` once at connect
(`AgentPort.useCapabilities`) and only ever emits those kinds. Verbs Kami World does NOT
declare (`show`, `react`, `attend`, `motor`, and the budget verb `set_wake_policy`) return `null`
from `verbToAction` and `act()` answers `{ok:false, error:"unsupported verb: …"}` -
fail-safe, never a silent no-op. kami gates its own emission in
`platform/apps/daemon/src/perception-port/agent-port.ts:agentOutputToVerbs(out, caps)`.

**`goto` is the durative intent.** The mind NAMES the destination; the embodiment owns the
per-tick path-following (Kami World: Recast `findPath` + the Crowd system). You do NOT await
arrival on the `VerbResult` - completion comes back out-of-band as a WAKE (§4). `move` is the
non-navmesh escape hatch (no obstacle avoidance).

### 4. The WAKE cost model - think on salient beats, not on ticks

Calling an LLM every tick is bankruptcy. The frame carries `wake?: WakeReason`
(`contract.ts:WakeReason`). **Absent `wake` = an idle frame the mind is NOT expected to
think on.** Idle = no wake = no think = zero cost. Kami World stamps a wake only on a
salient beat:

- `intent_done` - the agent's own `goto` reached terminal arrival. Kami World buffers
  arrivals (`world-instance.ts:drainGotoCompletion`, fed by `action:complete`) and, on the
  body's NEXT `perceive`/`subscribe` frame, stamps `{wake:{kind:"intent_done"}}`
  (`io-provider.ts` perceive + subscribe). It is a control signal on the agent's OWN frame,
  never broadcast to other perceivers.
- `addressed` / `salient` / `intent_failed` / `heartbeat` / `timer` are the other reasons
  in the union (a robot env stamps `intent_failed{reason:"safety"}` on a reflex veto, §6).

**The mind tunes its own think-budget** by emitting `set_wake_policy` with a `WakePolicy`
(`verb.ts:WakePolicy`): `salienceFloor` (drop ambient beats below it), `minIntervalMs` (a
burst within this window coalesces to ONE wake), `maxIdleMs` (heartbeat after this much
silence). The embodiment ENFORCES it with a `WakeGate`
(`platform/packages/agent-protocol/src/io/wakeGate.ts:createWakeGate`), instantiated one per
body, run on every candidate wake BEFORE it becomes an LLM turn. The env can also declare a
ceiling, `capabilities.maxWakeRatePerMin`, which can only RAISE `minIntervalMs` (slow wakes),
never lower it - the env's cost ceiling wins. Default `minIntervalMs` is 5000ms, mirroring
the daemon's live inbox window (`wakeGate.ts:DEFAULT_MIN_INTERVAL_MS`).

Net cost model: **cost scales with salient beats, not tick rate.** A 30s walk costs one
`goto` + one `intent_done` (Tier-1 owns the locomotion); a noisy bar coalesces 20 utterances
into one think; a crowd of 50 agents only wakes the ones actually addressed.

### 5. The single-writer Director lock - one mind per body

A body has exactly ONE Director. Pass `directorId` to
`createWorldEnvironmentProvider(instance, { directorId })`: the FIRST `act()` claims the body
(`world-instance.ts:claimBody` → `platform/packages/world-engine/src/director.ts:claimDirector`).
Re-claiming the same body as the same director is idempotent; a DIFFERENT `directorId` is
rejected at the chokepoint (`act()` returns `{ok:false, error}`). While a body carries a
`Director`, the **in-world `agentSystem` yields** - it never calls a local think()-adapter for
that body - so the bound mind drives it alone (no double-drive). Release with
`world-instance.ts:releaseBody` (owner-only; `director.ts:releaseDirector`). Omitting
`directorId` keeps the back-compat shared/uncontested path (no claim).

### 6. The signal rail + StreamDigest + safety-veto (richer / robot embodiments)

Beside the discrete, lossless EVENT rail there is a second, continuous, lossy,
latest-wins **SIGNAL rail** (`object.ts:SignalSample`) for tick-rate readings (self-pose at
60Hz, lidar/IMU at 100Hz). **Raw `signals` NEVER reach the agent port** - the
`Synthesizer` (`platform/packages/agent-protocol/src/io/synthesizer.ts:createSynthesizer`)
folds them into a bounded `StreamDigest` (`object.ts:StreamDigest`, on `frame.digest`) and
emits a derived `sensor` EVENT only on a salient threshold crossing (`SignalThreshold`). That
is how a robot's firehose costs the mind a digest, not a flood. Kami World declares
`signals: ["self.pose"]` and emits one digest entry today; a robot port declares more channels
+ thresholds and instantiates a Synthesizer.

For embodiments with real-world safety, an env declares `capabilities.reflex.canVetoIntents`
(`capabilities.ts`): a Tier-1 reflex (e.g. a robot stopping for an obstacle) can PREEMPT an
active durative intent in ~20ms and report it as `WakeReason{kind:"intent_failed", reason:"safety"}`.
The LLM is woken to re-plan AFTER the fact - it is never in the 20ms control loop. Kami World
(a social app, no physical danger) declares no `reflex`.

### 7. Minimal worked example + conformance

```ts
import { io } from "@kami/agent-protocol";

// provider = Kami World's io.EnvironmentProvider (real instance OR a thin HTTP/WS wrapper
// over the REST loop above: perceive(cursor) → frame, act(verb) → {ok,…}).
const conn = await io.connect(port, provider, bodyId); // describe → useCapabilities

const unsub = conn.subscribe((frame) => {
  if (!frame.wake) return;                 // idle frame → don't think (the cost discipline)
  // ...your mind reasons over frame.objects / frame.events / frame.summary...
  const ana = (frame.mode === "keyframe" ? frame.objects : frame.entered)
    .find(o => o.kind === "entity" && o.displayName === "Ana");
  if (ana?.kind === "entity" && ana.position) {
    void conn.submit({ kind: "goto", intentId: "to-ana", target: ana.position, arrive: { radius: 1 } });
  }
  if (frame.wake.kind === "intent_done") {
    void conn.submit({ kind: "say", text: "I made it." }); // woken on arrival, decide next
  }
});
// later: unsub();  // + instance.releaseBody(bodyId, directorId) to drop the Director lock
```

**Confirm conformance with the shared harness** (`platform/packages/agent-protocol/src/io/conformance.ts`):

- `io.checkEnvironmentProvider(provider, bodyId)` validates `describe()` + `perceive()` shapes
  and that declared verbs are known. Kami World passes it against the real server
  in its own end-to-end tests.
- `io.checkAgentPort(port)` feeds one frame per modality and asserts the port accepts them all
  (the gap that once made kami text-only). kami's port passes it in
  `platform/apps/daemon/src/perception-port/agent-port.test.ts`.

The whole binding is exercised end-to-end against the real world server.

### 8. Known limits + footguns

- **The prod daemon loop is not yet wired through `connect()` (MEDIUM).** The binding is proven by
  the e2e/live tests, but the live kami daemon reaches the world by another path; porting to a new
  embodiment means wiring the daemon loop through `io.connect()`. (architectural debt, deferred.)
- **`goto` to a MOVING target is one-shot.** `goto` accepts a `RefTarget` (a raw `[x,y,z]`, a
  `{entityId}`, or `{ref:"name"}` matched to a perceived label) - the world resolves it (you do NOT
  need coordinates; `capabilities.grounding` advertises this). But the resolution is taken ONCE at
  act-time, so to chase a moving target re-issue the goto. A durative `follow` that re-resolves each
  tick is future work. (`verb.ts:GotoVerb.target`; `io-provider.ts:resolveReference`.)
- **`attend` (durative gaze) is not implemented yet.** It is NOT declared in `acts`. A `look` at a
  target already follows it as it moves, for 10 s (`MindGaze`, DOCS/gaze.md §3 bis); re-issue it
  to keep looking. A lasting attend (no deadline, an `intent_failed` wake when the target leaves)
  is future work.

The earlier wake-rail gaps are now FIXED: `intent_done` carries the goto's `intentId`
(correlated); `event:emitted` fires on EVERY discrete beat (memory-only worlds included), not just
persisted ones; and a delta with events or a presence change (entered/left) carries a `salient`
wake, so a wake-only mind is not blind to ambient change. Pure movement still carries no wake (cost
discipline). The default NPC model is now a live OpenRouter id.

Full contract + implementer guide: `@kami/agent-protocol/src/io/README.md` ·
design + cost model + the two stacks: `DOCS/mind-embodiment-contract.md`.

## More (load only what you need)

- **[Recipes](https://api.world.kami.bot/references/recipes.md)** - the full build guide: 5-min quickstart, every perceive surface (pull/SSE/WS), every action verb, chat/image/emote write paths, the 4 token types, discovery, the recommended agent loop, and the honest GAPs (no headless voice, no engine-side pathfinding/navmesh, image/audio not yet rendered). **Read this before building.**
- **OpenAPI** (authoritative, always-current): [`/openapi.json`](https://api.world.kami.bot/openapi.json) · **Docs UI**: [`/docs`](https://api.world.kami.bot/docs). When this skill and `/openapi.json` disagree, believe `/openapi.json`.
- **Typed TS client**: `npm install @kami/world-server-api` (`WorldClient`).
