Chrysalis

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
nameRequired. Shown in the launcher.
versionRequired. Shown when an update is offered, and passed to your plugins when one lands.
kindRequired. web for a web app like the starter.
description, authorOptional. Shown in the launcher and the Store.
repositoryOptional https:// link to the app's source.
engineOptional 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, ?inline and ?worker imports, import.meta.glob, import.meta.env with VITE_* values from .env, tsconfig paths and the @ alias for src.
  • Tailwind v4 is built in: start src/app.css with @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

ExportPermissionCalled
handleRoute(req, host)routesFor requests to /v1/apps/<app>/…. req has method, path, query and body. Return { status, json } or { status, text }.
TOOLS and handleTool(name, args, host)toolsWhen a model calls one of the tools, returning { text, isError }.
appTools(ctx, host)toolsTo add tools to other plugins' model requests that ask for them.
llmRequest(ctx, host)hooks and llmBefore another plugin's model request, returning changes to it. The manifest's priority orders several.
onTick(ctx, host)scheduleOn 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

PermissionWhat it does
host.fsfsread, readBase64, write, list and remove, inside the app's data folder only.
host.storestoreget, put, delete and keys on the plugin's own saved values.
host.llmllmModel calls through the user's connections, in two passes (below).
host.netnetworkWeb requests in two passes, like host.llm, only to the hosts listed in the manifest's networkHosts.
host.logWrite 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.

Edit this page on GitHub