@cultivateapp/cli
v0.1.10
Published
The cultivate() Vite plugin and the cultivate command, which make a Vite React project a Cultivate app
Readme
@cultivateapp/cli
The cultivate() Vite plugin, which makes an ordinary Vite React project an artifact, and the cultivate command, a thin wrapper that shows an app with no platform at all (a preview) and pushes it to the local stack, or to production as the author (docs/artifacts.md). An artifact's vite.config.ts is defineConfig({ plugins: [cultivate()] }); everything platform-specific lives in the plugin, so a conversation's Sprite can start the same Vite dev server with the same plugin.
Commands
Run in an app's directory in the content repository, cultivate-content (its apps' package.json scripts call these), with the author's variables in the environment for a push: CULTIVATE_DEV_EMAIL from the platform's root .env.local, and CULTIVATE_PLATFORM_CHECKOUT, the platform checkout whose local stack the command uses (docs/local-development.md, Content, says how a clone of the content repository loads them):
cultivate preview [--port n]: the app in a page of its own, with no platform at all: no stack, no sign-in, no artifact on a platform and none of the variables above, so it runs in any checkout of the content repository. It is the Vite dev server with the plugin, as a conversation's Sprite runs it, so code, content and block changes hot-reload, and the server listens on every interface and serves, on one origin, a host page at/beside the frame: open the Local URL it prints on this machine, or the Network URL on a phone on the same network. The host page mirrors the web app's learner view (on the paper of an app's page, a slim bar with the app's name that says this is a preview, then the frame, which fills the rest of the screen at full width in a 16 px gutter while the app scrolls inside it) and answers the bridge itself: documents and the log are kept in the browser'slocalStorageunder the artifact's id, so a reload keeps the learner's progress, until "Voortgang wissen" in the bar ("Wipe progress" in an app of another language) clears it, after a confirmation, and reloads the frame. Storage is per origin, so each browser, and each address the preview is opened at, keeps its own; a port that is taken moves the preview to the next one, which is another origin, with empty progress. The learner is a fixed one (Preview, with a fixed id) and the theme the blocks' defaults, which are the paper values the web app sends from an app's page. Documents and the log are what it serves; a capability it has no service for, such as the judge, is to answerfailed, on which an app falls back as it does when the service is down. The page is started by the app's own@cultivateapp/runtime(@cultivateapp/runtime/preview), so the app needs the runtime released with this CLI; with an older one the command stops at once and says so. While it runs, anyone on the same network can load the app and whatever else the dev server serves: the app's source, and the content repository's files through/@fs/(Vite'sserver.fs.allow, the workspace's root; Vite keeps.envfiles and.gitout).cultivate preview --published [--port n]: the app as learners get it, without publishing. It builds the app ascultivate pushdoes (vite buildwith the plugin, pushing nothing, into a temporary directory rather thandist/) and serves the bundle from a second port of this machine as the artifacts Worker serves a published version, from the Worker's own implementation (@cultivateapp/runtime/shell): the frame's page for the page?host=names, under the published frame's policy, and each file of the bundle typed by its extension, under the origin's policy, withnosniffandnoindex; the page is never stored and a file is revalidated by itsETag(no-cache) rather than kept as immutable, since a rebuild replaces them in place under the same/v/<id>/. The host page is the preview's own, with its bar and its storage, and embeds the frame from that other origin at whatever address the page was opened at, so on this machine and at the Network URL on a phone the production bundle runs cross-origin under the policy, as learners get it; the frame's origin frames the app for the preview's own page alone, at an address the preview answers at and its port (any other?host=is refused, 403host_not_allowed). The frame is told it is no dev frame (useArtifact().devis false), and/speakis forwarded as the dev server forwards it. A change to a file of the build rebuilds it and reloads the page; there is no hot module replacement, and a changed manifest takes a restart. It is for what only the production bundle shows (React's development-only behaviour, such as its replay of effects, hides some defects), for a look on a phone at what a push would publish, and for the content repository's acceptance tier. The output names the frame's port. The agent surface reaches no frame of this mode, whose bundle has no dev client; the timeline shows the host page and each build. The app's runtime must be this CLI's release, which the command checks before it builds.cultivate push [--production] [--label text] [--dirty] [--rename]:vite build, then upload the source and the bundle, create the next version (numbered 1, 2, 3, … per artifact) and publish it. It publishes only what is committed and pushed (owner, 2026-09-26): before it builds anything, it fetchesorigin'smainintoorigin/mainand refuses when the app's repository has uncommitted changes anywhere in its working tree, when HEAD is notorigin/main's commit itself (ahead of it, with commits not yet pushed, or behind it, which would publish an older state thanmain's), or when the app is in no repository or one without anorigin, naming every problem. The fetch never prompts for credentials (GIT_TERMINAL_PROMPT=0) and gives up after 60 seconds. From a terminal,--dirtypublishes anyway (proposed, 2026-09-26), and the version records that nothing was checked.cultivate check: validate the manifest and the content and type-check the artifact, without pushing.
artifact.json names the artifact by its id, a UUID, which is also its address (/a/<id> on the web app, <id>.cultivate.io as its origin). A manifest without an id is refused with a freshly generated one to paste in; push then creates the artifact under that id when no artifact has it yet, and otherwise work on it, refusing an id that is another user's artifact. Because the manifest carries the id, it names the same artifact on the local stack and in production.
A copy of an artifact needs an id of its own: a directory copied from another artifact (the way a new artifact is often started) keeps the original's id, and would otherwise work on the original, publishing its versions there. So push refuses a manifest whose name differs from the name of the artifact its id names, and say what to do: for a copy, remove id from artifact.json and run the command again to get a new one; for a deliberate rename, run it again with --rename, which renames the artifact to the manifest's name once the version is published, never when it refuses the version, and prints it. A copy with the original's name as well is not caught: give it a new id before its first run.
Locally the command acts as the user in CULTIVATE_DEV_EMAIL (root .env.local), which must be the platform admin's address or an author's (an owner or author of a group), since only they author. It creates that user in the local stack if missing and gives it a verified Google identity for the address (written straight into auth.identities, as apps/web/test/google-identity.ts does for tests), because admin status is decided from one. Then it reads the stack's fixed development keys from supabase status, run with the Supabase CLI of the platform checkout that CULTIVATE_PLATFORM_CHECKOUT names (an app is in a repository of its own, so nothing above it is the platform), uses the secret key only to mint that user a session (a magic link verified on the spot, retried if a concurrent command voided it), and hands the session to the plugin, with the local artifacts Worker (http://localhost:8788, which pnpm dev runs against the local stack) as the upload target, to which a push uploads with the same session's access token.
With --production, cultivate push goes to production instead: production's Supabase project and artifacts Worker are built into the command, and the push, its uploads included, runs as you, with a session of your own that the command keeps on your machine or a token from your browser session (below). Either way the command sets the plugin's whole platform environment itself, so no variable left in the shell can point part of a push at another platform. --production and --dirty are for cultivate push alone.
In a conversation's Sprite (records/2026-09-27-redesign/README.md, Publishing) the agent runs pnpm push in the app's directory, and the command acts as the conversation's author on the conversation's platform; cultivate preview is refused there, since the platform runs the apps' dev servers (a preview would be a second one, open on every interface of the Sprite). The Sprite's host (packages/authoring-host) gives the agent's commands CULTIVATE_PLATFORM_FILE, the file in which it keeps the author's Supabase session (the Supabase URL and publishable key, the access and refresh tokens, and the access token's expiry), and, when the platform gave the Sprite an artifacts Worker, CULTIVATE_UPLOAD_URL, that Worker (src/session.ts). The command reads the session without refreshing it, since only the host may spend the refresh token, which a refresh rotates; it refuses --production and --dirty, a session that expires within two minutes (the host renews it before every turn), and a conversation without CULTIVATE_UPLOAD_URL, which has nowhere to publish to, as is the case for one from local development, whose artifacts Worker runs on the developer's machine. The uploads carry the author's access token, and the artifacts Worker stores a file only when that author owns the artifact; a push from the directory of another user's app is refused before anything is uploaded, when the plugin finds the artifact is not the author's.
Pushing to production
From a laptop an author pushes to production as themselves (docs/artifacts.md, Decisions), with a Supabase session of their own that the command keeps on the machine and refreshes there, the credential a conversation's Sprite has. Name its file, outside any repository, in the platform's root .env.local, which the content clone's mise.local.toml loads (docs/local-development.md, Content):
CULTIVATE_PRODUCTION_SESSION_FILE=~/.cultivate/production-session.jsonThen, in the artifact's directory:
pnpm push --productionThe first push, when the file does not exist yet, opens https://beta.cultivate.app/api/cli/session in the browser (on macOS; it prints the URL as well). Signed in there as an author or a platform admin, the page shows a new session of yours, apart from the browser's, as { "accessToken": …, "refreshToken": …, "expiresAt": … }: copy the whole answer and paste it at the prompt, which does not echo it. The command writes it to the file (mode 600, in a directory only you can read, a leading ~/ being your home directory; a relative path is refused) and pushes. Every later push reads the file and, when the access token has less than 30 minutes left, first refreshes the session with the publishable key and writes the rotated pair back, since a refresh spends the refresh token; so it asks for nothing, and an agent on the machine pushes the same way. Two pushes that refresh at once both get the session's current pair from Auth, which allows a token one rotation behind (src/production-session.ts). A push run without a terminal, by an agent, never asks: without the file it stops and says to push once in a terminal.
When production refuses the refresh, the session has been revoked, or has expired: the command says so, and removing the file and pushing again asks for a new one. The session has no expiry of its own (production's Auth sets no time-box or inactivity timeout), so it lasts until it is revoked, and removing the file does not revoke it. Nothing in the product lists or revokes these sessions yet (docs/artifacts.md, Open): the platform revokes one by deleting its row in auth.sessions, whose id is the session_id claim of the access token in the file, after which the access token already there stays valid until it expires, at most an hour. Every visit to the route mints another session.
Without CULTIVATE_PRODUCTION_SESSION_FILE, a push asks for a token each time instead: it opens https://beta.cultivate.app/api/cli/token, which refreshes your browser session there and shows its fresh accessToken and the token's expiresAt; copy the token, or the whole answer, and paste it at the prompt. The token lasts the session's hour: the command reads its expiry before it builds and refuses one with less than five minutes left. That route returns the access token only: the refresh token stays in the browser, which would be left with a spent one if the CLI rotated it. With CULTIVATE_ACCESS_TOKEN set, the command uses that token, before the file or asking.
Either way the push runs as you, under the same row-level security as on the local stack, and the artifacts Worker takes the same access token for the uploads, storing files only for an artifact you own. The refresh token never reaches the plugin: only the command spends it, before the push.
The plugin
It reads its platform from options or the environment, which the cultivate command sets in full:
| Variable | Meaning |
| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| CULTIVATE_SUPABASE_URL, CULTIVATE_SUPABASE_PUBLISHABLE_KEY | The platform's Supabase |
| CULTIVATE_ACCESS_TOKEN | The author's access token; every call of a push runs under row-level security as them |
| CULTIVATE_HOST_ORIGIN | The web app origin allowed to embed a hosted dev server's frame, the one the conversation's workbench runs on, which the Sprite's host sets; a hosted dev server requires it |
| CULTIVATE_PREVIEW | 1 serves the app with a host page of its own and no platform (cultivate preview); none of the platform's variables above is read then |
| CULTIVATE_HOSTED | 1 on a conversation's Sprite, where packages/sprites/sprite/serve.mjs sets it: the conversation's host loads the frame and holds the app's row, so the dev server needs no credential, and says so once in its timeline. A dev server that is neither hosted nor a preview has no host to show the app in and refuses to start, naming cultivate preview |
| CULTIVATE_UPLOAD_URL | Where uploads go: the artifacts Worker's upload route (cultivate push sets the local Worker's, http://localhost:8788, or production's, https://cultivate.io, with --production; in a conversation's Sprite, the host sets the one the platform gave it), which takes the author's access token and stores a file only for an artifact they own |
| CULTIVATE_PUSH, CULTIVATE_LABEL | Make vite build push, and name the version |
| CULTIVATE_ALLOW_DIRTY | 1 lets a push publish what is not committed and pushed to origin/main (--dirty) |
| CULTIVATE_RENAME | 1 renames the artifact to the manifest's name, which is refused otherwise |
| CULTIVATE_SPEECH_URL | Where the dev server forwards /speak: the artifacts Worker's speech route, production's https://cultivate.io/speak by default, or a local Worker's (http://localhost:8788/speak) |
In vite dev it serves an app for one of two hosts: hosted on a conversation's Sprite (CULTIVATE_HOSTED=1, which packages/sprites/sprite/serve.mjs sets) or in preview (CULTIVATE_PREVIEW=1, below); with neither it has no host to show the app in and refuses to start, naming cultivate preview. It validates artifact.json and content/ (one JSON file per declared collection: records with an explicit id, data valid against the collection's JSON Schema, and an optional source) at the start and again whenever one changes, logging an invalid change and keeping the manifest before it; code and content changes themselves are Vite's hot module replacement, since the artifact imports its content like code. Hosted, it serves the frame's page (the platform runtime mounting the manifest's entry), embeddable only by the web app in CULTIVATE_HOST_ORIGIN. The frame's page, hosted and in preview, is under a published frame's policy (shellPolicy from @cultivateapp/runtime/shell; docs/artifacts.md, Runtime, Origin), except that its scripts may also be inline and evaluated, which React Refresh's preamble and an agent's evaluate need; the conversation's host loads the frame, reads the manifest itself and holds the app's row, so the plugin creates nothing and needs no credential, and says so once in its log. Stylesheets are the artifact's own imports: an app imports the blocks' stylesheet (@cultivateapp/blocks/styles.css) from its entry, before its own, and the plugin itself depends on no content package. An app plays speech from /speak on its own origin, which the artifacts Worker serves for a published version (docs/artifacts.md, Runtime); the dev server forwards its own /speak there, to production's Worker unless CULTIVATE_SPEECH_URL names another, so an app under development, on a laptop or a phone, speaks with the published voice and needs no key. The forwarded request carries the query and a Range header but none of the browser's cookies, credentials or Referer (the page's URL). A dev server under a base, as a conversation's Sprite serves an app under /apps/<id>/, forwards <base>speak instead, since only the base's URLs reach it there. The frame's page names the dev server's base as the base of the platform's routes (<meta name="cultivate-api">, as a published version's page names its origin's root), and the boot module passes it to the runtime's start(), so an app that asks the runtime for the URL, apiUrl("speak", { lang, scheme, text }), reaches speech wherever it is served.
In preview (CULTIVATE_PREVIEW=1, which cultivate preview sets) the dev server has no platform: it validates the manifest and content as above, and again on every change, and creates nothing. It serves the host page at / (and /index.html) and the frame's page at /@cultivate/frame, and the host page starts with startPreview from the app's own @cultivateapp/runtime/preview, as the frame's page mounts the app with the app's own runtime; a changed manifest reloads both, since the host page carries it. Both pages are on the origin the browser opened, which the frame's page names as its host (the one page its hello goes to and the only one allowed to frame it), so that origin comes from each request's Host header. The one name it answers is localhost, since another site could make any other name resolve to this machine (DNS rebinding), and it answers any IP address, which cannot be rebound, so a phone, Android's emulator (10.0.2.2), a VM or a forwarded port reaches it at whatever address it has for this machine; Vite refuses other names too unless a vite.config allows them, but checks only the part before the port, and since the value goes into a header and the page, the preview also refuses anything but an origin written as a browser writes it, with a 403 (src/preview.ts). The host page itself may be framed by nobody. Vite's route that opens a file in the editor, /__open-in-editor, answers requests from this machine only, since in preview every device on the network reaches the server.
In vite build it builds the platform's bundle, into dist/ or the directory the command names (cultivate preview --published names a temporary one): one ES module entry (entry.js), a boot module that imports the manifest's entry and mounts its default export with the platform runtime, with React, the @cultivate packages and the content bundled in; its stylesheet and assets; relative URLs; and a build.json naming the entry and the stylesheets, with the source and bundle file maps (path → sha256, size, type) and repository, the Git repository the artifact's directory is in when the build starts (the content repository, for an app in its apps/): its HEAD commit (null before the first commit), the URL of its remote origin without any user name or token in it ("local" for a remote on this machine, a path or a file:// URL, whose path is not recorded; null without an origin), its uncommitted changes across the whole working tree, untracked files included, as a count and the first 50 paths from the repository's root, and checked: for a push, the ref it held HEAD to and that ref's commit as fetched ({ "ref": "origin/main", "commit": … }), null for a build that does not push or a push with --dirty. It is null for a directory in no repository; any other failure to read the repository, git missing included, fails the build rather than record none. With pushing on, it uploads the bundle and the source snapshot (source.json: artifact.json, package.json, tsconfig.json, vite.config.ts, README.md, src/ and content/, never dotfiles or symlinks) to write-once keys under the artifact's and version's ids, creates the version with its manifest and build.json, and publishes it; the push reports the commit it records, how many uncommitted changes, and what it was checked against. A build that fails, a push the rule refuses included, publishes nothing: Vite closes the bundle after a failed build too, while dist/ still holds an earlier build's output, so only a build that wrote its own bundle is pushed, and once.
Validation uses Ajv (JSON Schema 2020-12) in strict mode, so a misspelt keyword in a collection's schema is an error; format is not validated.
The agent surface
Wherever an app's dev server runs (cultivate preview, a conversation's Sprite), it is where an agent sees the app and acts in it (docs/artifacts.md, Authoring and the development loop): it keeps a timeline of what happens to the app and its frames, and carries an agent's calls into a frame, in the author's browser, over the frame's hot-module-replacement socket. src/agent-surface.ts is the dev server's side, src/client/ the pages' (each page's reporter and the frame's dev client), and src/agent-protocol.ts what the two share.
The timeline. One list of events per run of the server, each { id, at, kind, level, message, source?, stack?, data?, client? } with id increasing from 1 (a restart starts again, with server.start):
- what Vite's logger says:
server.log, andserver.errorfor a transform, resolution or plugin error and for the plugin's own validation errors, withsourcefrom Vite'slocor an oxc diagnostic's header when that is a file of the checkout on disk (not a virtual module's id or a dependency's file), and asstackan excerpt of the file at that place (Vite's own frame is drawn from the code a plugin handed on, whose lines differ from the file's, while itslocis mapped back to the file), Vite's frame only for a place that cannot be read, each further diagnostic of the same transform after it with its message and the file at its place, anddata.plugin(an error of a shape other than its type says, as Vite's builtin JSON plugin gives with an object foridand no place, is recorded with what it has); the same error again within five seconds is recorded once, since Vite reports a failing module for every request of it; server.start;hmr.update(data.files, the files whose change it carries, anddata.modules, the modules it updates) andhmr.full-reload(data.files, ordata.reason);- pages: a frame's
client.connectandclient.disconnect; what a page's reporter sends (below),console(data.level, the console's method:error,warn,info,log,assert, a failed one, as an error, ortrace),errorandunhandled-rejection(the error's name before its message, no name for a value that is not an error),resource-error(an element's resource that failed to load,data.taganddata.url: for a module script, the script whose graph failed, while the server error, if the dev server's, names the module) andreports-dropped(data.count, the reports the page could not hold while the dev server was out of reach), a frame's with itsclient, the preview's host page's withdata.page: "host"and none, and those the page before a reload held and this one sent withdata.previousPage; and the runtime's reports,framewithdata.stateconnecting,connected,rendered(the app rendered, the first time and again after a crash that a hot update fixed; recorded once when React's StrictMode reports it twice),no-welcome,crashedorcall-failed(withdata.methodanddata.code; an error forinvalid_callandnot_declared, which are the app's bugs, a warning forfailedandtimeout). Every place and excerpt refers to the original source, with lines and columns counted from 1 as an editor counts them: a stack from a page, an uncaught error's or a rejection's, aconsolemessage's and a crash's alike, is mapped through the source maps of the modules the dev server transformed, with its base and queries stripped, and written as> method file:line:column(the file relative to the workspace root), with an excerpt of the file under the first place in the author's own code; that place is the event'ssource. Where the served code has no mapping on a frame's line, the nearest mapped place before it in the module stands for it, with(approximate)after it, and is neither the event'ssourcenor excerpted: a component stack's frame points into the React Compiler's prologue of the component and so stands near the component's declaration, while a frame in code a plugin appended after the module's last mapped line (React Refresh's footer) stands at whatever line came last. A frame that does not map at all keeps the served code's path and place, with(served code, not mapped)after it. Aconsolemessage's stack is the one its text carries (console.error(error)), whose first line is then itsmessage, or else the stack of the call: alogor aninfohas only the call's place assource, the others the stack too. React 19's warnings carry no stack in their text, and their call's stack is React's own, since React warns after the component has returned, so a warning such as a missingkeyhas nosource: the component its message names is its only locator; rpc, each call an agent made in a frame (data.method, anddata.paramswithout an evaluated function's body).
Files are relative to the workspace root, the content checkout (apps/<name>/src/App.tsx, blocks/src/…), or absolute outside it, and a place's line and column count from 1. The timeline keeps the latest 500 events, cuts a message to 4 KB and a stack to 2 KB, and keeps the count and the latest five of its errors past the events that fall out.
Each page's reporter. The frame's page and the preview's host page load a reporter (src/client/reporter.ts, served as /@cultivate/reporter.js and /@cultivate/host-reporter.js from @cultivateapp/cli/reporter, dist/reporter.js in the published package) as the module script right after Vite's client and before anything of the app's, so it hears a module of the app's that fails to load, link or evaluate. It is small and imports nothing but the protocol's names, and apart from the dev client, which only the frame has. It wraps console.error, .warn, .info, .log, .assert and .trace (not .debug, with which Vite's own client says it is connecting), since an agent that debugs an app with console.log sees the frame through the timeline only, and sends each message with the stack of its call; it listens for uncaught errors and rejections, and in the capture phase for an element's resource that failed to load; and it serializes within bounds whatever it is given: 4,096 characters of message and 8,192 of stack, a value three levels deep and 20 entries wide, a cycle or a getter named rather than followed. It says hello (cultivate:client, with its page, frame or host, and the page's path, never its query), holds what it hears until the dev server's welcome, and again once the socket has gone, which it learns from the socket's state when it heard the socket open, from Vite's vite:ws:disconnect, or from Vite's own transport error: when a send of any of the page's modules finds the transport down, Vite's client logs console.error("[vite]", error) with a SendBeforeConnectError in the microtask after the send, which the reporter knows by the error's name, takes as the socket gone and never reports, since the line is Vite's and not the app's; the reports it sent since the microtasks last ran dry went to the same closed transport, and it holds them again. It sends at most 500 reports in one task and the rest in the next, which only keeps a report that feeds back into another from holding the page within one task. When the page goes it keeps what it still holds, at most 100 reports and the count of the rest, in the tab's session storage under the page's path (every app of a Sprite is on its origin) for the next page at that path to send (Vite's client reloads the page once the server is back). The dev server cuts a report again as it arrives (any page of it can send anything), and writes each error and warning to the terminal as one line with its place; logs and infos go to the timeline only.
The plugin turns Vite's server.forwardConsole off, whatever the app's vite.config or Vite's own default (on when an agent started Vite) says, because the reporters take its place and both would report everything twice (owner, 2026-09-27). Vite 8.3's forwarding serializes a console argument whole, without a bound (vitejs/vite#23545); drops what a page says once its socket has closed; gives a plain message no call site; and maps no stack under a base, since it resolves a frame's URL against the root without stripping the base, so on a Sprite it placed errors in the code it transformed. The place of a forwarded message could only be read from Vite's line in the terminal, which tied the surface to that line's wording and to the order of the socket's listeners.
The endpoints. Under /__cultivate/ at the server's root, whatever Vite's base (on a Sprite /apps/<id>/, under which they do not answer), for requests from this machine only: any other, and any request with an Origin header (a browser page's, which a site could aim at localhost), gets 403.
GET /__cultivate/status:app(id,name, anddirectory, relative to the workspace root),server(pid, the Vite process's, which a conversation's host compares with the process it started,port,base,startedAt,vite),clients, the frames connected (id,connectedAt,lastSeenAt,path, andframe: itsstate,loadinguntil the runtime's first report, thenconnecting,connected,renderedonce the app has rendered (after a crash, when it renders again),no-welcomeorcrashed, withsince, and theerrorofno-welcomeorcrashed),lastUpdate(at,files),errors(sinceStart,latest) andcursor, the latest event's id.GET /__cultivate/events?since=<id>:{ events, cursor }, the events aftersince; withAccept: text/event-streamor?follow=1, server-sent events (id:anddata:per event, the ones aftersinceorLast-Event-IDand then each new one, and a: keepalivecomment every 15 seconds).POST /__cultivate/reload: every frame reloads, one busy with an agent's call included;{ ok: true }.POST /__cultivate/rpcwith{ method, params, client? }: a call in a frame, answered with HTTP 200 and{ result }or{ error: { code, message, data? } }(400 for a body that is not JSON).
Calls in a frame. client names a frame of the status; without it the call goes to the most recently active, the frame that last sent anything (a person's click or key in it included), then the latest connected. The frame has 5 seconds to answer. Parameters and results follow Playwright MCP's:
snapshot { ref?, depth?, boxes? }→{ snapshot }: Playwright's accessibility snapshot of the frame's document (or of the element atref) in the YAML of itsmode: 'ai', with[ref=eN]on each element an agent can act on; the other calls take the refs of the frame's last snapshot. A page that shows nothing accessible has an empty snapshot, as a frame's does before its state isrendered(a connected app may still wait for its data).evaluate { function, ref? }→{ result, isFunction }:functionis evaluated as(<function>)in the page's global scope; a function is called, with the element atrefwhen one is given, and the result awaited and answered as JSON (undefined as null, an element as its markup, what JSON has no form for as its string, a value met again inside itself as"[Circular]", at most 256 KB).click { ref, doubleClick? },fill { ref, text },select { ref, values }andpress { key, ref? }→{ events, omitted? }, the frame's events in the 300 milliseconds after the action: at most 20, errors and warnings before the rest, in the order they happened, withomittedcounting those left out (the timeline has them all). An action scrolls the element into view and checks that it is visible and enabled, and a click, as Playwright's, lands in the middle of the ref's own element and checks that nothing but its enclosing button or link (or itself) is at that point (Playwright's hit-target check). A click's press and release, and a double click's two clicks, reach the page 20 ms apart, as tasks of their own, so React renders between them as it does for a person's. Its events are synthetic: the page sees what a person's input dispatches, but nothing that needs a user's activation happens (audio does not start), and there is no typing, hover, drag or file upload.fillsets the value through the element's prototype, so React takes it as input, and dispatchesinputandchange;selecttakes options by value or label;pressdispatches the key's events forEnter,Escape,Tab,Spaceand the arrows, and does what the browser would: Enter submits the element's form (requestSubmit) or activates a button or link, Space activates a button, checkbox or radio, and Tab moves the focus.- The error codes:
no_client(no frame is open),client_gone,timeout,no_such_ref(take a new snapshot),not_visible,covered(data.coveringis the element in the way),not_editable,disabled,evaluation_failed(datacarries the error's message and stack),invalid_paramsandfailed(the dev client itself failed).
The dev client. The frame's page loads /@cultivate/client.js (src/pages.ts) after its reporter, a module of its own, so it runs when the app fails to load. The plugin serves it from @cultivateapp/cli/client: this package's src/client/index.ts in a checkout linked to this one, dist/client.js in the published package. No other page has it: not the preview's host page, not a published bundle, whose entry is the boot module, and not the artifacts Worker's shell. It tells the dev server when a person uses the frame (cultivate:client, at most every five seconds), and answers the dev server's calls as JSON-RPC 2.0 (cultivate:rpc), whose numeric error codes src/agent-protocol.ts maps to the ones above. The accessibility snapshot is Playwright's own code, copied from its release into src/vendor/playwright with Playwright's license and notice (its README.md lists the edits and how to refresh the copy).
Limits, and what would close them. A JSON module's error has no place: Vite 8.3's builtin JSON plugin gives neither its file nor a loc (its id is an object of its options), so the event has the message and data.plugin only; a content file is named by the plugin's own validation error. A CSS error through Tailwind 4's plugin has no place either: its parser's CssSyntaxError gives the file and offsets into it as loc ([{ file, code }, start, end]), from which a place would follow, an @apply of an unknown utility gives nothing, and the error's id, which names the file, is not yet named in the event. On Safari, whose stacks leave out a URL's query, a module that the dev server has in its graph only under a query (?import) stays unmapped. A frame of a pre-bundled dependency is mapped into the dependency's own files when the dev server has the dependency in its module graph, and otherwise stays, marked as not mapped, at its place in node_modules/.vite/deps (as after a restart); an event's source never points into a dependency. A frame of a virtual module (served as /@id/…) is not mapped, since the module graph keys it by its unwrapped id. A report that a page carried over a restart is mapped when it arrives, before the reloaded page has asked the new server for its modules, so its frames may not map and its source may fall on a caller; asking the module graph for a module it lacks would close that. A frame is in the status from its reporter's hello, a moment before its dev client has loaded, and a call in that moment times out rather than failing with no_client. A page's message is cut to one line in the terminal at its first newline, but a carriage return or a colour code in it reaches the terminal as it is. What a page reports in the seconds a Sprite's edge keeps a socket open after the dev server has gone is lost, as it was with Vite's forwarding: acknowledgements, reports numbered in order and acknowledged by the dev server, with those not yet acknowledged kept when the page goes, would close that gap.
The React Compiler
The plugin composes @vitejs/plugin-react with the React Compiler through its Rust port (react({ compiler: { logDiagnostics: true } }), with oxc-transform-react a dependency of this package), so every app and the blocks it imports are compiled: a compiled component imports react/compiler-runtime and memoizes through it, which is why React 19 is a peer dependency. Its recoverable diagnostics, such as a component it left uncompiled and why, appear as Vite's warnings in the output of cultivate preview and cultivate push, and a fatal one fails the transform (and so a push's build); cultivate check does not compile. oxc-transform-react is taken at its newest release the supply-chain settings allow (^0.151.0). From 0.148.0 its results omit recoverable diagnostics, matching Babel's default logger: null (oxc-project/oxc#26128, deliberate), so on 0.148 to 0.151 logDiagnostics has nothing to log and a component the compiler skips goes unmentioned: checked on 2026-09-26 with a component that reads a ref during render, which 0.145.0 and 0.147.0 report as "Cannot access refs during render" and 0.148.0 through 0.151.0 do not. The opt-in that returns them while still emitting code, reactCompiler.reportDiagnostics (oxc-project/oxc#26624, merged 2026-09-25 and not yet released; the releases are weekly), is already passed through plugin-react's compiler option (react({ compiler: { logDiagnostics: true, reportDiagnostics: true } })), which 0.151 ignores, so the diagnostics return with the release that carries it. After that bump, check that such a component makes cultivate preview (or a push's build; cultivate check does not compile) print "React Compiler skipped optimizing this component or hook". Until then the React hooks rules of the content repository's oxlint are the safety net. @vitejs/plugin-react 6.1.1 declares oxc-transform-react ^0.145.0, a range that is out of date upstream (vitejs/vite-plugin-react#1437, open): it is where pnpm's unmet-peer warning comes from, and it is harmless, since the binding's interface is the one the plugin calls.
The plugin also resolves react, react-dom and @cultivateapp/runtime from the app's root (Vite's resolve.dedupe), so a frame has one copy of each even when the runtime is linked from a platform checkout, whose own React would otherwise be bundled beside the app's (docs/local-development.md, Working on the runtime and an app together).
Published, and in this workspace
The package is published to npm as @cultivateapp/cli (docs/deployment.md, npm packages), released together with @cultivateapp/runtime, whose minor its dependency holds (^0.1.0 takes no 0.2), with Vite and React as peer dependencies at their majors (vite 8, react 19), so an app's own versions of both are the ones used. Node does not strip types from files under node_modules, so the published package is built: pnpm build (tsdown, tsdown.config.ts) writes ESM and declarations to dist/, and the frame's dev client, with the Playwright files it uses, as one browser module (dist/client.js); prepack runs it before every pnpm pack and pnpm publish, and publishConfig points the published bin and exports at dist/ (pnpm applies it when it packs). The package also ships Playwright's LICENSE and NOTICE (src/vendor/playwright/), since dist/client.js holds its code. The ./client entry is internal to the plugin, which serves it into the frame; apps do not import it. In this workspace, and in a checkout linked to it (docs/local-development.md, Working on the runtime and an app together), Node runs the source directly (type stripping), so the package's tsconfig.json resolves modules the way Node does and relative imports carry .ts. The dev client runs in the browser, so src/client/tsconfig.json checks it and the Playwright files apart, with the DOM's and Vite's client types and none of Node's, and the package's typecheck runs both. The @repo/db types platform.ts uses stay out of the published declarations: nothing the package exports refers to them.
Tests
pnpm test at the root runs two tiers of this package's. The Node tier (src/**/*.test.ts) runs the plugin, the endpoints and the timeline on real Vite dev servers, with a WebSocket that plays a page, and the pushes, the platform and the command's pieces around them. The browser tier (test/*.browser.test.ts, the root config's browser project) runs what only a browser can: the pages' reporters, the frame's dev client and the runtime's reports, in Chromium through the workspace's Playwright. Each of its files but the published mode's copies the fixture app, test/fixtures/agent-app (one of each thing an agent's calls and the frame's reports meet; its README.md), to test/.tmp/, which Git ignores, serves the copy with Vite in the test's process and the cultivate() plugin, in preview, or hosted under a base with the mapping packages/sprites/sprite/serve.mjs adds on a Sprite and a host page of another origin, opens it in Chromium and drives the frame through /__cultivate/rpc, asserting in the frame's document, in the calls' answers and in the timeline (test/harness.ts). It covers the snapshot, evaluate, each action and its errors, the frame's reports from connecting to rendered, crashes and their recovery after a fixing edit, hot updates, errors in the source and before the app starts, a restart of Vite during an open event stream, eviction, several frames, a frame no host greets, and the preview's frame on a phone's screen as the app's viewport (the page still, the app scrolling inside the frame, position: fixed and sticky the frame's); and the reporters: their place among the page's scripts, an app that fails to import or to link, what each producer reports (an uncaught error, a rejection, console.error of an error and of plain text, a crash, a server error) placed in the file with the file's own lines under a base, what a page says before its socket connects and what it could not send before a reload, the preview's host page's own reports, bounded serialization, console.assert and console.trace.
test/published.browser.test.ts runs cultivate preview --published on a fixture of its own, test/fixtures/published-app (a stylesheet, an image file, a chunk loaded on demand, the bridge, speech; its README.md): the frame's page under the published frame's policy word for word, and the bundle's files with their types and revalidation; the app in the frame of the other origin, at another address of this machine, with no policy violation and no console error; the control, an injected inline script and a request to another origin refused and reported while an injected style applies; host pages refused, by ?host= and, for another page that names the preview's, by the browser through frame-ancestors; speech forwarded without cookies or Referer; and a rebuild reaching the page. In the Node tier, src/frame-server.test.ts runs the artifacts Worker's own handler in Node, with its bucket stood in for, beside the published preview's frame server, and requires the same page, headers and refusals of both for the same build, caching aside.
Run it alone with pnpm exec vitest run --project browser. It needs Playwright's Chromium, which pnpm --filter web exec playwright install chromium installs; without it the tier skips and the run says so, except in CI, where the run fails, since CI installs Chromium before pnpm test (.github/workflows/ci.yml). A test file takes between about 15 and 45 seconds (13, 17 and 42 in CI on 2026-09-27; the published mode's about 6 on a laptop), most of it waiting for the frame (a hot update, a restart, the ten seconds before a frame reports no welcome).
