An API for the visitor who is not a person
The room at the top of this page was furnished by a script, photographed by the same script, and nobody touched a mouse. It is the reference house on the live site, standing empty half a minute earlier. Ten pieces of furniture went in, a camera was put at eye height in a specific corner facing a specific compass bearing, and a JPEG came back.
That is new. Until this week Duimstok could be driven from a script only on a
developer's machine: window.__hm is assigned inside
if (import.meta.env.DEV), so a production build exposed nothing at all.
An agent with a browser and nothing installed could open the site and do precisely
nothing with it.
There is now a second surface, window.duimstok, and it ships in every
build.
Five lines to a furnished room
Open duimstok.gemelyn.com, open the console, and paste this. There is no install, no key, no account, and no pointer lock anywhere in it.
await duimstok.ready;
duimstok.plan.rooms().rooms[0];
// { id: 'living', name: 'Living / kitchen', area: 41.9646,
// centre: [2.153, 4.472], bounds: { minX: 0, maxX: 5.15, minZ: 0, maxZ: 10.5 } }
duimstok.room.place('sofa-three-seat', { at: [0.55, 2.3], rotation: 90 });
duimstok.room.place('coffee-table', { at: [1.75, 2.3], rotation: 90 });
duimstok.camera.teleport({ at: [3.3, 5.9], heading: 334 });
const photo = duimstok.camera.photo();
// { ok: true, dataUrl: 'data:image/jpeg;base64,…', width: 1280, height: 672, bytes: 63481 }
Every length is metres. A position is [x, z] on the floor plane, because
the floor is where furniture goes; +Y is up and you rarely need it. An
angle is degrees clockwise from -Z, which is the convention the plan's
own spawn.heading already used, so there is one bearing convention in
the whole application rather than two that disagree in the third decimal.
The full list is short enough to read in a minute:
items.list, items.define, items.remove;
plan.get, plan.list, plan.rooms,
plan.create, plan.open;
room.list, room.place, room.move,
room.remove, room.clear;
camera.teleport, camera.photo; and
measure.
Nothing throws
Every call returns the same three fields — ok, why,
errors — with the payload spread alongside them. There is no
try anywhere in the calling code because there is nothing to catch.
One function is responsible for that, and every call goes through it:
function withScene<R extends Envelope>(run: (scene: ApiScene) => R): R | Envelope {
if (!live) return fail([NOT_READY]);
const scene = host.scene();
if (!scene) return fail([NO_SCENE]);
try {
return run(scene);
} catch (error) {
return fail([`internal error: ${error instanceof Error ? error.message : String(error)}`]);
}
}
Three jobs that have to happen on every single call or not at all: refuse before the scene is interactive, refuse when there is no session, and turn any unexpected throw into a returned value. That last clause is the promise the whole surface rests on. A bug in here still leaves the caller with something to read.
The reason is not tidiness. A person who hits an exception reads the stack trace and adjusts. A script written thirty seconds ago by a language model hits an exception and the run ends — the model never sees the message, because the message went to a console it is not reading. Hand it a value and it can act on the value. This is the single design decision that most changes how the thing feels to use, and it is worth about four lines of code.
Three states, and the difference between them
ok: false covers two entirely different situations and conflating them
wastes a caller's time, so they are distinguished by whether errors is
empty.
duimstok.room.place('sofa-three-seat', { at: [0.55, 2.3], rotation: 90 })
// { ok: true, why: '', errors: [] } it happened
duimstok.room.place('dining-chair', { at: [1.3, 5.4], rotation: 0 })
// { ok: false, why: 'into a wall', errors: [] } your input was fine; the answer is no
duimstok.room.place('armchair', { at: [2, 3], rotation: 'left' })
// { ok: false, why: 'rotation must be a finite number, received "left"',
// errors: ['rotation must be a finite number, received "left"'] }
The middle case is a refusal by a rule. The document was valid, the spot was not, and
there is nothing in what you sent to correct — you need a different position, not a
different request. The refusal text is passed through byte for byte from the same
Verdict that the on-screen heads-up display shows a person, because two
descriptions of one refusal drift apart within a month.
The error message is the feature
Every message names three things: the field, the constraint, and the value received. One builder produces all of them, so none of them can be a bare "invalid input".
And every problem in a document is reported at once. Here is a deliberately broken piece of furniture, with one typo and one wrong shape name:
duimstok.items.define({
schema: 'duimstok/item@1', id: 'x', name: 'X',
category: 'seating', anchor: 'floor',
footprint: { w: 0.5, d: 0.5, h: 0.5 },
materials: { m: { colour: '#ff0000' } },
parts: [{ shape: 'cube', size: [0.5, 0.5, 0.5], at: [0, 0.25, 0], material: 'm' }],
});
// errors: [
// 'item.materials.m.colour is not a known field; expected one of color, metalness, roughness',
// 'item.materials.m.color must be a hex colour such as "#c8a06a", received nothing',
// 'item.parts[0].shape must be one of box, cylinder, sphere, received "cube"',
// ]
A validator that stops at the first fault turns a document with a dozen mistakes into a dozen attempts. That is not a hypothetical cost when the author is a model iterating in a loop; it is the difference between converging in two round trips and converging in twelve. The same argument had already been made for floorplan validation a year earlier, and it arrives here for exactly the same reason.
The colour line is the one worth staring at. British spelling is the
single most likely typo in this format, and the validator refuses unknown keys
outright rather than ignoring them — because ignoring it would have produced grey
furniture and no explanation.
Furniture that arrives as JSON
Every piece of furniture Duimstok ships is a TypeScript file that builds itself from primitives, which is a rule we like a great deal and also a closed door: adding a chair meant writing a module and rebuilding. The one thing an agent is best at was the one thing the application had no entrance for.
The obvious fix is to accept generated TypeScript and evaluate it. That is the thing
we specifically refused. A visitor's item is data —
duimstok/item@1 — naming primitives that the application then builds
with the very same helpers every shipped item uses. There is no eval, no
new Function and no dynamic import of anything a caller supplied,
anywhere in the pipeline. That is the whole reason the format exists.
It is deliberately not general. Three shapes — box, cylinder, sphere — one optional rotation per part, no lights, no conditionals, no expressions, no composition. It cannot express half of our own catalog, and that is the correct trade for something a stranger's browser will interpret.
duimstok.items.define({
schema: 'duimstok/item@1',
id: 'reading-chair',
name: 'Reading Chair',
category: 'seating',
anchor: 'floor',
footprint: { w: 0.68, d: 0.82, h: 0.98 },
materials: {
frame: { color: '#c8a06a', roughness: 0.5 },
cushion: { color: '#3f5d52', roughness: 0.9 },
},
parts: [
{ shape: 'box', size: [0.60, 0.12, 0.62], at: [0, 0.40, 0.02], material: 'cushion' },
{ shape: 'box', size: [0.60, 0.62, 0.12], at: [0, 0.72, -0.30],
rotate: [-12, 0, 0], material: 'cushion' },
{ shape: 'cylinder', radius: 0.025, height: 0.42, at: [-0.30, 0.21, 0.32], material: 'frame' },
{ shape: 'cylinder', radius: 0.025, height: 0.42, at: [ 0.30, 0.21, 0.32], material: 'frame' },
{ shape: 'cylinder', radius: 0.025, height: 0.42, at: [-0.30, 0.21, -0.32], material: 'frame' },
{ shape: 'cylinder', radius: 0.025, height: 0.42, at: [ 0.30, 0.21, -0.32], material: 'frame' },
{ shape: 'box', size: [0.06, 0.05, 0.72], at: [-0.31, 0.62, 0], material: 'frame' },
{ shape: 'box', size: [0.06, 0.05, 0.72], at: [ 0.31, 0.62, 0], material: 'frame' },
{ shape: 'cylinder', radius: 0.022, height: 0.22, at: [-0.31, 0.51, 0.30], material: 'frame' },
{ shape: 'cylinder', radius: 0.022, height: 0.22, at: [ 0.31, 0.51, 0.30], material: 'frame' },
{ shape: 'cylinder', radius: 0.025, height: 0.62, at: [-0.30, 0.53, -0.34], material: 'frame' },
{ shape: 'cylinder', radius: 0.025, height: 0.62, at: [ 0.30, 0.53, -0.34], material: 'frame' },
],
});
Twelve parts, 432 triangles, and it is placeable the moment the call returns. It also persists: definitions are stored one per key in the visitor's own browser, capped at 200 KB in total, and nothing is ever evicted to make room — over budget the write is refused, the item still works for the session, and the reply says so with the current usage. A feature that silently deletes furniture someone made, to make room for more furniture, is worse than one that admits it is full.
The audit, handed back to whoever wrote the numbers
A manifest can lie. Nothing stops a definition from declaring a footprint of 0.68 × 0.82 × 0.98 m and then building geometry that is a different size — and that kind of lie surfaces much later, as furniture placed half inside a wall, in entirely the wrong file.
So the registry has always audited it. What is new is that the audit comes back to the caller. That definition above is exactly what we sent, and it was wrong:
audit: {
declared: [0.68, 0.98, 0.82],
measured: [0.68, 1.0357, 0.7831],
drift: 0.0568,
originError: 6.5565109175214076e-9,
triangles: 432,
problems: [
'footprint claims 0.680 × 0.980 × 0.820 but built 0.680 × 1.036 × 0.783 (w × h × d)',
],
}
The back cushion is tilted twelve degrees, which pushes the top of the chair 5.6 cm
higher than the arithmetic in our head said it would. Correcting
footprint to { w: 0.68, d: 0.78, h: 1.04 } and calling
define again under the same id returned problems: []. Two
round trips, no rebuild, no deploy, nothing installed.
That check was written for us, to catch our own manifests drifting from our own geometry. Handed to a caller that generated the numbers, it stops being a guard and becomes a feedback loop — the thing that makes generated furniture converge instead of merely existing.
And a discrepancy is not a rejection. The item is registered, placeable and photographable regardless, because an author that cannot see its item cannot correct it, and the numbers it needs are in the same reply.
Photographs, and a default worth arguing about
camera.photo() returns a data URL, its dimensions and its byte count.
It takes the same options as teleport, so a caller can photograph a spot
without standing there, and it renders and reads back the frame
inside a single task,
which is what makes the picture arrive at all.
The default caps the long edge at 1280 pixels and encodes JPEG, which puts a room in
the region of 25 to 45 kB. This is the most consequential default in the surface. The
same frame as an uncapped PNG is about 1.7 MB of base64, and the usual caller is a
model reading the reply into a context window: one photograph like that is the entire
budget. The pictures in this post were taken at
{ maxWidth: 1700, quality: 0.92, scale: 2 }, which is what the option
exists for — but you have to ask.
teleport has one side effect, and it is not free: it closes the start
menu. Without it the feature does not work at all, because while the menu is up a
preview camera orbits the house and rewrites the view every frame, so a script that
teleported and photographed got a doll's-house shot with no clue why.
photo deliberately does not close it — a photograph is a picture of what
is on screen, and the documentation says which.
Nothing is snapped
The on-screen placement ghost rounds to 5 cm and 15°, because a hand cannot place
more finely than that and a hand is what is holding the mouse. The API rounds
nothing. A sofa asked for at: [2.6, 3.2], rotation: 90 reads back as
exactly [2.6, 3.2] and 90.
A caller that computed a position from plan.rooms() can do better than a
mouse, and moving its request two centimetres without telling it would put the reply
out of step with the request — in an application whose central property is that the
measurements agree with each other. The rules that decide whether a spot is legal are
not reimplemented either: placement goes through the very same check the mouse does,
so the API cannot be more permissive than the interface.
What it costs, and who can call it
Anything already running on the page can call this. A bookmarklet, an extension, a script a visitor pasted in. There is no account and no server, so the blast radius is exactly one visitor's own browser storage: their autosaved layouts, the plans they pasted in, the items they defined. Nothing reaches anyone else and nothing leaves the machine.
That is an accepted cost rather than an oversight. The alternative is a capability gate, and there is nothing to gate it against on a static site with no identity — a token shipped in the bundle is not a gate, it is a token shipped in the bundle. If the application ever grows accounts, this is the first decision to revisit.
Two smaller costs, stated rather than discovered. An instanceId is
unique within a session but is re-minted on every reload, because carrying it in the
saved layout would mean a schema bump that empties every furnished room already
sitting in a browser. And the surface is now written down in three places — the
module, /llms.txt, and the instructions the start menu copies to your
clipboard — which will drift. A test holds all three against the surface's own call
names, which catches a rename and not a changed argument. We know. It is in the
record.
How to actually use it
If you are driving it yourself, open the console and start with
await duimstok.ready. duimstok.version is
"1.0"; calls get added within a version and nothing already listed
changes shape without the major moving.
If you want an assistant to drive it, there are two doors. The start menu has a Copy AI instructions button that puts the whole contract on your clipboard, short enough to paste into whatever assistant you already use next to a question of your own — "furnish this room as a home office and show me a photograph" is a reasonable thing to ask it. For something that fetched the site itself, the same contract at length is served at duimstok.gemelyn.com/llms.txt.
What generalises out of all this is smaller than the API and older than agents. If the thing calling you cannot recover from a surprise, do not surprise it: return values rather than throwing, report every fault at once rather than the first, name the field and the value in every message, and hand back the measurement you took rather than the verdict you drew from it. Those four rules cost a few hundred lines here. They are also, as it turns out, what makes an interface pleasant for a person at two in the morning.