Creating a theme
This page is the authoring contract for theme contract themes. It is written to be sufficient on its own: a person — or an AI assistant pointed at this page — can build a working theme repository from it without reading any source code.
The contract below ships incrementally on the 0.7 line. The transport is v2 (postMessage); response shapes are versioned in the discovery document.
What a theme is
A theme is a static frontend in a Git repository that renders an organization's zones and apps under its own metaphor. The platform builds it with Cloud Native Buildpacks, deploys it into one of the organization's zones, and serves it at a URL. It runs in a sandboxed, cross-origin iframe and never receives a credential — it acts as the signed-in user via a broker in the Konstruct shell, and can do nothing that user couldn't.
Don't want to hand-author one? The Forge — a fill-in-the-blank builder reachable from Themes — generates a working theme repository from a short description in minutes. See Themes. Everything below still applies to what it generates; this page is what to check if you're refining the result or building from scratch.
Repository requirements
-
Buildable by Cloud Native Buildpacks with no Dockerfile. Any detected stack works (Node, Go, static + Procfile). The build must produce a server that listens on the
PORTenvironment variable (default 8080) and serves the frontend.Bundle your assets into the build outputBuildpacks strip source files from the run image — only the build artifact ships. A Go server must embed its frontend with
go:embed; a Node server must serve from its bundler's output directory. A server that reads./staticfrom source will build fine and then return 404 for everything in production, which the platform's health probes turn into a crash loop. -
Serve a single-page frontend at
/. No server-side theme contract logic is required — the theme calls the contract throughtheme.jsin the browser. -
Include
theme-manifest.yamlat the repository root — the theme's full self-description, defined in the theme specification. -
Include
THEME.mdat the repository root. Its header block is a faithful subset of the manifest:
# THEME
version: v2
theme: racetracks
capabilities: [apps, zones, shop]
vocabulary:
zone: { singular: race team, plural: race teams, verb: found }
app: { singular: car, plural: cars, verb: race }
deploy: { verb: send to the grid }
The contract transport (v2 — postMessage)
Your theme never receives a credential. Konstruct launches it in a sandboxed iframe at:
https://<your-theme-host>/?org=<namespace>
Copy static/theme.js from the starter theme verbatim — at that exact path — and call its methods; everything below is what it does for you:
- Each operation is posted to the platform window as
{ type: "theme-rpc", id, op, args }. The request carries no secrets. - Konstruct validates the sender, allows only theme contract operations, pins every org-scoped operation to the org you were launched for (an org you pass is ignored), performs the API call with the signed-in user's session on its own origin, and replies
{ type: "theme-rpc-result", id, ok, data | error, status }to your theme's origin. theme.isLaunched()is false when the theme is opened directly (no parent window). Render a welcome or sample-data mode — the popout link from Konstruct is deliberately unauthenticated. When it'strue, render your real app view immediately and load data underneath it — never gate the view behind a button; the user already clicked once to get here.
Allowed operations: discover, zones, createZone, apps, appRepos, deleteZone, shipApp, updateApp, deleteApp, redeploy, buildLogs, deployments, metrics, quota, regions, character, saveCharacter — plus the logs and appEvents streams below.
The v1 handshake (bearer token in the URL fragment, direct API fetch) is removed along with theme-origin CORS. Migrating is one step: replace your copy of theme.js with the starter's v2 version at static/theme.js — the method signatures are unchanged.
Names, repository URLs, branch names, env values, and status messages are user- and repository-controlled strings, not trusted HTML. Escape every API-derived value before it touches the DOM — treat innerHTML and template-literal HTML injection from contract data as a bug, not a shortcut.
The platform checks static/theme.js against its own reference copy at build time. A theme that edits it — even harmlessly — is treated as forged and fails to register. If you need behavior the client doesn't have (seeding sample data, wrapping a call with extra logic), wrap it in your own module instead of touching the file. Ship It! does exactly this with a small theme-bridge.js alongside an otherwise unmodified theme.js — the pattern to copy if you're wiring an existing frontend to the theme contract rather than writing one from scratch.
A theme never selects an organization — it receives one from the launcher (?org=) and manages only that organization's resources. Do not build org pickers, org lists, or cross-org views into a theme; the Konstruct shell owns that.
Bootstrapping: discovery first
Make theme.discover(org) your first call — everything a theme needs to know arrives in one document:
{
"version": "v1",
"org": { "name": "platform", "display_name": "platform" },
"capabilities": ["apps", "zones", "themes", "character", "runtime-logs", "quota", "app-events", "volumes", "custom-domains"],
"app_sizes": [
{ "key": "s", "cpu": "1", "memory": "1Gi" },
{ "key": "m", "cpu": "2", "memory": "2Gi" },
{ "key": "l", "cpu": "4", "memory": "4Gi" }
],
"rates": {
"currency": "USD",
"vcpu_hour_usd": 0.01, "gb_hour_usd": 0.005, "egress_gb_usd": 0.09,
"storage_gb_month_usd": 0.10, "platform_seat_month_usd": 15
}
}
Render sizes and prices from this document — never hardcode them; app_sizes is already filtered to the org's plan entitlement, and zones have no bands or per-zone prices (the org-wide quota is the only capacity gate). rates carries the metered unit prices. The document's version field tracks the discovery schema itself — it is independent of the v1/v2 transport generation, so "version": "v1" here is not a contradiction.
The contract operations
Every operation is a theme.js method. All payloads use snake_case JSON; org is always the launched organization.
| Concern | Operation | Notes |
|---|---|---|
| List apps | theme.apps(org) | each app: name, app_name, repo_name, zone_ref, size, replicas, volume, custom_domain, status.phase, status.url, status.url_ready, status.domain_token, status.domain_verified |
| List registered repositories | theme.appRepos(org) | what the org may ship; use for repository pickers |
| Ship an app | theme.shipApp(app) | {app_name, repo_url, repo_name, branch, zone_ref, size, port, replicas, env, public_url_enabled, volume {size, mount_path}, custom_domain} — the platform sets namespace to the launched org. Ship into an environment: set zone_ref and omit environment (the zone IS the environment; the platform mirrors it). Registering without an environment is rejected |
| Update an app | theme.updateApp(org, name, body) | branch, replicas, size, env, public_url_enabled, volume: {size, mount_path} (single persistent disk at an absolute path such as /data; locks the app to 1 replica), custom_domain (empty string clears) |
| Remove an app | theme.deleteApp(org, name) | |
| Redeploy | theme.redeploy(org, name) | fresh build from branch HEAD |
| Build logs | theme.buildLogs(org, name) | poll while building |
| Deployment history | theme.deployments(org, name) | real builds, newest first (max 20): build, ref, phase (Succeeded/Failed/Building), revision, reason (COMMIT, TRIGGER, CONFIG, …), image, started_at, finished_at. Never invent history — render this or nothing |
| Metrics | theme.metrics(org, name, {range, step}) | series: cpu, memory, pods, restarts, cpu_limit, memory_limit, network_rx, network_tx. The *_limit series are ceilings to draw usage against |
| Org quota | theme.quota(org) | {plan, capped, cpu|memory|storage: {used, limit}} — the org-wide allowance meter. Quota is admission-enforced; show friendly copy when a ship or update is rejected for it |
| List environments | theme.zones(org) | zones are logical environments — every app ships into one. Each: name, display_name, free, enabled, status.phase, status.apps. Ordering is a contract guarantee: creation order, oldest first — render the list as given so every theme agrees on environment order |
| Create an environment | theme.createZone(org, zone) | {name, display_name, cost_center} |
| List regions | theme.regions(org) | managed workload clusters an app can target: each {name}. Offer as the region choice when registering; empty means the platform picks |
| Remove an environment | theme.deleteZone(org, name) | empty environments only — answers 409 while apps still target it |
| Character | theme.character(org) / theme.saveCharacter(org, spec) | the signed-in user's own progression ledger; save replaces the spec |
Live streams
Two operations push events instead of resolving once. Each returns an unsubscribe() function; the optional onClose(reason) callback fires when the platform ends the stream — resubscribe to reconnect. At most 3 streams can be open at once.
| Concern | Operation | Notes |
|---|---|---|
| Runtime logs | theme.logs(org, name, onLine, onClose) | live log lines from the app's pods, {pod, time, line} per event. Lines without a pod are stream notices — style them differently |
| App events | theme.appEvents(org, onChange, onClose) | one event per app create/update/delete in the org. Fetch apps() again on each event instead of polling |
Feature detection
discover().capabilities lists what the platform implements. Only offer a feature when its flag is present: runtime-logs, quota, app-events, volumes, custom-domains (plus the original apps, zones, themes, character).
Custom domains
After saving custom_domain, the app's status.domain_token is the TXT ownership proof. Show the user the record to add — TXT _konduit-challenge.<domain> with value konduit-verify=<token> — and status.domain_verified flips true automatically once DNS resolves.
URL readiness: status.url_ready is true once the platform verified the URL fetches end-to-end from the public internet (public-resolver DNS, valid TLS, programmed route). Never render status.url as a link while url_ready is false — show a "propagating" state instead; an early click caches a DNS failure on the user's resolver for up to the zone's negative TTL. A platform that omits the field predates the verdict — keep the link in that case.
App phases: Building → Pushing → Deploying → Live, terminal Failed (read status.message). Subscribe to appEvents (or poll the app list roughly every 15 seconds) while any app is non-terminal, and stop once everything is terminal.
Capacity: the org-wide plan quota is the only capacity gate — zones have no bands, prices, or per-zone limits. discover().app_sizes is already filtered to the org's plan entitlement; build size pickers from it and never hardcode sizes.
Character ledger: keys are theme-namespaced (racetracks.q1, racetracks.driver.helmet). xp is global across themes — read it, add to it, never reset it.
Registering your theme
An organization admin registers the repository under Platform as a Service → Themes (or POST /theme/themes/{org} with {name, display_name, repo_url, repo_name, branch}). The platform builds it, serves it, and adds it to the org's launcher. Watch phase become Live and you're running.
For AI assistants: the generation checklist
Given a brief like "my theme is racetracks: race teams are zones, cars are apps, car count is replicas, and there's a racecar driver character", first express the brief as a theme-manifest.yaml (the blanks map 1:1 onto manifest fields — see the theme specification), then produce a repository that:
- Contains a buildpack-detectable frontend (a static site with a tiny Node/Go server is the safest shape) listening on
PORT, assets embedded in the build artifact - Copies
theme.jsfrom the starter verbatim and routes every platform call through it; renders a welcome/sample mode whentheme.isLaunched()is false - Calls discovery first and renders only the capabilities it declares
- Maps every contract term through the manifest's vocabulary — zones/apps/replicas/shop/character never appear in user-facing copy under their contract names
- Renders zone capacity as the metaphor demands (a pit garage filling with cars), using
allocated / capacityfrom zone status, and words every app phase through the manifest'sphaseWords—Failedstays findable - Ships apps with the fit rule previewed before submitting and handles
409/Failedstates in-theme - Celebrates the ship — the manifest's
shipMoment, skippable and non-blocking, with the Friday-ship variant - Writes progression to the character ledger under
<theme>.-prefixed keys only - Includes
theme-manifest.yamlplus aTHEME.mdwhose header matches it - Never hardcodes bands, prices, or org names — everything comes from discovery
- Passes the full conformance checklist
A Konstruct stack also exposes this contract through its MCP server, so an assistant connected to the stack can introspect the live API while generating the theme.
What's next
- Theme specification — profiles, the manifest, and the conformance checklist
- The game formula — what makes a game theme work
- Themes — the model and trust boundaries
- The theme contract — zones, bands, and payment concepts your theme renders