Build an app
The quickest way to build an app is to ask the agent. This page is for when you want to know how the pieces fit, write one by hand, or review what the agent made.
#Start one
Click New app in the launcher, give it a name, and open the agent: "Build a reading tracker with a list of books and a notes field." The starter is a working React and Tailwind page the agent builds out with you.
#What is in an app
my-app/
manifest.json name, version and what kind of app it is
package.json the frontend's npm packages
index.html the page; its module scripts are the entry points
src/ the frontend (TypeScript, React, CSS)
public/ static files served at the app's root
plugins/
api/
manifest.json the plugin's name, version and permissions
plugin.js code that runs on the server
data/ what the app stores; never touched by updates
AGENTS.md optional notes for the agent about this app
node_modules/ and dist/ appear once the app is installed and built. They are derived, never committed, and left out of backups.
#manifest.json
{
"name": "Reading Tracker",
"version": "1.0.0",
"kind": "web",
"description": "Books I am reading, with notes.",
"author": "Your name",
"repository": "https://github.com/you/reading-tracker",
"engine": ">=1.0.0"
}
| Field | |
|---|---|
name | Required. Shown in the launcher. |
version | Required. Shown when an update is offered, and passed to your plugins when one lands. |
kind | Required. web for a web app like the starter. |
description, author | Optional. Shown in the launcher and the Store. |
repository | Optional https:// link to the app's source. |
engine | Optional Chrysalis version range the app needs, like >=1.2.0, ^1.0.0 or >=1.0.0 <2.0.0. Updates that need a newer Chrysalis are refused with a clear message. |
Chrysalis records where it installed an app from outside the app, so fields like origin and source in a manifest are written by Chrysalis and ignored when they come from a repository or a file.
#The frontend
An app's page is a normal web project. Chrysalis builds it in your browser, so there is no dev server and no build step to run:
- TypeScript, TSX and JSX, CSS and CSS modules, JSON, and assets imported as URLs.
?raw,?url,?inlineand?workerimports,import.meta.glob,import.meta.envwithVITE_*values from.env, tsconfig paths and the@alias forsrc.- Tailwind v4 is built in: start
src/app.csswith@import "tailwindcss";. - Any npm package in
package.json. Packages install with their install scripts turned off.
Bundler plugins and toolchain config files do not run, so single-file component formats that need a compiler plugin are not supported. Saving a file hot-updates the open app and keeps component state.
#Talking to your plugins
App pages run in a sandboxed frame. fetch works for the app's own server routes and a few shared ones (models, images, speech, embeddings, assets); everything else is blocked. Your app's id is the folder it was installed into, which is not always the name you picked, so read it from the page address:
const appId = decodeURIComponent(location.pathname.split("/").filter(Boolean)[2])
const res = await fetch(`/v1/apps/${appId}/notes`)
const { notes } = await res.json()
Images, fonts and other media from other websites cannot load directly. For images, use the built-in proxy: <img src={`/v1/apps/${appId}/img?url=${encodeURIComponent(url)}`}> loads any https:// image from a host that one of your plugins lists in networkHosts and has the network permission for. Other media goes through one of your plugin's routes.
#Plugins
A plugin is a folder under plugins/ with a manifest.json and a plugin.js. Plugins run on the server in a sandbox and can only do what their permissions allow.
{
"name": "Notes API",
"version": "1.0.0",
"permissions": ["routes", "fs"]
}
plugin.js is an ES module. This one stores notes as files in the app's data folder:
export function handleRoute(req, host) {
if (req.method === "GET" && req.path === "/notes") {
let ids = []
try { ids = host.fs.list("notes").map((f) => f.replace(/\.json$/, "")) } catch { /* none yet */ }
return { status: 200, json: { notes: ids } }
}
const m = /^\/notes\/([a-z0-9-]+)$/.exec(req.path)
if (req.method === "PUT" && m) {
host.fs.write(`notes/${m[1]}.json`, JSON.stringify(req.body))
return { status: 200, json: { ok: true } }
}
return null
}
Return null for requests the plugin does not handle. Every plugin in an app sees the same paths and the first one to answer wins, so give each plugin's routes their own prefix.
#What a plugin can export
| Export | Permission | Called |
|---|---|---|
handleRoute(req, host) | routes | For requests to /v1/apps/<app>/…. req has method, path, query and body. Return { status, json } or { status, text }. |
TOOLS and handleTool(name, args, host) | tools | When a model calls one of the tools, returning { text, isError }. |
appTools(ctx, host) | tools | To add tools to other plugins' model requests that ask for them. |
llmRequest(ctx, host) | hooks and llm | Before another plugin's model request, returning changes to it. The manifest's priority orders several. |
onTick(ctx, host) | schedule | On the timer the manifest sets with "schedule": { "intervalMs": 60000 }. |
onAppUpdate(ctx, host) | After an update lands, with the old and new versions, to upgrade stored data. Gets five minutes and 512 MB, and is retried until it succeeds. | |
uiPanel(ctx, host) | To describe a settings panel the app can render for the plugin. |
#The host object
| Permission | What it does | |
|---|---|---|
host.fs | fs | read, readBase64, write, list and remove, inside the app's data folder only. |
host.store | store | get, put, delete and keys on the plugin's own saved values. |
host.llm | llm | Model calls through the user's connections, in two passes (below). |
host.net | network | Web requests in two passes, like host.llm, only to the hosts listed in the manifest's networkHosts. |
host.log | Write to the server log. |
#Model and network calls take two passes
A plugin never waits on a model or a web request. On the first pass it asks with host.llm.request(key, request) and returns { __llmPending: true } without writing anything. Chrysalis makes the call and runs the route again with the answer in host.llm.results[key]; that second pass does the work and responds. host.net works the same way. Because the first pass can run more than once, keep it free of side effects.
#Data
Everything under data/ belongs to the person using the app. Updates never change it, backups include it, and the agent can read and edit it directly, so plain JSON files with a clear layout make your app easy to extend by asking. A short data/README.md describing the files helps the agent a lot.
Files in data/ whose names start with an underscore, like _example-book.json, are templates: your app should not show them, the agent copies them to create new entries, and missing ones arrive with updates.
#AGENTS.md
Put an AGENTS.md at the root of your app with the things the agent should know before changing it: where the important code is, the shapes of your data files, and anything that is easy to break. The agent reads it before editing the app.
#Learn from a real app
Roleplay is a large app built this way: its plugins/ show routes, two-pass model calls and data layout, and its src/ is a full React frontend.
Ready to share it? See Publish to the Store. For how plugins run, streaming, tools and prompt hooks, see The plugin system.