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 want | you mean | declared in | seen by |
|---|---|---|---|
| a body standing in the 3D room | a KIND | shared/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 rows | a TYPE | one row in ENTITY_TYPES, shared/packages/schemas/src/entities.js | nothing — it is a bundled module |
| the operator to define their own table | a MODEL | collection_schemas, via Studio → Dynamic → Schemas | GET /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 to —
reasoning_graphs, notgraphs. - Scope follows ownership. Every row
owner IS NULL⇒scope: 'tenant'. Choose'user'there
and the read drops every row and answers a perfectly valid empty room.
variant,stateandbands.fieldmust name fields that exist on the row. AbyStatekeyed
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 is | declare in | reaches the API via |
|---|---|---|
a store app (media, wardrobe, civic…) | DOMAIN_ENTITIES in shared/apps/domain-entities.js | defineDomainApp → defineLeumasApp → FIRST_PARTY_APPS |
a core section (commerce, lmx, dynamic, ai…) | CORE_ROOMS in the same file | compiled there, spread into app.js's specs: |
a package (chatbots, hosting, surveillance…) | its own leumas.entity.js, exported as ./entity | named in app.js's specs: array |
a HAND-WRITTEN app (lead-finder, reasoning-graphs) | its own index.js | defineLeumasApp |
[critical] Aprospecting:entry indomain-entities.jscompiles 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 thenlead-finder.run, notprospecting.run:defineLeumasAppforcesappto the app id.
[warning] There are THREE sources of kinds, not two.app.js'sspecs:array is eight package-level imports plusCORE_DOMAIN_ENTITIESplusFIRST_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 products → app_commerce_products — a table that does not exist. The read then reports ok with zero rows, indistinguishable from an account that owns nothing.
map.idis required; a row without one is dropped.labeltakes 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.countdrives 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 →
byVariant→byState→bands, 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. Moston: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:
- Add the kind id to that domain's
kinds:inproducts/leumas-studio/src/admin/_shared/model/domainOverviews.js. - 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)
shared/packages/entity-kit/src/bodies.js—{ id, label, note }. The note is what another agent
reads to choose it.
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.
world-bodies.test.jschecks 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]followmust guard the ambient park.cameraMode: 'ambient'parks the avatar atBOUNDS * 100so 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'smax: a room where everything follows you is a stampede.
[critical]waveneedsrelaxto 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 tofaceon a body with noarm-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 toderiveMiiValue(id)— **deterministic and never
persisted**, so a collection nobody has given portraits to still shows distinct, stable individuals.
@leumas/avatar/miiowns 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
miifigure 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.fileTools — absent 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
| symptom | cause |
|---|---|
room is empty, sources says ok | sandboxed trust rewrote the collection name |
| room is empty on every account | scope: 'user' on a collection whose rows are unowned |
| every row looks the same | byVariant/byState keyed on a field that does not exist |
| the kind does not exist at all | a hand-written app's spec put in domain-entities.js |
| the kind id is unreadable | defineEntities slugs and lowercases — use kebab |
| a body renders as a generic figure | the id is not in bodies3d.js; bodyParts falls back to capsule |
| a label floats in a body's chest | the body breaks the base-0/top-1.3 contract |
| feet skate while walking | a gait driven by time instead of distance |
| a follower leaves the floor | follow without the ambient-park guard |
| bodies frozen mid-greeting | wave without the relax decay |
| the deck and the room show different faces | a 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).