Skip to main content
A world with routes can serve a page over them. The page is one more view of the same session state: a row created in a form is the row the API lists and the row the grader scores. The shipped connector templates declare a UI, so a world made from one has a page as soon as it compiles. A world you wrote yourself gets one with a single command, or serves a frontend you built with any framework: the world holds static files, and the host serves them.

Declare it

[ui] lives in connector.toml, the file that already declares the world’s routes.
[browser] is optional and defaults to enabled with a 1280x800 viewport; enabled = false turns the browser surface off for the world.

Generate it

ui init adds the [ui] section when connector.toml has none and compiles. Compile writes three files into ui/static/: gateway worlds schema compile rewrites the three files whenever the contract changes, so the page shows the current schema. ui init needs a connector.toml, which declares the routes the page calls. The page renders what the routes serve:
  • Home lists every entity with the route behind each verb.
  • List is the entity’s GET list route, with the world’s own query parameters as filters and the world’s paging (cursor or offset) for the next page.
  • Detail is the GET by id. A foreign key links to the related entity’s detail view.
  • New and Edit are the POST and PATCH/PUT routes. The form asks for the fields the route’s body mapping names, or every field the server does not mint itself. Enums are selects, booleans and numbers are typed, nested objects are JSON. A handler route may carry a body mapping too (body = { channel_id = "channel", text = "text" }): the handler reads the request itself, the mapping tells the form which fields to ask for and what the vendor calls them. A 2xx that carries no row (Slack’s {"ok": false, "error": ...}) keeps the form up and shows the answer.
  • Delete is the DELETE route.
  • Every view prints the request it made and the status it got, so an agent reading the page sees the API call behind it.

Bring your own frontend

Any frontend that builds to static files is a world UI: React, Vue, Svelte, Angular, Solid, plain HTML, a WASM app. The host serves files and rewrites one API prefix; it does not care what produced them.
  1. Set generated = false so compile never touches the directory.
  2. Build into static with index.html at its root: Vite build.outDir, Create React App BUILD_PATH, Angular outputPath, all pointed at ui/static; a Next.js output: "export" build is copied there from out/. Keep the build’s default base of /.
  3. Call the API at api_path on the page’s own origin with plain fetch, no credential.
That is all. The page is served at the root of its own origin, on the host and locally, so absolute asset URLs (/assets/index-3c4d.js) resolve. A path that names no file and has no extension returns index.html, so a client-side router’s deep links load. Hashed files under assets/ are cached as immutable; everything else is revalidated on every request. Export a frontend that renders on a server (Next.js without output: "export", a Django or Rails view) as static files, or keep the server outside the world and point it at the session’s API URL. The slack template is a worked example. Its ui/static is a Vite + React build of a Slack client, copied in unchanged, and connector.toml declares the routes that client calls the way it calls them: every Web API method accepts POST as well as GET (methods = ["GET", "POST"]), /events answers the page’s EventSource with the events since a sequence number, and /avatars/{name} serves the profile images the seed names as /avatars/ada_72.png. Assets a frontend loads through api_path (avatars, files, thumbnails) are routes, not files under static: the host rewrites the prefix onto the world’s API, so a [[operations]] handler has to answer them.

How the page authorizes

The page holds no credential. A browser cannot set an Authorization header on a page load, so the World Host admits the UI by cookie:
  1. The first page load carries the session token as ?token=, the URL session open prints as ui.entry.
  2. The host answers with an HttpOnly cookie and a redirect to the same page without the token.
  3. Every request after that (pages, assets, calls to api_path) rides the cookie. The host strips api_path and answers the route as it would for a caller holding the session token.
Each api_path call lands in the session’s call log with via: "ui", so a grader can require that a call went through the page’s door (see the call log). It records the door, not the client: a script holding the session token can call the same prefix. Under gateway worlds serve the page has its own port (--ui-port, by default the one after --port) with the same layout as the host: the page at / and its api_path calls answered with the local pin. The same files are at /__ui/ on --port. The bare routes still require the key, and the prefix answers the page only: a browser call from another site is refused. Only the UI port’s prefix counts as via: "ui"; the same prefix on --port is the API origin and logs api.

Open a session with it

The descriptor adds ui.url, ui.entry (with the one-time token) and browser.wsUrl. ui without browser serves the page for a browser you run yourself; browser needs ui.
browse drives the hosted browser when the session has one and a local Playwright otherwise, and prints what the agent would read. From TypeScript, Drive a world UI covers the same session. A schema world that declares [ui] opens on the World Host with tools, api, ui and, when asked for, browser.

Verify

Ship the checks as world tests: an HTTP case on /__ui/ and one on /__world-api/<route> with "auth": false, and gateway worlds test runs them with the rest.

Where to go next