Skip to main content
Version: 0.7 (Next)

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.

Alpha

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

  1. 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 PORT environment variable (default 8080) and serves the frontend.

    Bundle your assets into the build output

    Buildpacks 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 ./static from source will build fine and then return 404 for everything in production, which the platform's health probes turn into a crash loop.

  2. Serve a single-page frontend at /. No server-side theme contract logic is required — the theme calls the contract through theme.js in the browser.

  3. Include theme-manifest.yaml at the repository root — the theme's full self-description, defined in the theme specification.

  4. Include THEME.md at 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:

  1. Each operation is posted to the platform window as { type: "theme-rpc", id, op, args }. The request carries no secrets.
  2. 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.
  3. 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's true, 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.

v1 themes stop working

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.

Escape everything the API gives you

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.

theme.js is verified byte-for-byte

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.

The single-org rule

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.

ConcernOperationNotes
List appstheme.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 repositoriestheme.appRepos(org)what the org may ship; use for repository pickers
Ship an apptheme.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 apptheme.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 apptheme.deleteApp(org, name)
Redeploytheme.redeploy(org, name)fresh build from branch HEAD
Build logstheme.buildLogs(org, name)poll while building
Deployment historytheme.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
Metricstheme.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 quotatheme.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 environmentstheme.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 environmenttheme.createZone(org, zone){name, display_name, cost_center}
List regionstheme.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 environmenttheme.deleteZone(org, name)empty environments only — answers 409 while apps still target it
Charactertheme.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.

ConcernOperationNotes
Runtime logstheme.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 eventstheme.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:

  1. 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
  2. Copies theme.js from the starter verbatim and routes every platform call through it; renders a welcome/sample mode when theme.isLaunched() is false
  3. Calls discovery first and renders only the capabilities it declares
  4. Maps every contract term through the manifest's vocabulary — zones/apps/replicas/shop/character never appear in user-facing copy under their contract names
  5. Renders zone capacity as the metaphor demands (a pit garage filling with cars), using allocated / capacity from zone status, and words every app phase through the manifest's phaseWordsFailed stays findable
  6. Ships apps with the fit rule previewed before submitting and handles 409/Failed states in-theme
  7. Celebrates the ship — the manifest's shipMoment, skippable and non-blocking, with the Friday-ship variant
  8. Writes progression to the character ledger under <theme>. -prefixed keys only
  9. Includes theme-manifest.yaml plus a THEME.md whose header matches it
  10. Never hardcodes bands, prices, or org names — everything comes from discovery
  11. 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

  1. Theme specification — profiles, the manifest, and the conformance checklist
  2. The game formula — what makes a game theme work
  3. Themes — the model and trust boundaries
  4. The theme contract — zones, bands, and payment concepts your theme renders