# Hosting — what you can do

<!-- GENERATED by ops/tools/skill-sync — do not edit by hand. Run pnpm skills:sync. -->

<!-- GENERATED by ops/tools/skill-sync — do not edit by hand. Run `pnpm skills:sync`. -->

# Hosting — what you can do

`/admin/d/hosting` · pillar `imperium` · entitlement `imperium.hosting`

Build, publish and serve websites - pages, appearance, SEO, domains, DNS and edge rules.

## Fetch this, never remember it

These change per install and per release. Call for them - a list written here would be a future lie.

| What | Call | Why |
|---|---|---|
| render modes (what a site CAN be) | `list_modes` | `GET /index/modes` | grows every release, and a tenant can add its own `x-` modes - any list written down is a future lie |
| one mode's exact data contract | `get_mode_schema` | the fields a mode accepts are its own; guessing them produces a payload that validates as empty |
| theme presets | `GET /api/hosting/theme-presets` | presets and the custom-CSS capability key are read together, per install |
| this operator's sites and their status | `list_my_sites` | `GET /api/hosting/sites` | never assume a site exists, and never assume its status - `draft` and `published` behave differently |
| what this plan allows and how much is left | `GET /api/hosting/access` | returns the live capability plus used/remaining, so a quota refusal can be predicted instead of hit |

## How to actually do things here

Written by hand, because the order and the traps are the part no file can derive.

### Create a website and publish it

1. `GET /api/hosting/access` first - it says whether this operator may host at all and how many sites are left.
2. Choose the mode by CALLING, never from memory: `list_modes` for the catalog (each row carries a `group`: core, content, app, distribution, commerce), then `get_mode_schema(mode)` for the exact fields and a starter template.
3. `POST /api/hosting/sites` with `{id, appName, mode, data}`. The `id` IS the subdomain. The site is created `draft`.
4. Edit the home page with `set_site_content` ({appName, mode, data}); it updates the preview only.
5. `save_site` persists. `save_site({publish: true})`, or `POST /api/hosting/sites/:id/publish`, takes it live.

**Trap:** Publish re-validates the site data AND every page before it will go live - a draft may hold work in progress, a published site may not. A failure comes back as 400 `invalid_routes`, which is about a PAGE, not the home page.

### Add pages to a site

1. `list_pages` to see what is there. Paths are absolute: `/`, `/menu`, `/blog/:slug`.
2. `add_page` with a `componentKey`. The usual one is `mode:<type>` - a whole render mode used as a page, whose `props` ARE that mode's data payload, so call `get_mode_schema(<type>)` first.
3. `set_page_content` to edit one, `remove_page` to drop one. All three write immediately.
4. Set `guard` to `public`, `auth` or `membership` to decide who may see it.

**Trap:** [critical] The HOME page and every OTHER page are different lanes. Home is `config.mode` + `config.data` and needs `save_site`. Every other page is a route that persists on write. Sending `routes` or `components` to `PUT /api/hosting/sites/:id` answers 400 `use_route_lane`.

### Restyle a site without touching its content

1. `get_appearance` returns the current look AND the full contract - every field and its allowed values. Call it before writing.
2. `list_theme_presets` names the whole-site looks a site can start from.
3. `set_appearance` takes only what changes; pass `null` for a key to clear that key.
4. `save_site` to persist - appearance is editor state until then.

**Trap:** Every non-colour field is a closed enum, so an invented value is refused rather than approximated. `appearance: null` CLEARS the whole block and lets the tenant default show through; `undefined` leaves it alone. Never write `config.theme` - it is legacy and operator-only.

### Point a host at something the user already runs

1. This is the `target` axis, not the mode axis. Open the site editor's Serves tab.
2. Pick the kind: `proxy` (an origin they run elsewhere), `bundle` (a build they upload to this site), `redirect` (send visitors somewhere else), `config` (the default - an Imperium page).
3. For `proxy`, `POST /api/hosting/sites/:id/proxy-check` first - it is an SSRF-safe reachability probe.
4. Write it with `PUT /api/hosting/sites/:id` carrying `{target: {...}}`.

**Trap:** `static` and `server` targets, and a proxy marked `trustedOrigin`, are operator-only and answer 403 for a member. Deploying an uploaded build is deliberately TWO calls: upload into `builds/<id>/`, then set `{target:{kind:"bundle", dir:"builds/<id>"}}`.

## Tabs

Every one is a real URL. A tab with its own section below carries a real job.

| Tab | Path | Group | What it is for |
|---|---|---|---|
| `domains` | `/admin/hosted-domains` | - | Every domain connected to this account. |
| `setup` | - | - | Connect a domain the operator owns, and prove they own it. |
| `sites` | - | - | Which of your websites this domain serves - one row per hostname, and the one place a hostname ALIAS is set. |
| `dns` | - | - | The records this domain needs, and whether they are live yet. |
| `danger` | - | - | Disconnect this domain, or release the claim so somebody else can take it. |
| `hosted-sites` | `/admin/hosted-sites` | - | Every website this operator has, with its status. |
| `site-wizard` | `/admin/site-wizard` | - | The guided path from nothing to a first site. |
| `manage` | - | - | The mode's own records - posts, links, files, images, releases. Only present for a mode that has them. |
| `content` | - | - | The page this site serves at its root: its mode, and that mode's data payload. |
| `pages` | - | - | Every page that is not the home page. |
| `static` | - | - | Hand-written .html and .md files this site serves as real pages. |
| `appearance` | - | - | Preset, colours, type, corners, logo - the whole look of the site. |
| `seo` | - | - | Title, description, keywords and the social card image. |
| `domains` | - | - | The subdomain this site answers on, and any custom domains attached to it. |
| `analytics` | - | - | Who visited this site, and when. |
| `target` | - | - | What this host actually serves: an Imperium page, a proxied app, an uploaded bundle, or a redirect. |
| `files` | - | - | Upload a build for this site to serve. |
| `connect` | - | - | Point this host at something the operator already runs, and test that it answers. |
| `mcp` | - | - | Serve this site as a tool server that outside agents can call. |
| `assets` | - | - | Records and assets scoped to this site. |
| `code` | - | - | The whole site as one editable config - import and export it as a single file. |
| `shield` | - | - | Per-site edge rules - who may reach this site at all, and how often. |
| `danger` | - | - | Unpublish, rename and delete. |
| `blueprints` | `/admin/blueprints` | - | Every blueprint this install offers and every one you own, and which of your sites is running each. A blueprint is a whole set-up — pages, portals, staff screens and the tables under them — so it sits ABOVE Page Builder, which edits one page of one. |
| `page-builder` | `/admin/page-builder` | - | Compose a page visually, then bind it to a route as `page:<slug>`. |
| `static-pages` | `/admin/static-pages` | - | Hand-written files any site can serve. |
| `apps-registry` | `/admin/apps-registry` | - | The apps a site can point at. |
| `deploy` | `/admin/deploy` | - | Push a build out. |
| `client-routes` | `/admin/client-routes` | - | The PLATFORM app's own routes - a different scope from a site's pages. Do not cross them. |
| `view-modes` | `/admin/view-modes` | - | Tenant-authored `x-` render modes, which is why the mode catalog must always be fetched. |
| `page-views` | `/admin/page-views` | - | Traffic across every site. |
| `global-settings` | `/admin/global-settings` | - | Defaults every site under this tenant inherits. |
| `configs` | `/admin/configs` | - | The raw config documents behind every site. |

## Tabs in focus

### `setup` - Setup

Connect a domain the operator owns, and prove they own it.

**Read with:** `list_domains` | `check_domain`

**Write with:** `connect_domain`

**Trap:** Connecting writes no DNS anywhere - Leumas only READS it to prove ownership, so the person must publish the TXT record themselves. A domain serves NOTHING until it is `verified`; reporting a connect as done is the misleading half of a true sentence.

### `sites` - Websites

Which of your websites this domain serves - one row per hostname, and the one place a hostname ALIAS is set.

**Read with:** `list_hostnames` | `list_websites` | `get_alias_contract`

**Write with:** `bind_hostname` | `unbind_hostname` | `POST /api/hosting/zones/:zoneId/bindings`

1. A subdomain of a PROVED domain needs no DNS work at all - the wildcard record set up during verification already covers it. Say so; people expect to have to do something.
2. `bind_hostname` points one address at one website. Several labels at once is a single act in the UI and several calls here.
3. Prefer an ALIAS over a second website when the only difference is what it looks like or which kind of page it shows: pass `overrides` to `bind_hostname`.

**Trap:** [critical] An alias is an ALLOWLIST of six presentation keys (mode, data, routes, appearance, skin, seo). Publish status, ownership, what the host serves and `baseApiUrl` belong to the website and are refused BY NAME - a binding must never be a way around the site lane. A `mode` override must bring its own `data`, or a payload shaped for one renderer reaches another that has never heard of it.

### `hosted-sites` - My Websites

Every website this operator has, with its status.

Address: `/admin/hosted-sites`

**Read with:** `list_my_sites` | `GET /api/hosting/sites`

**Write with:** `start_new_website` | `open_site_editor`

### `content` - Home

The page this site serves at its root: its mode, and that mode's data payload.

**Read with:** `get_site_overview` | `list_modes` | `get_mode_schema` | `open_editor_tab`

**Write with:** `set_site_content` | `save_site`

1. `get_site_overview` FIRST - it returns the current mode, publish state, subdomain, SEO and the unsaved payload.
2. Changing mode? `get_mode_schema(newMode)` for the field contract and a template before you write anything.
3. `set_site_content` with only what changes.
4. `save_site` - nothing above this line has persisted.

**Trap:** This is the ONLY page that needs `save_site`. Also: asking for a template for a mode that does not exist silently returns the `default` template, so an empty-looking result is not proof the mode is real - check the catalog.

### `pages` - Pages

Every page that is not the home page.

**Read with:** `list_pages` | `GET /api/hosting/sites/:id/routes`

**Write with:** `add_page` | `set_page_content` | `remove_page` | `POST /api/hosting/sites/:id/routes`

1. `list_pages` for what exists and each page's id.
2. `add_page` with an absolute `path` and a `componentKey`.
3. `set_page_content` edits one; `remove_page` drops one.

**Trap:** These write IMMEDIATELY - no `save_site`. A `componentKey` with an unrecognised prefix is re-read as a built-in and then refused with a message about the wrong thing, so check the prefix (`mode:`, `page:`, `dynamic:`, `library:`, `ssr:`, `static:`) before believing the error. And a non-public page is NEVER answered by the fast server-rendered plane - gating a page also hides it from crawlers and link previews, which is usually not what "members only" was meant to buy.

### `appearance` - Appearance

Preset, colours, type, corners, logo - the whole look of the site.

**Read with:** `get_appearance` | `list_theme_presets` | `GET /api/hosting/theme-presets`

**Write with:** `set_appearance` | `save_site`

1. `get_appearance` - it returns the current values AND the contract, so there is no need to guess a field name.
2. `list_theme_presets` if the user wants a whole look rather than one colour.
3. `set_appearance` with only the keys that change.
4. `save_site`.

**Trap:** Colours are a grammar (hex, rgb, hsl, oklch, color-mix) and anything unparseable is dropped silently. Everything else is a closed list. Custom CSS answers 402 without the capability - that means "not bought", not "not allowed".

### `seo` - SEO

Title, description, keywords and the social card image.

**Read with:** `get_site_overview`

**Write with:** `set_seo` | `save_site`

**Trap:** Editor state - it does not persist until `save_site`.

### `domains` - Domains

The subdomain this site answers on, and any custom domains attached to it.

**Read with:** `GET /api/hosting/sites/:id/domains`

**Write with:** `POST /api/hosting/sites/:id/domains` | `POST /api/hosting/sites/:id/domains/:host/verify`

1. Set the hostnames, then read them back - the response carries the DNS records the user must add.
2. Once the records are in place, run the verify call.

**Trap:** A domain that is attached but not verified serves nothing and reports no error on the site itself - check here, not the front end. The site id IS the subdomain, so renaming one is a different, operator-only call.

### `analytics` - Analytics

Who visited this site, and when.

**Read with:** `GET /api/hosting/sites/:id/telemetry` | `GET /api/hosting/activity`

### `target` - Serves

What this host actually serves: an Imperium page, a proxied app, an uploaded bundle, or a redirect.

**Read with:** `get_target` | `GET /api/hosting/sites/:id`

**Write with:** `set_target` | `PUT /api/hosting/sites/:id` | `POST /api/hosting/sites/:id/proxy-check`

1. `get_target` FIRST - it returns the current target AND the exact fields every kind takes, so there is nothing to guess and nothing to remember.
2. `set_target` with the WHOLE target including `kind`. The fields are per-kind, so a partial patch across kinds means nothing.

**Trap:** [critical] `target` is NOT `mode`. `mode` decides how the home page renders; `target` decides whether the mode renderer is reached at all - only `kind: "config"` gets there. An `embed` mode iframes somebody else's site; a `proxy` target IS the site, on your origin, with your TLS and cookies. `static` and `server` are operator-only.

### `files` - Files

Upload a build for this site to serve.

**Read with:** `GET /api/hosting/modes/:siteId/folder/tree` | `GET /api/hosting/modes/:siteId/folder/usage`

**Write with:** `POST /api/hosting/modes/:siteId/folder/upload` | `POST /api/hosting/modes/:siteId/folder/dir`

**Trap:** This prefix is the per-site STORAGE lane and has nothing to do with the mode catalog. Uploading a build does not serve it - that is a second call setting `target` to `{kind:"bundle", dir:"builds/<id>"}`.

### `connect` - Your app

Point this host at something the operator already runs, and test that it answers.

**Write with:** `POST /api/hosting/sites/:id/proxy-check`

**Trap:** Only actionable for the modes that describe an external app. For everything else the answer lives on the Serves tab.

### `mcp` - MCP

Serve this site as a tool server that outside agents can call.

**Read with:** `GET /mcp/hosted` | `GET /api/hosting/sites/:id/grants`

**Write with:** `POST /api/hosting/sites/:id/grants`

**Trap:** A grant secret is returned ONCE, at creation. Anything beyond a private server answers 402 - a purchase, not a permission.

### `code` - Code

The whole site as one editable config - import and export it as a single file.

**Read with:** `GET /api/hosting/sites/:id/export`

**Write with:** `POST /api/hosting/sites/:id/import`

**Trap:** Editing here bypasses every field-level lane above, so the tiered write rules still apply and a refusal will be about a field, not the file.

### `shield` - Shield

Per-site edge rules - who may reach this site at all, and how often.

**Read with:** `get_shield`

**Write with:** `set_shield` | `PUT /api/hosting/sites/:id`

**Trap:** [critical] An EMPTY crawler list means INHERIT the standard one, not "nobody". Writing `denyAgents: []` to be explicit removes the scraper protection the site already had. The deny list is also checked BEFORE the welcome list, so one broad pattern there de-indexes the site with nothing to read in a log.

### `danger` - Danger

Unpublish, rename and delete.

**Write with:** `POST /api/hosting/sites/:id/unpublish` | `DELETE /api/hosting/sites/:id`

**Trap:** Unpublish is ungated and reversible; delete is not, and it also sweeps every zone binding naming this site. Rename is operator-only because the id is the subdomain.

### `blueprints` - Blueprints

Every blueprint this install offers and every one you own, and which of your sites is running each. A blueprint is a whole set-up — pages, portals, staff screens and the tables under them — so it sits ABOVE Page Builder, which edits one page of one.

Address: `/admin/blueprints`

**Trap:** Three kinds of row and they are not interchangeable: an OPERATION is a pack, a STARTER is ours and cannot be edited (fork it and the copy is yours), and YOURS is a fork on one of your sites — the only editable kind. A pack’s parts are deliberately NOT listed one by one: a staff workspace installed on its own is a workspace with no public site and no portal.

## Ways in

Declared cross-links. Every address is matched against the real route table by `check:huds`.

| From | Does | Go to |
|---|---|---|
| `hosting/hosted-sites` | Start a website | `/admin/site-wizard` |

## Tools an agent can call here

These are **live page capabilities**: they exist only while that surface is open, and they
call the page's own mutators, so a change made through one is a change the operator can see. A tool
missing from this list is not callable — reach for the API or an adapter instead.

### `hosting:site-editor` — Site: …

Source: `hosting/model/useSiteCapabilities.js`

What the page tells an agent about itself:

> Call get_site_overview first. Use get_mode_schema before changing a mode (or before setting a mode: page's props) so payloads fit the contract.
>
> TWO LANES, do not confuse them: the HOME page is config.mode + config.data — edit it with set_site_content and persist with save_site.
>
> Every OTHER page is a route: add_page / set_page_content / remove_page write immediately through the gated lane, so they need no save_site.
>
> Make surgical edits. Only pass publish:true when the user asks to go live. Report exactly what changed.

| Tool | Args | Does |
|---|---|---|
| `get_site_overview` | — | Read this site: id, name, current mode, publish status, subdomain, SEO and the current (unsaved) data payload. Call FIRST before editing. |
| `list_modes` | — | Every render mode this site can switch between (type, pageName, group). Call it rather than assuming - the catalog grows per release and a tenant can add its own. |
| `get_mode_schema` | `mode` | A mode's data contract — the exact fields its payload supports — plus a default template. Call before set_site_content when changing modes or unsure of a field. |
| `set_site_content` | `appName` `mode` `data` | Update this site's name, render mode and/or data payload IN THE EDITOR (the live preview updates; nothing persists until save_site). Pass only what changes. |
| `set_seo` | `title` `description` `keywords` `image` | Update this site's SEO (title, description, keywords, social image URL). Editor state — persist with save_site. |
| `list_theme_presets` | — | The named looks a site can start from (id, name, description, the appearance each applies). |
| `get_appearance` | — | This site's current look PLUS the full appearance contract (every field and its allowed values). Call before set_appearance. |
| `set_appearance` | `preset` `mode` `accent` `background` `surface` `text` `font` `radius` `density` `logo` | Change how the site LOOKS — preset, light/dark, colours, typeface, corners, spacing, logo. Editor state, so the preview updates immediately; persist with save_site. Pass only what changes; pass null for a key to clear it. |
| `save_site` | `publish` | Persist the current editor state (the same save the buttons use — validated server-side). publish:true also pushes the site live. |
| `open_editor_tab` | `tab` | Switch the editor to a tab: manage (the mode’s own records — posts, links, files — when the mode has them), content (the home page), pages, appearance, seo, domains, connect, target, files, mcp, shield, analytics, assets, code, danger. |
| `list_pages` | — | This site's pages: address, title, what each shows (componentKey), who can open it, whether it is in the menu. Call before adding or editing one. |
| `add_page` | `path` `componentKey` `props` `label` `guard` `navVisible` | Add a page. componentKey is usually "mode:<type>" (any render mode as a page) — then `props` is that mode's data payload, so call get_mode_schema first. Also valid: "page:<slug>" (a Page Builder page) or "library:<path>". |
| `set_page_content` | `id` `path` `componentKey` `props` `label` `guard` `navVisible` `enabled` | Update one page by its id (from list_pages). Pass only what changes — `props` replaces that page's data payload. |
| `remove_page` | `id` | Delete a page by its id (from list_pages). The home page is config.mode/data, not a page — use set_site_content for that. |
| `get_target` | — | What this host SERVES, and the exact fields each kind takes. `kind` decides everything else: config (the pages you author here) \| proxy (an app already running elsewhere) \| server (a local app Imperium starts) \| static \| bundle (a build uploaded to this site) \| redirect. Call before set_target. |
| `set_target` | `kind` `origin` `dir` `port` `to` | Change what this host serves. Pass the whole target including `kind` — the fields are per-kind, so a partial patch across kinds is meaningless. Writes immediately; `static` and `server` are operator-only and will be refused for a member. |
| `set_page_gate` | `id` `guard` | Who can open one page: public (anyone) \| auth (signed in) \| membership (an active membership). Takes a page id from list_pages. NOTE a non-public page is only served after the site's app boots, so a crawler or a link preview sees nothing on it. |
| `get_shield` | — | This site's edge rules — rate limit, welcome and refusal crawler lists, honeypot paths — plus the full contract (every field and what it means). Call before set_shield. |
| `set_shield` | `enabled` `maxRpm` `allowAgents` `denyAgents` `blockPaths` | Change the edge rules. Pass only what changes. Everything is inert while `enabled` is false. |

**2 more tool(s) on this scope are named at runtime** (a computed key, or built
by a helper), so they cannot be listed here. Call `listTools` on the live page to see them.

### `hosting:websites-home` — My Websites

Source: `hosting/model/useSiteCapabilities.js`

What the page tells an agent about itself:

> list_my_sites shows what exists; open a site before trying to edit it — the editor page grants the editing tools.

| Tool | Args | Does |
|---|---|---|
| `list_my_sites` | — | The signed-in user's Imperium websites: id, name, mode, publish status, subdomain. |
| `open_site_editor` | `id` | Open a site in the Site Editor (where content/SEO/publish tools become available). |
| `start_new_website` | — | Open the New Website wizard (template gallery → subdomain claim → content → publish). |

### `unnamed scope`

Source: `hosting/model/useZoneCapabilities.js`


**1 more tool(s) on this scope are named at runtime** (a computed key, or built
by a helper), so they cannot be listed here. Call `listTools` on the live page to see them.

### `hosting:domains` — Connected domains

Source: `hosting/model/useZoneCapabilities.js`

What the page tells an agent about itself:

> Connecting a domain writes no DNS anywhere — Leumas only READS it to prove ownership. The person must publish the TXT record themselves, then check_domain confirms it.
>
> A domain serves nothing until it is `verified`. Say that plainly rather than reporting a connect as done.
>
> Open a domain to bind hostnames under it; the binding tools live on that page.

| Tool | Args | Does |
|---|---|---|
| `list_domains` | — | The domains this account has connected: host, whether ownership is proved, and how many hostnames are bound under each. A domain must be `verified` before anything on it serves. |
| `connect_domain` | `host` | Claim a domain this account owns. It starts `pending` and serves nothing until the TXT record in the result is published and check_domain passes. Idempotent — connecting one already connected returns it. |
| `check_domain` | `id` | Ask DNS whether the ownership record is published yet, and flip the domain to verified if it is. Safe to call repeatedly; DNS can take minutes to hours to propagate. |
| `open_domain` | `id` | Open one domain’s setup checklist, where the DNS records and the hostname map live. |

### `hosting:domain` — Domain: …

Source: `hosting/model/useZoneCapabilities.js`

What the page tells an agent about itself:

> Adding a subdomain of a PROVED domain needs no DNS work at all — that is what the wildcard record set up during verification is for. Say so; people expect to have to do something.
>
> One website can answer on several hostnames. Prefer an alias over a second website when the difference is only what it looks like or which kind of page it shows.
>
> Removing a hostname does not delete the website behind it.

| Tool | Args | Does |
|---|---|---|
| `list_hostnames` | — | Every hostname on this domain and what it serves — including whether an address is ALIASED (serving the bound website differently from its other addresses). |
| `list_websites` | — | The websites that can be put on this domain (the signed-in account’s own). |
| `bind_hostname` | `host` `siteId` `overrides` | Point a hostname on this domain at one of the account's websites. A subdomain of a proved domain needs NO DNS work — the wildcard record already covers it. Pass the full hostname (blog.example.com), or the domain itself for the apex. |
| `unbind_hostname` | `host` | Stop serving a hostname. The website itself is untouched — this only removes the address. |
| `get_alias_contract` | — | The exact fields a hostname alias may carry, and what each does. Call before passing `overrides` to bind_hostname. |

## API

Mounted prefixes that serve this domain: `/api/hosting` · `/api/hosting/modes`

The full route table is `leumas-capabilities/reference/api-routes.md`.

## Traps

The authed lane is `/api/hosting`; the site STORAGE lane is `/api/hosting/modes/:siteId/...`; the mode CATALOG is `GET /index/modes`. The middle one is not the last one. Read the status code: 402 means the operator may do this and has not bought it (custom CSS, MCP visibility, quota). 403 means operator-only (static/server targets, trustedOrigin, rename, theme/header/footer). Sub-tab paths under a site are declared WITHOUT the site segment - `/admin/hosting/sites/domains` is the SHAPE; the running app supplies the id. A site id is its subdomain, and a deleted subdomain becomes claimable again.



---
Source: .claude/skills/leumas-studio/reference/domains/hosting.md
Canonical: https://docs.leumas.tech/p/skills/leumas-studio/domains/hosting
