Volter World

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.

endpointanswers
GET /twinthe twin's manifest: its vendor, version, protocol, what it models, the matchers its handlers accept, and whether it has a root
GET /twin/scenariothe handlers in force, how many times each matched, and the requests none matched

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 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 the injector cannot stand in for, 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) with the WebSocket that carries it and the variables its client reads (SMTP_HOST/SMTP_PORT…). The attacher listens on loopback for each and sets those variables to the listener, so nodemailer or Python's smtplib connects to 127.0.0.1 unmodified (@volter/world-core/stream-bridge). 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.

Browsers

These endpoints share the world's existing credentials. Reads take read or write access; mutations take write access. Their replies are not cached. Names are 1–24 lowercase letters, digits or single hyphens, starting and ending with a letter or digit. Sites whose browser labels would exceed 63 characters are skipped; a hosted browser is refused only when no site fits, with the reason.

endpointanswers
GET …/browsers{ browsers: [<name>, …] }, names sorted
GET …/browsers/<name>the browser profile below, or 404
PUT …/browsers/<name>replace the whole state; Content-Type: application/json required; returns that state. Invalid state/name, a hostname collision or no fitting site label is 400; a body over 1 MiB is 413; creating a 33rd browser is 409
DELETE …/browsers/<name>remove it and its local file; 204, or 404 if absent
type BrowserStorageState = {
  cookies: {
    name: string; value: string; domain: string; path: string; expires: number;
    httpOnly: boolean; secure: boolean; sameSite: 'Strict' | 'Lax' | 'None';
    partitionKey?: string; _crHasCrossSiteAncestor?: boolean;
  }[];
  origins: { origin: string; localStorage: { name: string; value: string }[];
    indexedDB?: { name: string; version: number; stores: {
      name: string; autoIncrement: boolean; keyPath?: string; keyPathArray?: string[];
      records: { key?: unknown; keyEncoded?: unknown; value?: unknown; valueEncoded?: unknown }[];
      indexes: { name: string; keyPath?: string; keyPathArray?: string[]; multiEntry: boolean; unique: boolean }[];
    }[] }[];
  }[];
};
type BrowserContext = {
  permissions?: string[];
  geolocation?: { latitude: number; longitude: number; accuracy?: number };
  locale?: string; timezoneId?: string; userAgent?: string;
  viewport?: { width: number; height: number } | null;
  deviceScaleFactor?: number; isMobile?: boolean; hasTouch?: boolean;
  colorScheme?: 'light' | 'dark' | 'no-preference' | null;
  reducedMotion?: 'reduce' | 'no-preference' | null;
  extraHTTPHeaders?: Record<string, string>; offline?: boolean;
};
type BrowserState = { storageState: BrowserStorageState; context: BrowserContext };

context is the profile subset of Playwright 1.61.1's BrowserContextOptions, with its names and value shapes. Unknown profile and context keys are refused, including execution options such as proxy, base URL, credentials and recording. Geolocation latitude is between -90 and 90, longitude between -180 and 180, and optional accuracy is nonnegative; numbers are finite. Viewport dimensions are integers; a null viewport cannot be combined with deviceScaleFactor or isMobile set to true. Headers have string values valid for HTTP; locale and userAgent are also validated as header values. Missing context reads as {}. A legacy top-level { cookies, origins } object is accepted as storageState and returned inside the profile. Playwright storageState has no session storage in this version.

Unknown storageState schema fields are refused, as are duplicate cookies, origins or local storage names. Domains and origins are the vendor's real ones; an origin is canonical HTTP or HTTPS, with no path. expires is Unix seconds or -1 for a session. Cookie values may contain spaces; controls, semicolons and characters above U+00FF are refused. Cookie paths also require header byte characters. Optional partitionKey, Chromium ancestor metadata and IndexedDB snapshots are carried unchanged; IndexedDB schema fields are validated and its encoded record values remain opaque. An empty starting browser is { "storageState": { "cookies": [], "origins": [] }, "context": {} }. The seed uses this same loader. Locally the current World writes the whole profile in .volter/browsers/<name>.json, independent of the env output. Load it with browser.newContext({ ...profile.context, storageState: profile.storageState }), adding the World's proxy and CA for execution. Browser bookkeeping is inherited by a branch and recreated by reset; it is outside the log, diff, changesets and push.

GET …/sites/go?host=<real-host>&browser=<name>&land=<path> opens the named browser's site through the existing page access flow. Hosted addresses are <browser>--<site>--<world>--<org><suffix>; local view addresses are <browser>--<site>.<world>--<org>.localhost:<port>, where <site> is the real host with dots changed to hyphens. Sites with overlong browser labels are skipped; PUT refuses the browser only if no site fits. A name colliding with a claimed site label or another browser address is refused at PUT. Exact claimed-site labels take precedence when routing, including sites claimed after the browser was made. First navigation loads matching cookies host-only and local storage from the first real origin with that host (including a port), once per state version. On replacement, previously loaded cookie names expire at their recorded paths. Each version retains those paths for all contexts; deletion keeps the paths for the name's next state, without cookie values. Hosted bootstrap loads cookies and local storage; native Playwright consumers can load IndexedDB. The World applies the profile's Accept-Language from extraHTTPHeaders to forwarded requests, falling back to locale. Other context settings, including permissions, time zone, location and device emulation, take effect where a browser is created from the profile, not inside a board frame. Relabelled sites keep independent copies; the proxy and CA at real vendor hostnames preserve real cookie domains. After loading, ordinary requests go to the twin at the caller's World access scope, resolving the real host and path by the proxy's rules. The page's bearer authorization is retained; redirects outside the World are unchanged. On a browser site, GET/HEAD navigation admits same-origin, same-site or none; other requests require same-origin. The site-opening endpoint with browser admits same-origin and none only. WorldDoors and the HTTPS redirect and reflection fronts strip client-supplied browser routing headers before forwarding; the site-link helper ignores an invalid browser header. A page on one relabelled site calling another site of the same browser has no page-access session there and is refused. Each site opens through its own World page pass.

GET …/board lists a page for each browser whose address can be made, including a signed-out browser. If any browser address cannot be made, it also lists one plain copy, as it does with no browsers. A frame labelled with a browser opens at that browser's site address and loads that state. Saved browser-less frame and section keys alias only the first browser by name; a plain fallback frame retains its own key. Other browser copies are new frames. Browser frame keys start ~browser:<browser>:<vendor>:, so colon-containing frame ids cannot collide with ordinary vendor-prefixed keys. The console groups and labels pages by browser name, including hidden entries. See Seed and reset.

The history

endpointread tokenanswers
GET …/log/<twin>?after=<position>yesthe world's log for that twin from a position: what volter world fetch reads
GET …/checkpoint/<twin>[?at=<position>]yesthe twin's state at a position, the latest by default: what clone starts from
GET …/changesets and GET …/changesets/<name>yesthe 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), readiness: whether it may be deployed under this World's rules, with approvers (the different people whose approvals bind to it; a key's counts for its person), required and the reasons it may not, and deployed: each action's receipt on its log entry, one { service, actionId, status, at, externalId?, url?, reason? } per action that has one (deployed = performed against the vendor, landed = on the parent and waiting, refused, failed, skipped; a settled receipt wins over a landed one). A deploy writes these receipts; so does a push, on the pushing World (landed while the remote waits, deployed when it performed it), beside the changeset's own applied (replayed at the remote, confirmed when the remote performed it)
GET …/requests?hours=24yesthe world's request report over the period: per twin and per link, the requests by status class, the response time's p50 and p95, and the top routes; journalFailures, the journal lines this World's server failed to keep since it started, for its links and the twins it runs in its own process (a twin in a process of its own says its failures on its stderr) (an audit gap, said, never silent)

Pushing and deploying

endpointanswers
POST …/push with a changeset as the bodyappend 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. The answer and the kept changeset record who pushed it (pushedBy: a person, a key, or token), and owner, the pusher's person when it arrived with none and the pusher names one; the owner and cutter it arrives with are the sending World's record. A changeset landed once keeps its first record
POST …/changesets/<name>/verifyrun 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; on a local World the person signed in with volter login, else this machine) and its as is not read; a key signs as itself (key:<name> for:<person>), and readiness counts it for that person; the World's token signs as its as
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

Opening a seal (PUT …/twins/<vendor>/root, PUT …/twins/<vendor>/credential, PUT or DELETE …/links/<name>, PUT or DELETE …/checks/<name>, since a local World runs its checks in its own process, and POST …/credentials/rewrap) takes the World's token or a person's session at its page; a key (an app's, a script's) is answered 403.

endpointanswers
GET …/twinsevery twin of the world, each as its status endpoint answers it (read token)
GET …/twins/<vendor>/statusthe twin as a consumer sees it: boot-recorded identity (mounted package/version and pinned source), execution (real-root connection and deploy policy), recorded dataOrigin, protocol, the workspaces it lists (screens), 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>/credentialseal the vendor's real credential, bound to where it goes: the twin's root origin, else the link of that name (409 with neither). It opens for that origin only: a root or link pointed elsewhere refuses it until it is set again. Never readable back; GET answers only that one is sealed, when, by what (sealedBy) and for where (boundTo). A signingSecret field enables webhook ingest
POST …/credentials/rewrapafter the vault key that wraps this World's credentials is rotated, wrap each one's DEK again under the newest version (the vault's transit/rewrap: the DEK never leaves it): { rewrapped, current, left } name the vendors and links that moved, those already current, and those it left: sealed under a host key or by another vault key, a wrapped key whose version could not be read, a record sealed again while it was rewrapped (never overwritten), or one that does not read. Only current says a record needs no old version. The World's write access; a key is answered 403
GET/PUT/DELETE …/twins/<vendor>/scenarioa 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 …/sessiona 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; GET answers { origin, ahead: { changes, changesets }, behind: { <twin>: n }, asOf }: the changes not cut and the changesets not pushed, and per twin what the last fetch brought that this branch has not taken, as of that fetch (asOf, the origin's fetchedAt), as git's ahead and behind (a changeset a push applied in part counts as pushed, as the push endpoint's queue does); a World with no origin answers { origin: null }
POST …/origin/pullfetch what the origin has and move this branch onto it, naming conflicts by record and field, as volter world pull does
GET …/rules, PUT …/rules { changesets: { approvals?, summary? } }how this World's changesets are made and readied (its config's changesets): read with any access; changed with write access by a person's session or the World's token, never a key (403)
POST …/changesets { message, name? }cut the unpushed changes into a changeset, as volter world changeset does; message is required unless the World's rules generate the summary, and a cut with none and nothing to cut is refused (400); it records who cut it (cutBy: key:<name> for:<person>, person:<who>, machine, person, token) and its owner, the person the cutting grant names
POST …/origin/pushpush 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. No tree; each forward is journaled as a twin's request is, under the link's name (…/requests, volter-world tail --requests). 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>/ingesta 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.

The status identity is boot evidence, not a fresh lookup of installed dependencies. identity.mounted is null when the host has no recorded package name; a recorded package may have a null version. identity.pinned names the selection at boot, so editing a config does not rewrite a running host's identity. execution.rootConnected and deploy describe the configured real root, not proof that a vendor operation was performed. dataOrigin is shared-history when origin history is recorded, vendor-refresh when a refresh marker is recorded, otherwise unrecorded; absence of provenance never establishes synthetic data. Older hosts may omit these additive fields.

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.

endpointread tokenanswers
GET /<org>/<world>/<vendor>/<path> opened as a pageyesthe vendor's own screens: each workspace the twin's GET /twin lists (screens: [{ id, path, host? }], the twin's built workspace screens) is answered by the twin at its path under the twin's place
POST …/session, DELETE …/sessionyesa 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 vendor's screens: 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>nothe 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, by the subject that revokes it), forName with it (that person as attribution names them), expiresAt and replace: true (the key of that name for that person goes). A name holding for: is refused: the caller a write records reads key:<name> for:<person>. GET lists { keys: [{ id, name, scope, createdAt, lastUsedAt, expiresAt, for, person, createdBy }] }, person the name attribution uses; 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 …/mapyesevery twin and what it holds: { twins: [{ twin, position, screens, root, resources: [{ type, count }] }] }
GET …/timeline?limit=&before=&twin=&trace=yesthe twins' logs merged, newest first, by each entry's occurredAt, then twin, then position: { entries: [{ twin, position, entry, caller? }], next }. caller is who made the request the entry was written in, as the World named it on the way to the twin (key:<name>, person:<who>, token, token:read); what the vendor makes on its own while serving it (an entry whose actor is the system's, a due renewal) carries none: the World replaces any x-volter-caller a caller sends and continues the request's trace (or starts one) under a parent-id it mints, which joins the entry to the twin's request journal line; the twin's handler never sees the header. 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 …/diffyesthis world's own changes since its base, what a changeset would cut: { base: { id, at }, changes: [{ twin, entry }] }
GET …/clockyes{ 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>yeseach twin's history cut at the instant, { views: { <twin>: { view, position } } }: what a branch as of that instant clones
GET …/branchesyesthe 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.

endpointanswers
GET /-/worldsevery 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 /-/vendorsthe twins this host can make a world with: { vendors: [vendor, …] }
POST /-/worlds/<org>/<world>/rotatenew 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.

endpointanswers
GET /-/worldsevery 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>/rotatenew 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 /-/vendorsthe 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

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 transit key of the org slug it is served under (<VAULT_TRANSIT_KEY>-org-<slug>) when the deployment names a vault (VAULT_ADDR, VAULT_TOKEN, VAULT_TRANSIT_KEY, optionally VAULT_TRANSIT_MOUNT, VAULT_FINGERPRINT_KEY (default <VAULT_TRANSIT_KEY>-fingerprint, one transit key for every org, never rotated) and VAULT_NAMESPACE; company decision 0034), else by the Worker's SEALING_KEY secret. A record sealed under SEALING_KEY is sealed again by the vault the first time it opens (a root's credential, a link's, a signing secret); GET …/credential says sealedBy (vault or key), and SEALING_KEY can be removed once no record says key. 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) 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), which a hosted world serves virtually, and a vendor's own server a local World runs itself (livekit-server), which a hosted world does not run.

View Markdown source

On this page