Docs
/

Leumas Entities

Write a Leumas entity — room kinds, picker types, operator models, the body/motion vocabulary, and the silent failures each causes. Read before touching domain-entities.js or a leumas.entity.js.

Writing a Leumas entity

Three unrelated registries share this word. They have no code in common, so the first question is always which one, and the answer is decided by where you want the thing to show up.

you wantyou meandeclared inseen by
a body standing in the 3D rooma KINDshared/apps/domain-entities.js, a package's leumas.entity.js, or defineLeumasApp({ entities })GET /api/room/kinds · /api/room/entities
cameraId to render a dropdown of real rowsa TYPEone row in ENTITY_TYPES, shared/packages/schemas/src/entities.jsnothing — it is a bundled module
the operator to define their own tablea MODELcollection_schemas, via Studio → Dynamic → SchemasGET /api/dynamic/schemas (appliance/dev only)

Browse what already exists at /admin/dynamic/entities before adding anything.


1. Room kinds

Verify the data BEFORE you write the spec, never after

Two fields decide whether the kind works at all, and both fail silently when wrong. Read them out of the live database rather than guessing:

// node ops/<throwaway>.mjs — read-only
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync('./data/leumas.db', { readOnly: true });
db.prepare(`SELECT COUNT(*) c, SUM(CASE WHEN owner IS NULL THEN 1 ELSE 0 END) n FROM "dyn_<collection>"`).get();
db.prepare(`SELECT data FROM "dyn_<collection>" LIMIT 2`).all();   // the REAL field names
  • The collection is the one the surface actually WRITES toreasoning_graphs, not graphs.
  • Scope follows ownership. Every row owner IS NULLscope: 'tenant'. Choose 'user' there

and the read drops every row and answers a perfectly valid empty room.

  • variant, state and bands.field must name fields that exist on the row. A byState keyed

on a missing field matches nothing and renders every row at the species default — it looks deliberate. Omit the mapping rather than guess it.

Where to declare it — and the trap

the domain isdeclare inreaches the API via
a store app (media, wardrobe, civic…)DOMAIN_ENTITIES in shared/apps/domain-entities.jsdefineDomainAppdefineLeumasAppFIRST_PARTY_APPS
a core section (commerce, lmx, dynamic, ai…)CORE_ROOMS in the same filecompiled there, spread into app.js's specs:
a package (chatbots, hosting, surveillance…)its own leumas.entity.js, exported as ./entitynamed in app.js's specs: array
a HAND-WRITTEN app (lead-finder, reasoning-graphs)its own index.jsdefineLeumasApp
[critical] A prospecting: entry in domain-entities.js compiles into NOTHING. DOMAIN_BACKED_APPS = domainAppsExcept(HAND_WRITTEN_APPS) filters the generated domain app out, so the table never sees it. No error, no warning — the kind simply never exists. The id is then lead-finder.run, not prospecting.run: defineLeumasApp forces app to the app id.
[warning] There are THREE sources of kinds, not two. app.js's specs: array is eight package-level imports plus CORE_DOMAIN_ENTITIES plus FIRST_PARTY_APPS.map(a => a.entities). A script that checks only the last two reports a perfectly registered kind as missing.

Verify what you actually registered by mirroring that whole array and printing the ids.

The spec

{
  kind: 'playlist',                    // SLUGGED and LOWERCASED — `triggerType` becomes
                                       // `triggertype`. Use kebab: `trigger-type`.
  label: 'Playlist', plural: 'Playlists', glyph: 'music',
  source: { collection: 'music_user_playlists', scope: 'user' },
  max: 24,                             // clamped to 48 server-side
  aggregate: { over: 12, as: 'stack', label: '{n} more' },   // anything that can exceed a handful
  map: { id: 'id', label: 'name|id', sub: 'artist', href: '/admin/music/playlists' },
  appearance: { body: 'jukebox', color: '#c084fc' },         // color is a HEX LITERAL, never a token
  behaviour: { idle: 'station', approach: 'wake' },
  reactions: [{ on: 'music.played', match: { playlistId: '$.id' }, do: 'pulse', for: 900 }],
}
  • trust: 'first-party' whenever the source names a shared collection. The default is

sandboxed, which rewrites productsapp_commerce_products — a table that does not exist. The read then reports ok with zero rows, indistinguishable from an account that owns nothing.

  • map.id is required; a row without one is dropped. label takes a fallback chain

(name|title|id) that resolves left to right and lands on the id, so the worst case is a slug.

  • Mappable fields: id label sub href variant state count live face. count drives SIZE (a

standing quantity), live drives BRIGHTNESS (a right-now quantity) — encoding both in one number makes busy-last-week and busy-right-now indistinguishable, the one comparison the room is for.

  • Style layers merge base → byVariantbyStatebands, per property. Legal properties:

body model color scale opacity emissive. bands are >= inclusive over a numeric field.

  • Reaction verbs: pulse jump approach alert celebrate blip slump. Most on: events have no

server emitter yet — POST /api/room/emit exercises them by hand. Say so in a comment rather than implying they animate today.

Then claim it, or the guard fails you

check:huds rejects a kind no domain asks for ("its own room is empty while /dashboard draws it"). Two honest resolutions:

  1. Add the kind id to that domain's kinds: in products/leumas-studio/src/admin/_shared/model/domainOverviews.js.
  2. If the domain has a bespoke door (it is in DOMAIN_LANDINGS, so it may not have a table entry

at all), add it to UNCLAIMED_OK in ops/infra/scripts/check-domain-huds.mjs with a sentence that is true — e.g. that the door's own populator casts it, or that 29,102 rows are an index you search rather than a room you walk through.

A new spec FILE must also join SPECS in that guard, or it reports a real kind as imaginary.


2. Bodies — the closed vocabulary

A body is a silhouette, not a picture: shape survives fog, distance, a light-mode floor and a colour-blind viewer; colour stops working past about four species. Shape says WHAT KIND of thing this is, colour says WHICH one.

Pick the nearest honest one from @leumas/entity-kit's BODIES — each carries a note saying what it is FOR. Do not invent geometry in a spec; the list is closed. (No count is written here on purpose: it grows, and a number that drifts is worse than none. Read the array.)

Several ship unused, waiting for the first thing that fits — critter (a pet or mascot), drone (something sent out to look at a place), satellite (a machine of yours somewhere else), jellyfish (quietly ongoing), alien (not yours). Reaching for one of those is cheaper and more legible than adding a thirty-ninth.

Adding a body (two files, one test)

  1. shared/packages/entity-kit/src/bodies.js{ id, label, note }. The note is what another agent

reads to choose it.

  1. shared/packages/features/src/nav-views/world/bodies3d.js — a parts list:

{ g, y, x, z, rx, ry, s, mat, part, pivot }. mat is a ROLE (body light dark glow), never a colour.

  1. world-bodies.test.js checks both directions and the contract: base ≥ −0.08, top ≤ 1.55,

top ≥ 0.5. The ground halo, the label anchor (y+1.5) and the proximity radius all assume it.

Limbs and the gait

A part may name a part role — leg-l leg-r arm-l arm-r head — and a pivot, the y of the joint. WorldStage hangs each tagged part off a hinge group (several parts may share a role and swing as one limb) and stride() swings them.

[critical] The stride phase advances with DISTANCE TRAVELLED, not with time. A time-driven gait cycles at a fixed rate while speed varies, so the feet skate — everybody sees it, nobody can name it.

y still means where the part rests, so adding a pivot changes no measurement and leaves the flat snapshot renderer untouched.


3. Motion

Idle (IDLE_MODES): wander patrol orbit station sweep float follow Approach (APPROACH_MODES): face wake greet expand wave none

Motion is a statement about what a thing IS — agents wander, places stand still, cameras sweep. Two carry traps:

[critical] follow must guard the ambient park. cameraMode: 'ambient' parks the avatar at BOUNDS * 100 so nothing greets an invisible person. An unguarded follower reads that as a destination and sprints off the floor with its label and halo. Use it sparingly and cap the kind's max: a room where everything follows you is a stampede.
[critical] wave needs relax to lower the arm. It is not called at all once proximity reaches 0, so without the decay the room fills with bodies frozen mid-greeting. It degrades to face on a body with no arm-r, so it is safe to declare anywhere — it just says less on a filing cabinet.

Adding a mode = the table in behaviours.js + the list in entity-kit/bodies.js. The cross-check test fails in both directions: an implemented mode nobody can ask for is dead weight.


4. Characters: the mii body and the face field

body: 'mii' is the only body assembled per row rather than per kind, so two rows are two people rather than one model twice.

map: { id: 'id', label: 'name', face: 'avatar' },   // the row's own portrait
appearance: { body: 'mii', color: '#a56bff' },      // colour still tints the character's accent
  • The seed is the mapped face, falling back to deriveMiiValue(id) — **deterministic and never

persisted**, so a collection nobody has given portraits to still shows distinct, stable individuals.

  • @leumas/avatar/mii owns the ONE derivation (deriveMiiValue · miiFaceFor · miiSeedOf). Never

write a second: the deck card and the room would show different people for one record.

  • A photo URL has no seed and no mesh. The procedural mii figure stays — the same floor a

no-WebGL surface and a flat snapshot get. It is the guaranteed floor, not a placeholder to apologise for, exactly like appearance.model's GLB contract.


5. Picker types — one row, everywhere

Registering a type makes every field named <id>Id render a dropdown of real rows, at render time, with nothing to migrate.

camera: { label: 'Camera', aliases: ['surveillanceCamera'], source: 'db',
          collection: 'surveillance_cameras', labelKeys: ['name'] },

source is one of db · crud · api · catalog · adapter · static. Two rules:

  • A picker is an upgrade, never a requirement. A type whose list cannot be fetched must fall back

to the plain box it replaced.

  • A picker aimed at the WRONG rows is worse than none — confidently wrong, and indistinguishable

on screen from a correct one. runId means at least three different things here, which is why those stems are deliberately unregistered rather than guessed at. Use ENTITY_HINTS to scope a name that means different things in different places.

idKey decides what the picker SUBMITS (profile emits userId, wikiPage emits slug). Getting it wrong is silent: the picker looks right and submits an id the endpoint cannot resolve.


6. Models — the one an operator writes

No code. Studio → Dynamic → Schemas, or POST /api/dynamic/schemas. Raw JSON Schema is the stored truth; presentation rides in x- extensions (x-label, x-order, x-control, x-span).

Worked examples live in shared/engines/dynamic/src/schemas/starters.js — and smoke:dynamic walks every one through the real create route and the real validator, so a starter the API would refuse fails the build rather than teaching an operator a shape that does not work. Add one there rather than inventing an example in a doc.

Gated on caps.fileToolsabsent on platform and control, not merely disabled. Any surface touching it must degrade with an explanation, not a 404.


Before you say it works

pnpm check:huds        # the kind is claimed, the spec is real, the door contract holds
pnpm check:nav         # if you touched the manifest
pnpm check:theme       # if you touched CSS
node --test shared/packages/entity-kit/test/*.test.js
node --test shared/packages/features/test/world-bodies.test.js shared/packages/features/test/world-behaviours.test.js
pnpm smoke:dynamic     # if you touched a starter model

Then print what actually registered — mirror app.js's three-source specs: array and list the ids. Every failure mode in this document is silent; the guard catches most of them, and the list catches the rest.

The silent failures, in one place

symptomcause
room is empty, sources says oksandboxed trust rewrote the collection name
room is empty on every accountscope: 'user' on a collection whose rows are unowned
every row looks the samebyVariant/byState keyed on a field that does not exist
the kind does not exist at alla hand-written app's spec put in domain-entities.js
the kind id is unreadabledefineEntities slugs and lowercases — use kebab
a body renders as a generic figurethe id is not in bodies3d.js; bodyParts falls back to capsule
a label floats in a body's chestthe body breaks the base-0/top-1.3 contract
feet skate while walkinga gait driven by time instead of distance
a follower leaves the floorfollow without the ambient-park guard
bodies frozen mid-greetingwave without the relax decay
the deck and the room show different facesa second mii derivation

Related: leumas-domains (where a kind's room and card counts come from) · build-leumas-plugin (what a sandboxed plugin may declare — app forced, collection namespaced, reactions confined to your own events, data only, no functions).

Source .claude/skills/leumas-entities/SKILL.md (no-git)markdownjson
Generated from the Leumas repository. Every page cites the file it came from.leumas.techllms.txt