@cultivateapp/runtime
v0.1.10
Published
The Cultivate platform's runtime for apps: the frame's bridge to the host, a learner's documents and log, the manifest, the build document and the published frame's page
Readme
@cultivateapp/runtime
The platform's side of an app (docs/artifacts.md): what an app uses inside its sandboxed frame to reach the platform, the runtime that mounts it, the host side of the bridge, the preview's host page, the manifest, the build document and the published frame's page. It knows nothing about exercises, subjects or scheduling: it offers two storage primitives and stores what an app writes without interpreting it. Answer checking, review scheduling and the practice loop are the content repository's @cultivateapp/learning. Browser TypeScript (@repo/typescript-config/react-library.json), with React as a peer dependency. Each app's build bundles it.
In the frame (@cultivateapp/runtime)
start(App, { hostOrigin, api })mounts an app. The platform calls it, never the app: from the Worker's page of a published version, from the dev server's page in development, whose boot module passes what the page declares in its meta elements, the host's origin (cultivate-host) and the base of the platform's routes (cultivate-api). It connects to the host, sets the host's design tokens as CSS custom properties on the frame's root (--background,--primary, …, the names inTHEME_TOKENS) and its colour scheme, renders the app inside an error boundary and a Suspense boundary, and tells the host once the app is on screen (frame.ready, below). The frame is the app's viewport: the host gives it the space its own chrome leaves and the app's document scrolls inside it, soposition: fixed,position: stickyand100dvhare the frame's, and the mount sets nothing on the page's layout that would change that (docs/artifacts.md, Seamlessness). What the mount itself says (outside Cultivate, without a connection, after a crash) is in the language of the page,<html lang>, which both pages set from the manifest'slang: Dutch and English, and English for any other language.useArtifact(): the app, the learner (every frame has one since groups; the type still allows null), whether a dev server serves the frame, andpreview, true incultivate previewalone (a dev frame in a conversation's workbench hasdevbut notpreview), for chrome only a preview should show.apiUrl(route, params): the URL of one of the platform's routes beside the frame, resolved against the base the frame's page declares (<meta name="cultivate-api">, an absolute path or URL:/on an artifact origin,/apps/<id>/on a conversation's machine, the dev server's root incultivate preview), with the parameters that have a value as its query. The one route is speech:apiUrl("speak", { lang: "grc", scheme, text })is an MP3 to play with an Audio element, which the frame may load from its origin (docs/artifacts.md, Speech). It is undefined beforestart()and when the page declares no base, and an app then shows the route's service as unavailable rather than build a URL of its own: one relative to the bundle's (./speak, from Vite's base) lands under a published version's path, where only the bundle's files are.collection<T>(file): types a content file the app imports (import words from "../content/words.json") as its records, eachRecordEntry<T>,{ id, data: T, source }, in file order. Thecultivate()plugin has validated the file against the collection's schema at dev start and at build, so the type holds; the content is part of the bundle and never crosses the bridge.- Documents, the
statecapability:useLearnerState<T>(key, initial)is a per-learner value under a key, for what is current (a setting, a mode, a position), replaced whole on every change and saved in the background; at most 64 KiB of JSON. Two writers on different keys never conflict. - The log, the
logcapability:appendEntry(entry)appends the app's own JSON (at most 16 KiB) to the learner's log and resolves with theLogEntrythe platform stored,{ id, createdAt, entry };useLog()is every entry, oldest first, updated as entries are appended. What an entry means is the app's convention (the learning package's results); any learner model is computed from the log and can be recomputed. - The judge, the
judgecapability:judge({ state, questions })asks the platform's judge questions about a learner's free-text answer (docs/artifacts.md, Content and progress), in the shape of the AI SDK'sexperimental_evaluate, withchoice,scoreandbooleanquestions. It resolves with aJudgeResponse: one answer per question, typed by its question (a choice'schoiceis one of its own options), with the model's probabilities, and the version of the model that answered. A call asks every question about one state at once, at most 32, with the state at most 8 KiB and the questions 16 KiB of JSON, and ids and option names of letters, digits and_. An app lets the pupil grade themselves on any rejection:failedwhen the host has no judge, the platform has no key, the learner has asked too often in the last minute or the judge did not answer (the message says which),timeout,not_declared,invalid_callfor a request outside the bounds, and plainErrors for a frame without a connection, a closed one or a state that cannot be cloned. The state's type must be a type alias or an object literal: one typed by aninterfaceis refused (TS2322, it has no index signature), and a question built outside the call needsas constorsatisfies JudgeQuestionto keep itstypeand its options, and with them its typed answer. - Every call waits at most 20 seconds for the host's answer and then rejects with a
BridgeErrorwhosecodeistimeout. The host answers every call it receives, a failed one included (failed), so none comes only when its request to the platform never settles, as on a network that hangs; the limit lets an append that an app chains others behind reject and the next go ahead. A timed-out append may still have been stored, in which case its entry appears when the log is next loaded.appendEntrycan also reject after its entry was stored, when the append succeeded and the log's load it then waits for failed. A caller that retries a rejected append can therefore store an entry twice; results that must count once carry something that tells a repeat apart. - The limits (16 KiB an entry, 64 KiB a document) are the bridge's, measured on the JSON the frame sends; the database allows twice as much, a margin for its own measure, jsonb's text form, which is longer (
supabase/schemas/artifacts.sql). A value of pathological numbers can still exceed it, since jsonb prints numbers in full (1e300as 301 digits), and then fails asfailed. The bridge refuses U+0000 anywhere in a value or its keys, which jsonb cannot hold, and a document whose value isnullor missing, which could not be stored either (the bridge'sstate.deleteremoves a key):useLearnerStatecannot savenull, whose save is then refused and logged, so a document meant to hold nothing holds another value, such asfalseor"". The platform stores entries and documents as jsonb, which normalizes objects: their keys come back ordered by length and then by bytes ({ "n": 1, "item": …, "kind": … }), and of a key given twice only the last value is kept.appendEntryresolves with the entry as stored, anduseLoglists entries that way, so an entry's keys can be in another order than the app appended them in.
The hooks suspend until their data has arrived, and each dataset is loaded once per frame (src/store.ts). Loading starts as soon as the frame is connected, for documents and the log together, for the capabilities the welcome declares (preloadLearnerData in src/data.ts, which start() calls): an app's first render stops at the first hook that suspends, so loading each dataset when its hook first asked made an app that reads a document and then the log wait for one round trip after the other. A capability the app does not declare is never asked for. A load the platform could not complete is tried twice more, within about a second and a half; one that still fails reaches the frame's error boundary and is not kept, so the next caller (an appendEntry, say) loads anew. What a failed append or a failed save of a document means is the app's to handle: appendEntry rejects, and a document's save is logged to the console. Content is imported like code, so a content change during development arrives through hot module replacement.
Reports to the dev server
In development the frame tells the dev server that serves it what otherwise only the browser's console would show: connecting and connected as start() connects to its host, rendered when the app has rendered (from a component after the app inside its error boundary and Suspense, so after the data the app waits for has arrived, and again whenever the boundary's content mounts anew, as after a crash that a hot update fixed; reported once the commit's other effects have run, and not when one of them threw), no-welcome when no welcome came within ten seconds (the frame shows its no-connection text then, and keeps saying hello once a second: a later welcome, from a page that hydrated late, replaces the text with the app and is reported as connected), crashed when the app's error boundary caught an error (its message, and its stack with React's component stack), and call-failed for every bridge call that rejected (the method and the BridgeError code, timeout included, even when the app handles the rejection). They go as cultivate:frame events over Vite's hot-module-replacement socket, import.meta.hot (src/dev.ts), into the dev server's timeline (packages/cli/README.md, The agent surface). In development the error boundary also renders the app again after a hot update that arrives while the app is crashed, 100 ms later, once React Refresh has put the new code in place, since such an update may be the fix (an update that arrives while the app runs is none, even the one that crashes it): React Refresh remounts a failed boundary itself where it can, and where it did not (a prebundled runtime on a conversation's Sprite), the crash screen stayed until a reload. Vite gives import.meta.hot to the modules it serves, the runtime prebundled among them, and replaces it with undefined when it builds, so a published bundle carries none of this: on 2026-09-27 a build of an app, against this source and against the packed package, had no cultivate:frame in its entry.js.
On the host (@cultivateapp/runtime/host)
connectFrame({ window, frameWindow, frameOrigin, welcome, manifest, handlers, onConnect, onReady, onError }) answers the frame's hello and runs its calls, and calls onReady when the frame says its app is on screen, which is when a host shows the frame. The web app's frame host, which the app's page and the conversation workbench embed, implements handlers (listLog, appendLog, listState, setState, deleteState) with the signed-in person's Supabase client, scoped to the artifact, and judge with its judge route (apps/web/src/components/artifacts/host-handlers.ts); a host without a judge leaves judge out, and the frame's judge calls then fail. A handler's error reaches the frame as a generic failure, unless it is a BridgeError, whose code and message are the handler's word to the frame. parseJudgeRequest validates a judge call's parameters, for the bridge and again for the web app's judge route, which anyone signed in can call. parseManifest (also @cultivateapp/runtime/manifest) validates an artifact.json, for the host, which reads manifests written by other users, and for the cultivate() plugin; isArtifactId and THEME_TOKENS are there too.
The preview's host page (@cultivateapp/runtime/preview)
This entry is internal to the platform: the @cultivateapp/cli released with the runtime loads it from the app's own runtime for cultivate preview, and the web app takes the preview's handlers from it for a dev frame, whose progress it keeps in the browser as the preview does (previewHandlers, storageKey and wipeProgress, from src/preview-handlers.ts). Apps do not import it, and its shape may change with any release of the pair.
startPreview({ manifest, frame, published }) builds the host page of cultivate preview (packages/cli/README.md), which serves an app with no platform: the page's shell, from the cultivate() plugin's dev server, calls it with the manifest and the frame's URL, as the frame's page calls start(). The frame is the dev server's page on the same origin, or with published the built bundle's page on another origin (cultivate preview --published), whose origin the bridge then checks, as the web app checks an artifact's. It mirrors the web app's learner view (a slim bar over the frame, which fills the rest of the page, as tall as the dynamic viewport, at full width in a 16 px gutter, while the app scrolls inside it; the frame hidden until the app is on screen; the page on the paper of an app's page, whose tokens the blocks' defaults repeat, so the preview sends none), with the app's name, "Preview" and the wipe control in its bar, and it says so when a frame document does not connect within ten seconds, as the web app does for a dev frame. It connects the frame with connectFrame and handlers over the browser's localStorage (src/preview-handlers.ts): the log and documents of the artifact, under cultivate-preview:<id>, stored as JSON and kept across reloads. Each call reads and writes them as one step, under a Web Lock named for the app where the page has Web Locks, so a second tab on the same origin neither loses a document written at the same moment nor gives two log entries one id; browsers give Web Locks to secure contexts only, which localhost is and the Network URL (http://<address>) is not, so two tabs there can still overwrite each other's writes. The bar's "Voortgang wissen" ("Wipe progress" in an app of another language) reloads the frame and removes them once its new document connects, when the old document's port is closed, so nothing the old document still sends lands in the emptied store. The welcome names a fixed learner (Preview), since the preview keeps documents from the first call on as the platform does for a signed-in learner, dev: true (false for the built bundle), preview: true, the manifest's capabilities and no design tokens, so the frame keeps the blocks' defaults, which are the paper of the web app's app pages. Documents and the log are all it serves; a capability it has no service for, such as the judge, gets no handler and is to be answered failed, on which an app falls back as it does when the service is down. The bar speaks the page's language, <html lang>, Dutch or English, as the mount's messages do.
The build document (@cultivateapp/runtime/build)
BuildInfo is a published version's build.json, which the cultivate() plugin writes beside the bundle and the version's row mirrors: its name and language, the bundle's entry and stylesheets, the bundle's and the source snapshot's file maps, and the Git repository the app was built from. parseBuildInfo reads what the artifacts Worker needs to serve the frame's page.
The published frame's page (@cultivateapp/runtime/shell)
How an artifact origin serves a published version, in one implementation that the artifacts Worker (apps/artifacts) and the cultivate CLI share, so that nothing else serving a bundle can drift from it. originRoute says what a path names: a version's page at /v/<version>/ (a lowercase UUID), a file of its bundle below it, speech at /speak, or nothing, never the build document (build.json) or a path with an empty, . or .. segment. renderShell is the page itself: the bundle's stylesheets and entry module under the version's path, the host's origin in <meta name="cultivate-host"> and the base of the platform's routes in <meta name="cultivate-api" content="/">, the origin's root, which start() reads and apiUrl resolves against, with no inline script. shellHeaders gives the page's type, its policy (shellPolicy, an ordinary web app's: scripts from the origin only, styles from it and inline, images, media and fonts from it and the web, requests and forms to the origin alone, no nested frames, workers or plugins, and frame-ancestors the host page alone; docs/artifacts.md, Runtime, Origin) and Referrer-Policy: no-referrer; shellPolicy(host, { dev: true }) is a dev server's frame page's, whose scripts may also be inline and evaluated; addOriginHeaders gives every other response the origin's policy with frame-ancestors 'none' and sandbox (assetPolicy), and every response nosniff and noindex; mediaType types a bundle file by its extension, whatever it was uploaded as; isOrigin checks the page's ?host=. Caching and who may be a host are each server's own. The module imports nothing and needs only the Fetch API's Headers, so it runs in Workers and in Node. It is internal to the platform, as the preview's entry is: apps do not import it.
The bridge
The frame posts hello to its parent with the host's origin as targetOrigin, so a parent on another origin never receives it, and repeats it until it is answered, under a nonce the document picks once. The host accepts hello only from its own iframe's window and from the frame's origin, and answers with a welcome (the app, the learner, the theme and the declared capabilities) that echoes the nonce and transfers a MessagePort; the frame accepts it only from its parent and the host's origin, and only for its own nonce. The host answers each nonce once, so a hello repeated while the host was busy cannot close the port the frame took, and a new nonce (the frame reloaded) gets a new port. Every call then runs over that port, which only these two windows hold: log.list, log.append { entry }, state.list, state.set { key, value }, state.delete { key } and judge { state, questions }; and one notification, frame.ready, which the frame sends once per document when its app first rendered or its error boundary's message did. Both sides check the protocol's version on the hello and the welcome (PROTOCOL, cultivate/2 since 0.1.5, when showing a frame began to wait for frame.ready), so a frame and a host of incompatible versions never connect, and the host says the frame did not start. The host checks each call against the manifest's capabilities and bounds its size (and a document's key) before any handler runs, and tells the frame only that a failed call failed: database errors stay on the host. It checks nothing about what an entry or a document holds; a judge call's questions it checks in full, since the platform passes them on to a model. The tests (src/bridge.test.ts) run both ends over a real MessageChannel with windows that drop messages addressed to the wrong origin, as browsers do.
Published, and in this workspace
The package is published to npm as @cultivateapp/runtime (docs/deployment.md, npm packages), for apps in the content repository, and released together with @cultivateapp/cli, whose dependency on it holds its minor. pnpm build (tsdown, tsdown.config.ts) writes ESM and declarations for the six entries to dist/, prepack runs it before every pnpm pack and pnpm publish, and publishConfig points the published exports at dist/. The web app, the artifacts Worker and the CLI in this workspace use the source (exports → src/*.ts).
Tests
pnpm test at the root runs src/**/*.test.ts in the Node tier: the bridge's origin, source, nonce, capability and size checks, the judge's bounds and its answers typed by their questions, manifest parsing, the stores' loading and the log's merge (an entry kept once when the log's load races its append, and in order of id when appends are answered out of order), the learner's data asked for together at connect and only for the declared capabilities, the preview's handlers (progress kept across a reload, as JSON, apart per app, wiped per app), and apiUrl (a route resolved against each kind of page's base, and no URL without one).
