# Publishing
The local app is plain static files. Optional remote mode adds the Rust host
and its private database; there is no account system yet. This page covers
what to publish, what the web server must do, and how to ship and roll back a model. For the markup the app expects, see
`host-page.md`.
## Frontend build prerequisites
Use Node.js 24 or newer and run `npm ci` from the repository before building.
The shared rules need Rust 1.88+ with the `wasm32-unknown-unknown` target and
`wasm-bindgen-cli` at exactly the version `core/wasm/Cargo.lock` pins
([core](../core/README.md)); `core/wasm/build.sh` names the missing piece.
`npm run typecheck` checks all TS/TSX with strict mode (after `npm run core`).
`npm run build` writes `dist/`; `site/build.sh OUT` remains the
deployment/preview entry point. It builds the rules module
(`tokenling_core_bg.wasm`), checks types and bundles Preact plus the app into
`main.js`, and inference into a separate `brain.js`, before replacing output.
`site/bundle.mjs` accepts an output directory argument, reads no environment
variables and copies the rules module beside both bundles. The builder hashes
emitted JavaScript, source maps and the module together, so dependency and
rule changes also get a fresh immutable path. Model and runtime assets are still published
separately. No Node process, package registry or Preact CDN is needed to serve
or use the built app.
## What gets published
```
/ the page (index.html), sw.js and _headers, from site/build.sh
/bench.html unlinked brain benchmark using this release's model/runtime
/assets/ the font, manifest.webmanifest and icons
/app// bundled main.js, brain.js, source maps and tokenling_core_bg.wasm, under a content hash
/models// exported models (see export-and-runtime.md)
/runtime// the vendored transformers.js + ONNX Runtime files
```
`` is the URL path the site is served under (`/` for a whole domain).
| Piece | Produced by | Notes |
|---|---|---|
| Page, benchmark, app, worker, assets | `BASE= site/build.sh OUT` | fills the page and `test/bench.html` from `site/release.json`; leaves `OUT/models` and `OUT/runtime` alone |
| Model | `train/export.sh CKPT OUT` | writes tokenizer/config JSON, `onnx/model_q4.onnx` and `SHA256SUMS` (plus an `eval/` file that is not published) |
| Runtime | `train/vendor.sh OUT` | pinned transformers.js and its matching ONNX Runtime build |
| Install model and runtime | `STATIC_ROOT=… train/publish.sh MODEL_DIR MODEL_ID RUNTIME_DIR RUNTIME_NAME` | verifies checksums first; `BASE` (default `tokenling`), `FILE_MODE` (default 0644) and `DIR_MODE` (default 0755) are optional |
`site/build.sh` copies and renders the new static files in a temporary sibling
directory before replacing output entries. A copy, release-validation or
render failure during staging leaves the previous output untouched; `models/`
and `runtime/` are preserved on successful builds too. The final multi-entry
installation is not an atomic directory swap.
`publish.sh` finishes and checks the model partial and any needed runtime
partial before publishing either. A new runtime is renamed first, then the
model; a runtime-copy failure removes the partials without consuming the model
id, so the same command can be retried. Existing runtime directories are left
alone, and an existing model directory is never replaced. To build into the
same tree, point `site/build.sh` at `$STATIC_ROOT/$BASE`.
### `site/release.json`
What the page says about the release. It is project data, not host data:
| Key | Meaning |
|---|---|
| `modelId`, `size` | the current model directory and the byte size of its `onnx/model_q4.onnx` |
| `trained` | one line for the page's details |
| `news` | what the current brain does better, for the upgrade dialog |
| `features` | features the current brain was trained for (`host-page.md`) |
| `older` | older brains, newest first, `{"id", "features"}`, that visitors may keep running until they upgrade; only brains that speak the current prompt format |
| `runtime` | the runtime directory name under `/runtime/` |
| `source` | URL of the source code for the page's link (`SOURCE` overrides it) |
A deployment's external `RELEASE` file may also contain `analyticsToken`:
the public Cloudflare Web Analytics site token. Keep deployment identifiers
out of the repository's release file.
`site/build.sh` also takes `MODELS_URL` and `RUNTIME_URL` to load the model or
runtime from other paths, and `MODEL_ID`/`SIZE` overrides (the preview uses
them). `CF_ANALYTICS_TOKEN` overrides `analyticsToken`; an empty value disables
analytics even when the release override contains a token. Both settings
default to disabled and accept only an empty string or a 32-character
hexadecimal token.
The build always includes `bench.html`, without a navigation link or analytics
beacon. It uses the same resolved model/runtime URLs and model id as the app,
including build overrides. It runs on opening, downloads its own model copy
without using the pet's model cache, and reports results locally; it neither
reads nor changes the saved pet. Serve it with isolation headers and
`Cache-Control: no-cache` so a new release updates its model/runtime selection.
See `testing.md` for its query options and measurements.
### Optional page analytics
With a configured token, the builder emits the Cloudflare Web Analytics
module snippet before the closing body tag and adds a visible disclosure
about page visits and performance. It never adds app event reporting or
passes pet/owner fields, chat, diary or save data to analytics. Without a
token, neither the snippet nor its disclosure is emitted.
Use the module form so the external script uses CORS while COOP/COEP remain
enabled. After enabling it, verify that exactly one beacon script loads,
its report succeeds, `crossOriginIsolated` remains true, and blocking the
beacon or going offline does not stop the pet. Inspect a report from a
synthetic test pet to confirm private app fields are absent. Provider setup,
site tokens and deployment receipts belong in the operator's documentation.
Avoid combining a manual snippet with a second automatically injected one.
## Web server requirements
Any static file server or static host works; nothing runs on the server.
- **HTTPS** (or localhost). Cache Storage, persistent storage, service workers,
notifications and threads need a secure context.
- **Trailing slash.** Serve the page at `/` (redirect `` to it):
the service worker is registered relative to the page and its scope is that
directory.
- **MIME types:** `.mjs` and `.js` as `text/javascript`, `.wasm` as
`application/wasm`, `.json` as JSON, `.webmanifest` as
`application/manifest+json`. Range requests are not required.
- **Cross-origin isolation** on the page and everything under ``:
`Cross-Origin-Opener-Policy: same-origin` and
`Cross-Origin-Embedder-Policy: require-corp`. Without them the WebAssembly
backend runs on one thread: 2.0 tokens/s measured, against 5.9 with them
(`export-and-runtime.md`). Cross-origin resources require appropriate CORS
or CORP permission; keep the app, model and runtime same-origin.
- **Immutable caching** for `app/*`, `models/*` and `runtime/*`:
`Cache-Control: public, max-age=31536000, immutable`. This is only safe
because those directories never change in place. Serve `sw.js` with
`Cache-Control: no-cache` (or a short max-age) so worker updates arrive.
- **Compression** is optional; the `.onnx` and runtime `.wasm` files barely
compress. The app's rules module (about 0.45 MB) shrinks to about a third
with gzip.
- Allow for sizes: about 97 MB per model and about 85 MB of runtime (a browser
fetches only one of the `.wasm` builds; the one transformers.js loads is
27 MB).
`site/build.sh` writes these header rules to `/_headers`, the format some
static hosts read. Other servers need the same rules in their configuration.
The shipped service worker passes `.onnx` downloads through to the browser
without `respondWith`, including when `MODELS_URL` changes the path. Keep
that bypass when embedding the app behind another service worker: proxying
long model streams can end them early in Firefox (`Error in input stream`).
The model's separate `transformers-cache` still supplies it offline.
## Shipping a change
**Code or page only** (nothing the model sees): run `npm test`, preview with
`test/dev-site.sh` (see `testing.md`),
build with `site/build.sh` and copy the output to the server. **Keep old
`app//` directories** when copying (no delete-sync of `app/`): a tab
opened before the deploy may still start its brain worker from its own
version's directory.
**A new model:**
1. `train/export.sh CKPT OUT/tokenling-vN`.
2. `train/publish.sh OUT/tokenling-vN tokenling-vN RUNTIME_DIR RUNTIME_NAME`
(the runtime step is skipped if that directory already exists).
3. Edit `site/release.json`: the new `modelId`, `size`, `trained` and `news`;
move the previous model to the front of `older` if the prompt format did
not change (otherwise empty `older`); list any new `features`.
4. Build and deploy the page, then test a fresh profile end to end (download,
cached reload, a chat with each lookup tool, a diary page) and a profile
that has the previous brain (the upgrade dialog, the download and the swap).
Visitors who already have a pet keep their brain until they accept the
upgrade when its prompt format is still compatible; nobody loses a pet,
chat or diary to a model change.
**Rollback:** point `modelId` back at the previous model (and take it out of
`older`), rebuild and deploy. Old model directories can stay on disk. A visitor
who already upgraded to the withdrawn brain has nothing usable cached and
downloads the restored one again.
**If the prompt format changed** (see `AGENTS.md`), empty `older`: older
brains cannot read the new format, so pets with only an old brain doze until
the new one is downloaded. Saved pets still load, because their state stores
plain chat lines, but expect a few odd replies until the old history scrolls
out of the context. If a stored shape changed, bump the save version and add
a migration (`pet-simulation.md`).
v6 changes sleeping replies and filters owner quotes from the chat header.
Its release therefore has an empty `older` list; v2–v5 are incompatible.
## Doing the GPU work
Training and bulk teacher generation need the GPUs. On a shared machine:
1. **Look first.** Check `nvidia-smi` and whatever else is serving from those
GPUs for in-flight work, and note what is resident so you can restore it.
2. Free the GPUs. Do not start jobs on cards other services need.
3. `train/teachers.sh start`, `wait`, run `gen_data.py`, then `stop`. The
script starts each server as its own process group and stops it by group, so
nothing is killed by name pattern (a `pkill -f` pattern inside a shell
command can match that same shell's command line).
4. Train on one or both GPUs (`CUDA_VISIBLE_DEVICES`). Export and quantization
are CPU-only and can run after the GPUs are given back.
5. **Hand the GPUs back as soon as they are free**: restart whatever you
stopped, restore what was resident, and verify each service with a real
request. Do not hold the GPUs while only CPU work remains.
6. Record the dates, the downtime and the results in your run receipt.
## Optional remote pet host
The static app supports an independent [Rust host](../server/README.md).
`REMOTE_ENDPOINT` optionally pre-fills its address; otherwise owners enter
one. It never changes the local default. The equivalent release override
key is `remoteEndpoint`. Do not embed private access keys in deployment
configuration or the host page.
The host has required listen, database, model/release, native runtime and
allowed-origin inputs documented in `server/README.md`. Keep private SQLite
state and backups outside Git. Use HTTPS for remote clients and exact CORS
origins. Authenticated API data use `no-store`; the service worker bypasses
Authorization requests and `/api/` paths. Alerts remain browser/device managed;
there is no new push service.
Run the Rust ownership/API tests and prompt parity, then check setup choices,
both transfer directions, closed-page background turns, reconnect/outage
recovery and the 360 px layout. Native CUDA and simultaneous CPU/GPU bursts
have a limited [qualification receipt](testing.md#native-cuda-and-mixed-serving-check-receipt-2026-10-09).
Repeat it with the selected runtime/device before deployment; it does not
establish sustained capacity or public-host readiness.
## Live checks
```sh
curl -fsSI https://HOST// # 200, with the COOP/COEP headers
curl -fsS https://HOST//models/tokenling-v6/SHA256SUMS
curl -fsSI https://HOST//sw.js # 200, text/javascript, not immutable
curl -fsSI https://HOST//assets/manifest.webmanifest # 200, application/manifest+json
(cd && sha256sum -c SHA256SUMS)
curl -fsSI https://HOST/ # no COOP/COEP outside , if you scoped them
```
Then run the browser flow in `testing.md` against the live URL with a fresh
persistent profile, and check the layout at 360 px width.