Chrysalis

The plugin system

Plugins are the server side of an app: they store data, call models and reach web APIs. This page explains how they run and the patterns bigger apps use. For the list of exports and host methods, see Build an app.

#How a plugin runs

Every plugin call runs in QuickJS, a small JavaScript engine compiled to WebAssembly, inside a worker thread of the engine:

  • Fresh every time. Each call loads plugin.js from scratch. Nothing kept in a module variable survives to the next request, so state belongs in host.store or host.fs.
  • Or kept loaded. With "keepLoaded": true in the manifest, the plugin loads once and later calls reuse it, which saves the load on every request (about 20 ms for a large plugin). Module variables may then outlive a call, but not reliably: keep them to constants and reset anything else when a call starts. host works only during its own call. Needs Chrysalis 1.0.6 or newer; older versions ignore it.
  • Synchronous. Exports are plain functions. There is no await on the network or a model; those calls work in passes (below). fetch does not exist inside the sandbox.
  • Logged. console.log and host.log go to the server log, prefixed with the plugin's id.
  • Bounded. One pass may use 1 GB of memory and run for 2 minutes in a route, 256 MB and 10 seconds in anything else. A plugin that runs over, crashes or corrupts the runtime loses only its worker, which restarts on the next call. The engine keeps serving.
  • Side by side. Calls run on several workers at once. GET routes of one app read its folder together; any other call has the folder to itself while it runs, so a read-modify-write is never interleaved.
  • Reads are remembered. A GET route that only read files and the store is answered again from memory until one of those files or folders changes. A route that writes, calls a model or the web, or reads the clock or a random number always runs.

Modern syntax is fine: QuickJS supports ES2023. Helper functions belong at the top level of the module; a function declared inside a block is only visible in that block.

#Splitting a plugin into files

plugin.js is the entry, and the engine calls the exports it has. A bigger plugin can keep its code in more .js or .mjs files anywhere in its folder and import them by relative path. This needs Chrysalis 1.0.5 or newer.

plugins/notes/
  manifest.json
  plugin.js            export { handleRoute } from "./src/routes.js"
  src/
    routes.js          import { readNote } from "./notes.js"
    notes.js

Only files inside the folder load: links, hidden files and node_modules are skipped, and package imports do not resolve. Editing any of the files reloads the plugin on its next call. Shortly after, the engine runs the plugin as one bundled module, which is as fast as a single file.

#Where plugins come from

Plugin folder names use letters, digits, hyphens and underscores. Each plugin has its own permissions and private store.

After upgrading from an engine with underscore-separated plugin IDs, approve imported app plugins again. Standalone plugins with repeated underscores in their names also need approval again. Old private store files remain on disk without being assigned automatically. App files in data/ are unaffected. Disabled plugins stay disabled.

Each folder in an app's plugins/ is one plugin, known to the engine as <app>.<folder>. Its manifest declares what it needs:

{
  "name": "Weather",
  "version": "1.2.0",
  "description": "Current weather for the chat's location.",
  "permissions": ["routes", "network", "store"],
  "networkHosts": ["api.open-meteo.com"],
  "schedule": { "intervalMs": 600000 },
  "priority": 0
}

What a plugin may actually use depends on where it came from:

  • Plugins you or the agent write get the permissions their manifest lists.
  • Plugins from the Store, a git repository, a backup or a plugin import only get the permissions you approved when you installed them. A community app update that asks for more stops and asks again.

Any plugin can be turned off in the app's Plugins dialog. A disabled plugin gets no requests, tools, hooks or timers.

#Requests go to the first plugin that answers

A request to /v1/apps/<app>/<path> is offered to each enabled plugin with the routes permission in turn. The first handleRoute that returns a response wins; returning null passes. Give each plugin its own path prefix so they never compete.

  • Request bodies are capped at 1 MB, or 220 MB for paths under /import/. JSON bodies arrive parsed.
  • A response is { status, json } or { status, text, contentType }. Text responses are served with a sandboxing content security policy, so a route can never act as a live page.
  • When a route answers with a status below 400, the changes it made to the app's data/ are committed to workspace history, so they can be undone like any other edit.

#Model and web calls take passes

Because plugins are synchronous, anything slow happens between two runs of the same function:

  1. Pass 1. The plugin registers what it needs with host.llm.request(key, request), host.net.request(key, request) or host.llm.embed(key, request), and returns { __llmPending: true }.
  2. The engine does the work. It runs the requests one after another with your keys, which never enter the sandbox.
  3. Pass 2. The engine calls the same function again with the same request. The answers are in host.llm.results[key], host.net.results[key] and host.llm.embedResults[key]. The plugin can finish, or ask for more.

A call gets at most three passes, so a route can wait on two rounds of requests before it must answer. One pass may register up to 16 model requests, 32 web requests and 8 embedding requests.

#Carry state between passes

Module variables reset between passes. Return a stash with the pending marker and it comes back on the request as req.stash:

export function handleRoute(req, host) {
  if (req.method !== "POST" || req.path !== "/summarize") return null

  const done = host.llm.results.summary
  if (done) {
    host.fs.write(`summaries/${req.stash.day}.md`, done.text)
    return { status: 200, json: { text: done.text } }
  }

  const day = new Date().toISOString().slice(0, 10)
  host.llm.request("summary", {
    systemPrompt: "Summarize these notes in three sentences.",
    messages: [{ role: "user", content: String(req.body.notes) }],
  })
  return { __llmPending: true, stash: { day } }
}

Store and file writes take effect on every pass, including the first. Write only once you have the answer, as above, so a retried first pass cannot save something twice.

#Model requests

host.llm.request takes the same request whichever provider you use:

Field
messagesRequired. { role, content } turns with user, assistant or system roles.
systemPromptOptional system prompt.
model"provider/model-id" from the model list, or leave it out for the user's default.
reasoningoff, minimal, low, medium, high, xhigh or max on models that think.
presetParams{ temperature, max_tokens, params }, where params passes sampler settings like top_p or min_p to providers that accept them.
cache{ depth, retention } for prompt caching. depth pins the history breakpoint that many turns back on Anthropic models (0 is the last turn). retention is "short", "long" (1h on Anthropic, 24h on OpenAI where the endpoint supports it) or "none" to drop the markers and cache hints. Gemini, DeepSeek and friends cache on their own.
schemaA JSON Schema with an object root. The model is made to answer in that shape and the parsed result lands in json.
tools, wantsToolsTools the model may call (below).
stream{ chatId, name } to stream the reply to the app's page (below).

The result has text, reasoning when the model thought out loud, json for a schema, the model that answered, and usage with tokens and cost. A failed call has model: "error" and the reason in error.

Completed chat generations also return replay: { text, messages }, containing the original assistant blocks, signatures and tool results. Save it unchanged and attach it as replay to the corresponding assistant turn in a later request. Its text must match that turn’s content; the engine replays it only on the original provider, API and model. A different model receives the ordinary reply text. Unsigned thinking can be supplied as reasoning with reasoningModel naming the original model; it is sent as ordinary context only to that model. Aborted replies do not return native replay data.

#Streaming replies to the page

Add a stream descriptor and the reply streams to the app's open pages while the plugin waits for the whole thing:

host.llm.request("reply", {
  messages,
  stream: { chatId: chat.id, name: character.name },
})

The page listens on the WebSocket for app_stream events. Each carries the chatId and name with one of delta (answer text), thinking (reasoning text) or tool (a tool call starting or ending):

ws.onmessage = (ev) => {
  const { type, payload } = JSON.parse(ev.data)
  if (type !== "app_stream" || payload.chatId !== openChatId) return
  if (payload.delta) appendToBubble(payload.delta)
}

To stop a reply, post { chatId } to /v1/apps/<app>/__abort. The provider stream is cancelled and the plugin's second pass receives an empty result, so the page decides what to keep.

#Giving models tools

A plugin with the tools permission can let a model call its functions. List the tools on the request and handle calls in handleTool:

const ROLL = {
  name: "roll_dice",
  description: "Roll dice written like 2d6.",
  parameters: { type: "object", properties: { dice: { type: "string" } }, required: ["dice"] },
}

export function handleTool(name, args, host) {
  if (name !== "roll_dice") return { text: "unknown tool", isError: true }
  const [count, sides] = args.dice.split("d").map(Number)
  let total = 0
  for (let i = 0; i < count; i++) total += 1 + Math.floor(Math.random() * sides)
  return { text: String(total) }
}

// in a route: host.llm.request("reply", { messages, tools: [ROLL] })

The engine runs the tool loop: each call the model makes runs handleTool in its own sandbox call, which can itself use host.llm, host.net and embeddings in passes. A model request made inside a tool gets no tools of its own. Up to 16 tools fit on one request, with names made of letters, digits, _ and -.

#Tools from other plugins

A dedicated tools plugin can offer tools to every model request in its app. It exports appTools(ctx, host) returning { tools: [...] }, and any sibling plugin that marks a request with wantsTools: true gets them, with calls routed back to the plugin that offered each one. Roleplay works this way: its chat plugin asks for tools, and its separate tools plugin supplies dice, pictures and custom tools. Turn the tools plugin off and tool calling is gone.

OpenAI Chat Completions and Responses keep system messages in their supplied positions. Native Anthropic and Gemini requests carry leading system instructions separately and convert later system messages to user messages in place.

#Adjusting other plugins' prompts

A plugin with both hooks and llm can export llmRequest(ctx, host). It runs before every model request another plugin in the same app makes, sees the request as ctx.request (with ctx.plugin and ctx.key naming who asked), and returns the fields to change:

export function llmRequest(ctx, host) {
  const facts = host.store.get("facts") || []
  if (!facts.length) return null
  return {
    systemPrompt: `${ctx.request.systemPrompt || ""}\n\nThings to remember:\n${facts.join("\n")}`,
  }
}
  • Only messages, systemPrompt, model, sessionId, reasoning, thinkingBudget, reasoningTags, assistantPrefill, promptFormat, presetParams, cache, schema, streamMuted and streamDecode can be changed. Tools, streaming and cancelling stay with the engine. streamMuted: true keeps a generation out of the live stream while it still runs and commits; Stop still cancels it. streamDecode: { fields, replace? } is for wire formats: deltas are decoded live out of those JSON string fields, in wire order (with optional literal replacements), so the real text streams while the model writes it.
  • Several hooks run in order of the manifest's priority, lowest first, so the highest has the last word. Ties go by plugin id.
  • A hook that crashes is skipped and the request goes ahead. Model requests a hook makes itself are not hooked again.

This is how you add a feature to an app without touching its code: drop in a plugin that adds memory, a style guide or a translation step to every prompt.

A preview route with llm permission can return { status: 200, __llmPreview: "reply", json: request }. The engine runs the same request hooks using that key and returns the patched request, without generating a reply. Use the same key and request fields as the real send. Hooks run normally, so their own side effects still apply.

Stream decoding selects top-level string fields only, in the order they arrive. Nested metadata is skipped. Escapes and literal replacements can span chunks. If no selected field appears, decoding falls back to at most 64 KB of the raw response.

#Adjusting what comes back

The same plugin permissions also allow llmResponse(ctx, host), which runs after a sibling's model call finishes. It sees the request that was sent as ctx.request and the result as ctx.result, and returns the result fields to change:

export function llmResponse(ctx, host) {
  const text = ctx.result.text
  if (!text.startsWith("{")) return null
  try {
    const parsed = JSON.parse(text)
    return { text: parsed.response || text }
  } catch {
    return null
  }
}
  • Only text, json, reasoning and assistantPrefill can be changed. Usage, timing, the model name and the tool trace stay as measured. Text, reasoning and assistant prefill patches must be strings; an invalid value is ignored while other valid fields still apply.
  • Ordering and crash rules match llmRequest: lowest priority first, each hook sees the result as patched so far, a crashed hook is skipped.
  • Clear assistantPrefill when the hook consumed the prefill turn, or the caller will prepend the prefill echo to the reply again.

#Web requests

host.net.request(key, { url, method, headers, body, form, timeoutMs, maxBytes, json, binary }) works in passes like a model call. The result has ok, status, headers, the final url, and text, json or base64.

  • Only listed hosts. Requests go only to hosts in the manifest's networkHosts, exact names or *.example.com. A plugin that lists none has no network at all.
  • Never your network. Addresses on your computer or local network are refused, and each redirect is checked again (up to 5).
  • No Chrysalis credentials. Requests carry only the headers the plugin sets.
  • Limits. 15 seconds by default and 60 at most; 5 MB of response by default and 20 MB at most.

#Embeddings

Any plugin with llm can turn text into vectors with host.llm.embed(key, { texts, model }) on one pass and read host.llm.embedResults[key], an array of number arrays, on the next. Store the vectors with host.fs to build search or long-term memory. The embedding model comes from Settings unless the request names one.

#Fast pattern matching

Plugin code runs in an interpreter, and a regular expression that scans long text can take seconds there. host.regex.matches(source, flags, text) runs the pattern in Chrysalis itself and returns every match as { index, match, groups, named }, one entry when the flags have no g. A group that did not take part is null. Build the replacement text in the plugin. No permission is needed.

#Timers, updates and panels

ExportWhat it is for
onTick(ctx, host)Background work on the manifest's schedule.intervalMs, at least every 5 seconds. Needs the schedule permission. Timers run for every installed app from the moment Chrysalis starts, each tick runs the plugin's current code, and a tick never starts while the previous one is still running. Turning a plugin on or off applies at once; a new plugin or interval is picked up within 10 seconds of using Chrysalis. What onTick returns reaches the app's open pages as a plugin_event with app, plugin and payload.
onAppUpdate(ctx, host)Runs after an update lands, with ctx.from and ctx.to versions, to upgrade stored data, on a sandbox of its own with up to five minutes and 512 MB. If it throws, the update reports it and it runs again, from the same ctx.from, before the app's next request.
uiPanel(ctx, host)Returns a description of a settings panel with at least a label. The app lists every plugin's panel from /v1/apps/<app>/__panels and renders them.

#Settings panels

A panel is { label, icon?, hint?, items, create? }. Each item is { id, title, subtitle?, badge?, enabled, saveUrl, deleteUrl?, deleteLabel?, fields?, note? }:

  • The switch beside an item toggles it with PUT saveUrl { enabled }. Opening it shows its fields and a Save button that sends PUT saveUrl { enabled, values }, where values maps field keys to strings.
  • Each field is { key, label, kind, value, options?, hint? }. Kinds: text, number, textarea, code, toggle (sends "on"/"off"), select (with options: [{ value, label }]), model (a picker over the engine's model catalog, empty value means the chat's model), and section, a heading row that carries no value.
  • create: { url, label } adds a New button. POST to that URL returns a fresh item to edit; nothing is stored until its first save.
  • Panel URLs are app-relative routes your plugin answers with handleRoute.

#Reading uploaded zips

Importers use the zip permission. When a request body includes zipBase64, host.zip.entries() returns the archive's text files as { "path": "contents" }, with binary files marked. Archives are checked before unpacking: at most 200 MB, 5,000 files and 256 MB uncompressed, and no paths that climb out.

Edit this page on GitHub