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.
receivesOptional. What other apps can send this one, like { "route": "/inbox", "kinds": ["character"] }. See Sending items between apps.
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.

Remote builds batch asset checks and compress large build uploads. App startup scripts download together and run in order. Saves with known data changes record those files without a second full workspace scan.

While an app builds, Chrysalis shows its current step. Hot updates show a small status label while the app stays usable.

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.

Sending items between apps

An app can hand an item, such as a character card or a note, to another app. The sending app calls:

const { sentTo } = await window.ChrysalisApp.share({
  kind: "character",
  name: "Ember",
  data: card, // any JSON value, or a Blob (it arrives as a data: URL)
})

Chrysalis asks the user which app should get it, listing only the installed apps whose manifest takes that kind. Nothing is sent until the user picks one, and then the receiving app opens. sentTo is { id, name }, or null if they cancelled. Items can be up to 32 MiB.

To take items, list the kinds in the manifest's receives ("*" takes any kind) and answer the route with a plugin:

export function handleRoute(req, host) {
  if (req.method === "POST" && req.path === "/inbox") {
    const { kind, name, from, data } = req.body // from = { id, name } of the sender
    host.fs.write(`inbox/${Date.now()}.json`, JSON.stringify(data))
    return { status: 200, json: { ok: true } }
  }
  return null
}

Apps never call each other directly. The user's choice is the only way an item moves, and the receiving app sees only the item, never the sender's data. If the route answers with an error, the dialog shows it so the user can pick another app or cancel.

Speech

Apps read text aloud through the voices the user set up in Settings > Speech. Free Edge voices need nothing; the user can also add ElevenLabs, OpenAI, Gemini, Fish Audio, OpenRouter or a local server. Keys never reach the app.

const { endpoints } = await (await fetch("/v1/audio/speech/endpoints")).json()
const ep = endpoints[0] // { id, name, kind, model, voice, controls }
const { voices } = await (await fetch(`/v1/audio/speech/endpoints/${ep.id}/voices`)).json()
const res = await fetch("/v1/audio/speech", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ text, endpointId: ep.id, voice: voices[0].id, speed: 1.1, style: "whispering, nervous" }),
})
const { dataUrl } = await res.json() // play it with new Audio(dataUrl)

Leave out endpointId and pass provider: "edge" with a voice such as en-US-AriaNeural for the free voices. Each service lists the settings it honors in controls: speed and pitch are multipliers (1 is normal; pitch is for Edge voices), style is plain words describing the delivery, and stability and similarity (0 to 1) tune ElevenLabs voices. A service ignores settings it does not list. /v1/audio/speech/endpoints/:id/models lists the speech models the service offers right now, so a new one shows up without a Chrysalis update. Text up to 20,000 characters is read in pieces and returned as one clip.

#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.
llmResponse(ctx, host)hooks and llmAfter another plugin's model call, returning changes to its result text, JSON, reasoning or prefill echo.
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, readMany (many files in one call), readBase64, write (up to 512 MB), append (adds to the end of a file), list and remove, inside the app's data folder only. query, lines and spliceLines work with large data (below).
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.

#Large data

The files stay the source of truth, so the agent can still read and edit them. These calls only save the plugin from reading everything. They arrived in Chrysalis 1.0.6, so check for them (typeof host.fs.query === "function") and keep a plain path for older servers.

host.fs.query(spec, options) reads fields from many JSON files at once. Chrysalis keeps an index beside your data and refreshes an entry whenever its file changes, whoever changed it.

const { rows, total } = host.fs.query(
  { files: "characters/*/card.json", fields: { name: "name", fav: "extensions.fav", hasBook: "character_book?" } },
  { where: { fav: true }, sort: "name", offset: 0, limit: 50 },
)
// rows: [{ path, keys: ["aria"], data: { name, fav, hasBook } }]

A field path ending in ? answers whether the value exists, without the index holding it. where takes a value, a list of values, or {contains, gt, gte, lt, lte, exists, not}. A file that is not valid JSON comes back with error.

host.fs.lines(path, from, to) reads records of a JSONL file by position, and a negative from counts from the end. host.fs.spliceLines(path, from, records) keeps the records before from as they are on disk and writes the new ones after them. Both answer with total and the file's version, which changes whenever the file does. A long transcript can open with its last few hundred messages, and a save writes only what changed.

Chrysalis keeps the index in data/__index/. Plugins cannot write there, and it is left out of history and backups.

#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.

#Large archive imports

Apps can create a file-backed upload with POST /v1/apps/<id>/__imports/new, sending {name, size, collections}. Upload raw chunks with PUT /__imports/<job>?offset=<bytes>, then poll GET /__imports/<job> until indexing finishes. Chunks are limited to 8 MB. Collection prefixes order the archive for imports that resolve links by name.

POST /__imports/<job>/batch with {cursor, route} calls the app’s import route with a bounded host.zip.entries() map. Use the returned cursor for the next batch. Retry the same cursor after a lost response; the most recent completed result is retained. After all batches, call POST /__imports/<job>/finish with {route} to clean up app lookups and commit the written paths to history once. Delete the job after completion. Abandoned jobs expire after seven days.

Text files up to 64 MB arrive whole. A bigger one (a transcript years long) arrives as {__large__: true, size} in a batch of its own: host.zip.read(name, offset) returns {text, next}, whole lines from that byte offset, about 4 MB at a time, with next null after the last slice. Write the result as you go with host.fs.append, so no file is too big for the sandbox. Images up to 256 MB stay on the host. A failed batch rolls back and is tried once more before the import pauses. Import batches may use 1.75 GB of memory and run for an hour.

host.zip.image(name) stores an image entry (PNG, JPEG, WebP or GIF) as media and returns {url, metadata}, with a PNG's text metadata read and stripped from the stored copy. File-backed batches expose image entries as image descriptors so large image bytes stay outside the guest heap. host.fs.image(dataUri) reads image text metadata, including compressed PNG text, and stores stripped artwork, returning {url, metadata}. It requires filesystem permission. host.fs.media(dataUri) writes deduplicated image files; host.fs.lookup(collection, key, value) stores small persistent lookup values for cross-batch links. These services require the existing filesystem and archive permissions. Each app can access only its own upload jobs and media.

#Skill data tools

To make agent inspection efficient, ship .agents/skills/<name>/SKILL.md with a precise description and optional inspect.js and edit.js. Use focused skills for each area of your app rather than loading all schemas for every task. The engine loads only the selected app's skills. Document the inspector's arguments and how to handle missing or ambiguous records in the skill.

export function inspect(args, host) {
  const record = JSON.parse(host.fs.read("items/" + args.id + ".json"));
  return { id: record.id, name: record.name, enabled: record.enabled };
}

Inspectors are self-contained sandboxed JavaScript modules. host.fs.read(path) reads UTF-8 text and host.fs.readVersioned(path) returns {text, revision}, and host.fs.list(path) lists filenames, relative to this app's data/. Node imports and write methods are unavailable. Each call has a 2-second execution limit and a 32 MB sandbox memory limit. Reads are limited to 1 MB per file and 16 MB total, directory listings to 2,000 entries, and returned JSON to 64 KB. Narrow large requests rather than silently omitting results.

For editing, export edit(args, host) from a self-contained edit.js. It has the same read-only sandbox: app code validates its schema and constructs a plan, while the engine performs the writes. Return {writes: [{path, expectedRevision, content}], result}. Paths are relative to app data. Use the revision returned by inspection for an existing file, or null to create a file that must not already exist.

export function edit(args, host) {
  const file = "items/" + args.id + ".json";
  const version = host.fs.readVersioned(file);
  if (version.revision !== args.revision) throw new Error("Inspect the changed record again");
  const record = JSON.parse(version.text);
  if (typeof args.enabled !== "boolean") throw new Error("enabled must be boolean");
  record.enabled = args.enabled;
  return {
    writes: [{ path: file, expectedRevision: args.revision, content: JSON.stringify(record) }],
    result: { id: record.id, enabled: record.enabled }
  };
}

skill_edit is unavailable in Plan mode. It validates every write before saving, checks revisions again at each write, and uses atomic file replacement. If a later write fails, it attempts to restore earlier writes that still contain its own saved content. Concurrent changes are preserved. Only JSON and JSONL are supported, with 1 to 20 unique files, 1 MB per file, an 8 MB plan, and a 64 KB result summary. Module source is limited to 32 KB and arguments to 16 KB. Saved files enter workspace history through the agent's usual commit path. Document unsupported operations and use existing app routes for operations with additional side effects.

Edit this page on GitHub