HTTP API
What a twin and a served world answer beside the vendor's API. Everything a vendor's SDK does goes through the vendor's own paths; these endpoints are the world's own.
A twin
Every running twin answers these on its own URL, whatever the vendor.
| endpoint | answers |
|---|---|
GET /twin |
the twin's manifest: its vendor, version, protocol, what it models, the matchers its handlers accept, and whether it has a root |
GET /twin/scenario |
the handlers in force, how many times each matched, and the requests none matched |
GET /twin/store/<name> |
a named, deterministic projection of the twin's state. The only way a UI or a tool reads twin state; the names are per twin |
There are no write endpoints beside the vendor's API. State changes through the vendor's paths; behavior changes by editing the handlers file and restarting.
A served world
volter world serve puts a world at http://<host>:<port>/<org>/<world>/. Each twin's vendor
API is under its vendor id, so an app pointed at …/acme/team/github is an app pointed at that
world's GitHub twin, and, when the twin has a root, at GitHub with the keys hidden. The world's
own endpoints are under /-/<org>/<world>/.
GET /<org>/<world>/.well-known/volter-world answers a token holder with the world's attach
manifest: { name, vendors: { <vendor>: <url> }, ca, proxy, env, streams? }. env holds
VOLTER_TWINS_KEY and, for a twin whose client is pointed by an endpoint variable
(TUNNEL_SERVER_URL, QSTASH_URL), that variable at the twin's URL. An app started with
VOLTER_WORLD=<the world's URL> and VOLTER_WORLD_TOKEN=<token> under @volter/world-core/attach,
or by volter-world attach <the world's URL> --token <token> -- <command>, reads it, and its
unmodified SDKs reach the world's twins. A request the app addresses to the world's own paths carries
the token as x-twins-key.
A twin whose clients speak a TCP protocol is reached through a stream: streams names each one
(smtp; planetscale-mysql, PlanetScale's MySQL wire over the same state as its HTTP API) with the
WebSocket that carries it and the variables its client reads (SMTP_HOST/SMTP_PORT…, or
PLANETSCALE_MYSQL_TWIN_URL). The attacher listens on loopback for each and sets those variables to
the listener, so nodemailer, mysql2 or Python's smtplib connects to 127.0.0.1 unmodified
(@volter/world-core/stream-bridge); an app that reads its MySQL URL under another name is given
PLANETSCALE_MYSQL_TWIN_URL there. The WebSocket is /-/<org>/<world>/streams/<id>; its first
message is the world's token (the read token opens no stream), and after it each message is bytes of
the connection, both ways.
Credentials are tokens in the x-volter-token header. A served world has two: the token,
which opens everything, and a read token, which opens only GET on the vendor API and the
endpoints marked below. serve prints both and writes them to .volter/token. The vendor API
also takes the token where an app presents it: in x-twins-key, which the injector sends from
VOLTER_TWINS_KEY so the app's own Authorization stays the vendor's, or as the app's API key
(Bearer <token>, token <token>, or the password half of Basic).
Every answer on the vendor API of a twin that has a root carries x-volter-observed-at: the
instant of the last completed refresh. It is absent until the first refresh completes, so an app
reading the copy can always tell how old the copy is.
The history
| endpoint | read token | answers |
|---|---|---|
GET …/log/<twin>?after=<position> |
yes | the world's log for that twin from a position: what volter world fetch reads |
GET …/checkpoint/<twin>[?at=<position>] |
yes | the twin's state at a position, the latest by default: what clone starts from |
GET …/changesets and GET …/changesets/<name> |
yes | the changesets that landed, with their receipts, verification and approvals, its stored contentHash, and currentHash, the body's hash as it stands (an approval or verification made on another hash is for an earlier version; a stored hash that differs from it is a changeset whose body changed outside the World, which approval refuses) |
GET …/requests?hours=24 |
yes | the world's request report over the period: per twin, the requests by status class, the response time's p50 and p95, and the top routes |
Pushing and deploying
| endpoint | answers |
|---|---|
POST …/push with a changeset as the body |
append the changeset's changes to the world's log if its base is current, else 409 with the drift. Under deploy: auto the receipts are in the answer; otherwise each change is landed |
POST …/changesets/<name>/verify |
run the world's checks over the changeset and record the result |
POST …/changesets/<name>/approve { as, note? } |
sign the changeset's current hash; a browser session signs as the person it was opened for (a platform's pass; this machine on a local World) and its as is not read |
POST …/deploy[/<name>] |
perform landed changes against each twin's root, by its policy; receipts in the answer. A browser's session (no token presented) deploys only what the body names: { "confirm": "<name>" } (the changeset, or the world's name for all), else 400 |
A twin's root
| endpoint | answers |
|---|---|
GET …/twins |
every twin of the world, each as its status endpoint answers it (read token) |
GET …/twins/<vendor>/status |
the twin as a consumer sees it: protocol, root, whether a credential is sealed, the last refresh, the position, the last performed entry's receipt |
GET/PUT …/twins/<vendor>/root |
{ url, deploy, refresh } as in the config; PUT with null clears it |
PUT …/twins/<vendor>/credential |
seal the vendor's real credential. Never readable back; GET answers only that one is sealed, and when. A signingSecret field enables webhook ingest |
GET/PUT/DELETE …/twins/<vendor>/scenario |
a generative twin's scenario: the handlers document it answers its generative surface from, read again on every request, so a PUT is live on the next call. A hosted world keeps it in its own state; a local world writes the file its service declares as colocate.scenarioPath. 404 for a twin that takes none |
POST …/twins/<vendor>/refresh[?force=1] |
observe the root now: the twin observes, the kernel folds what changed; throttled to the root's refresh.atMost (else the twin's) unless force=1 |
POST …/session, DELETE …/session |
a browser session for this world in the presented token's scope: an HttpOnly cookie holding an opaque session id (never the token), which the vendor API and the World's endpoints accept like the token. Sessions last 30 days and end when the tokens rotate; DELETE ends this one |
GET …/origin, PUT …/origin { url, token } |
the world this one branches from: PUT names it (https://<host>/<org>/<world>, the token that opens it) and brings its history in, as volter world clone does |
POST …/origin/pull |
fetch what the origin has and move this branch onto it, naming conflicts by record and field, as volter world pull does |
POST …/changesets { message, name? } |
cut the unpushed changes into a changeset, as volter world changeset does |
POST …/origin/push |
push every unpushed changeset to the origin, oldest first; each answers with its receipts, as volter world push does |
GET …/links, PUT …/links/<name> { origin }, DELETE …/links/<name> |
a link: /<org>/<world>/<name>/… forwards to the vendor at origin (https, a hostname) with the credential sealed as …/twins/<name>/credential, whose headers replace any of the same name the caller sent. Stateless: no log, no tree. The caller presents the world's token |
GET …/checks, PUT …/checks/<name>, DELETE …/checks/<name> |
the world's checks: JavaScript exporting check (or default) as { name, run(entry, tree) }, run over every entry before it is performed. PUT refuses a file that exports no check. A hosted world runs each check in an isolate of its own, with no network and nothing of the world's |
POST …/twins/<vendor>/ingest |
a vendor webhook, verified with the sealed signing secret, folded into the log. Keyless; the vendor's signature is the credential |
Every served world is capped: request bodies to 4 MB, checkpoints to 24 MB, and a request budget
answered with Retry-After.
Looking into a world
What a person steps into a world through: each vendor's own UI, what the world holds, what happened
in it, its clock and its branches. Every host answers these alike (volter world serve,
volter world view, world-host, a hosted world); the console reads nothing else. The model is
Viewing a World.
| endpoint | read token | answers |
|---|---|---|
GET /<org>/<world>/<vendor>/… opened as a page |
yes | the vendor's own screens (the twin's mirror) at that very address, when the twin does not serve that page itself (an OAuth screen): a browser navigation or frame (Sec-Fetch-Dest: document or iframe) that the twin answers with an error or non-HTML gets the mirror's shell, so a mirror's routes are real, reloadable URLs |
GET /<org>/<world>/<vendor>/mirror/ |
keyless | the mirror's shell (opened as a page, it moves to the vendor's address above); mirror/assets/<file> its client. GET only; 404 for a twin without one |
POST …/session, DELETE …/session |
yes | a browser session in the token's scope, { world, scope: "read" | "write", origin? }. Where the host gives the world an origin of its own (<world>--<org>.localhost:<port> locally, <world>--<org><WORLD_ORIGIN_SUFFIX> hosted) the session lives only there, in a host-only cookie, and asked for elsewhere answers 409 { origin }. A request the session carries that is not a read must say Sec-Fetch-Site: same-origin. A World served on this machine's loopback with an origin of its own (volter world view, volter world serve) opens a write session with no token for its own page: POST …/session with nothing presented, a loopback Host, an Origin equal to that origin and Sec-Fetch-Site: same-origin, answered { world, scope: "write", local: true, origin }. A read session browses every mirror: its reads pass, and a twin that enforces the read scope refuses its writes (at any other, its non-GET requests are refused). A named key opens no session, except one that may only read: that is how a shared read-only link opens the World's pages, and its session reads only, lasts no longer than the key and ends when the key is revoked. |
POST …/session with x-volter-pass |
— | a person a trusted platform signed a pass for (--trust, TRUSTED_ISSUERS), from the world's own page (Sec-Fetch-Site: same-origin) and only where the world has an origin of its own: a session of the pass's scope for 12 hours, { world, scope, who, issuer, origin }. A pass is spent once; one for another world or origin, expired or from an untrusted platform answers 401 with the reason |
GET …/keys, POST …/keys, DELETE …/keys/<id>, DELETE …/keys?person=<subject> |
no | the world's named keys, each for what holds it (an app, a CI job), managed with write access by the world's token or a person's browser session, never by a key: POST { name, scope?: "write" | "read" } answers the key (tok_k_…) once with its id; the world keeps only its hash and who made it, and a key opens the vendor API and the World's endpoints as the token of its scope does, never handing back the world's token. With the world's token, POST also takes for (the person it is for), expiresAt and replace: true (the key of that name for that person goes). GET lists { keys: [{ id, name, scope, createdAt, lastUsedAt, expiresAt, for, createdBy }] }; DELETE revokes one alone; DELETE ?person= (the world's token) revokes every key made by or for a person. New tokens (rotate) end every key |
GET …/map |
yes | every twin and what it holds: { twins: [{ twin, position, mirror, root, resources: [{ type, count }] }] } |
GET …/timeline?limit=&before=&twin=&trace= |
yes | the twins' logs merged, newest first, by each entry's occurredAt, then twin, then position: { entries: [{ twin, position, entry }], next }. next is the before cursor of the page after; twin narrows to one twin, trace to one W3C trace id. Entries at one instant are not ordered finer than the clock |
GET …/diff |
yes | this world's own changes since its base, what a changeset would cut: { base: { id, at }, changes: [{ twin, entry }] } |
GET …/clock |
yes | { at, frozen }: the instant every twin stamps from, or the wall clock when none is set |
PUT …/clock { at }, POST …/clock/advance { by: "<N>(s|m|h|d)" } |
set or advance it. 409 when it would move back: before its current instant, or, unset, before the newest entry. Advancing needs a set clock. Twins catch up on their next request |
|
GET …/history?at=<instant> |
yes | each twin's history cut at the instant, { views: { <twin>: { view, position } } }: what a branch as of that instant clones |
GET …/branches |
yes | the host's branches of this world: [{ name, from, at, createdAt, expiresAt }]. 404 where the host makes none (volter world serve) |
POST …/branches { at?: { instant }, ttl?, live?, label? } |
a branch as of the instant (now, without one): another world, cloned from this one's history cut there, its clock frozen at the instant (live: true, for a branch as of now only: its clock keeps this world's time, as a pull request's preview does; label names the branch <world>-<label>-<4 hex>), removed after ttl seconds (60 to 2592000) when given. 201 { name, token, readToken, expiresAt } |
|
DELETE …/branches/<org>/<world> |
remove one of this world's branches |
On the vendor wire, the read token's GET and HEAD pass as before, and anything else is refused, except at a twin whose
manifest names requestScopes: ["read"]: its other requests reach it with x-volter-read-only: 1,
and the twin refuses the ones that write (an append, stored bytes, a git ref) while the vendor's own moves (a renewal falling due) still
happen. A twin records the incoming W3C traceparent on the entries a request causes and carries it,
continued, on the webhooks those entries cause; the timeline's trace follows it.
The hosting product
@volter/world-host serves many worlds under one URL by <org>/<world>, each with its own tokens.
It mounts every bare world under a directory (<dir>/<org>/<world>/, made by volter world init --bare <org>/<world>) and answers the names of its worlds at GET /-/ping, unauthenticated.
Its own endpoints open to the admin token, minted once and kept at <dir>/.volter-host/admin,
printed by volter-host serve. The admin token opens nothing under a world, and a world's token
opens nothing here.
| endpoint | answers |
|---|---|
GET /-/worlds |
every world: { org, world, name, base, owner?, token, readToken, twins: [{ vendor, protocol, root }] } |
GET /-/console/ |
the console, when @volter/world-console is installed beside the host: one page for every world above, reading these endpoints and a world's with the token you type |
POST /-/worlds { org, world, vendors, owner? } |
a new bare world, mounted at once, with its two tokens; owner records the org that owns it, as its platform names the org |
PUT /-/worlds/<org>/<world>/owner { owner } |
record the owning org of a world made without one (a claim); 409 when another org is recorded |
GET /-/vendors |
the twins this host can make a world with: { vendors: [vendor, …] } |
POST /-/worlds/<org>/<world>/rotate |
new tokens; the old ones die with the response |
DELETE /-/worlds/<org>/<world> |
remove the world and its tree |
A world served by the host answers exactly what a world served by volter world serve answers.
Hosted on Cloudflare
apps/cloud is the same hosting product as a Cloudflare Worker: one Durable Object per world, each
world in an isolate of its own, its state in the object's SQLite and its blobs in R2. A world
answers exactly what a served world answers, and the admin endpoints answer as a world-host's do (the same
inventory row, the admin token as x-volter-token or a bearer), so a platform provisions onto either.
The admin token is the Worker's ADMIN_TOKEN secret.
| endpoint | answers |
|---|---|
GET /-/worlds |
every world as an inventory row: { org, world, name, base, owner?, token, readToken, twins: [{ vendor, protocol, root }] } |
POST /-/worlds { org, world, vendors, from? } |
with from: "<org>/<world>", a branch of that world: its origin is the parent, at the parent's current position. A world with branches is not removed |
POST /-/worlds { org, world, vendors, token?, readToken?, owner? } |
a new world, answered as its inventory row (owner: the org that owns it); 409 when the name is taken. token/readToken adopt a credential the world's callers already hold (24-256 URL-safe characters) instead of minting one |
POST /-/worlds/<org>/<world>/rotate |
new tokens, { name, token, readToken }; the old ones are refused from the next request |
PUT /-/worlds/<org>/<world>/owner { owner } |
record the owning org of a world made without one (a claim); 409 when another org is recorded |
GET /-/vendors |
the twins this host can make a world with: { vendors: [vendor, …] } |
DELETE /-/worlds/<org>/<world> |
remove the world: its state, its blobs, its refresh schedule |
PUT /-/worlds/<org>/<world>/import { twin, files } |
place a twin's state files, { ".volter/world/<state>/<file>": content }: moving a v1 twins-cloud namespace into a world |
GET /<org>/<world>/<vendor>/mirror/, …/mirror/assets/<file> |
the twin's mirror page and its bundle, keyless. The page's base is the twin's place in the world, so its reads go to the world's vendor API with the browser's session for that world (POST /-/<org>/<world>/session) |
A rooted twin refreshes on the world's own schedule (root.refresh.every, else the twin's), and its
sealed credential is wrapped by the Worker's SEALING_KEY secret. A Worker accepts no inbound TCP
connection, so the TCP twins (smtp, PlanetScale's MySQL wire) are reached through their streams; a
twin that answers a WebSocket upgrade on its vendor API (tunnel's control socket and its visitors'
sockets) answers it here too. A world names the code it ran on in
x-volter-world-code on the answers of its vendor API and endpoints (not on an upgrade). Not hosted: the real engines a twin can run locally beside its twinned control
plane (fly's Docker execution plane, livekit's server and egress), which a hosted world serves
virtually.