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.jsfrom scratch. Nothing kept in a module variable survives to the next request, so state belongs inhost.storeorhost.fs. - Synchronous. Exports are plain functions. There is no
awaiton the network or a model; those calls work in passes (below).fetchdoes not exist inside the sandbox. - Logged.
console.logandhost.loggo to the server log, prefixed with the plugin's id. - Bounded. One pass may run for 10 seconds and use 64 MB of memory. A plugin that runs over, crashes or corrupts the runtime loses only its worker, which restarts on the next call. The engine keeps serving.
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.
#Where plugins come from
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:
- Pass 1. The plugin registers what it needs with
host.llm.request(key, request),host.net.request(key, request)orhost.llm.embed(key, request), and returns{ __llmPending: true }. - The engine does the work. It runs the requests one after another with your keys, which never enter the sandbox.
- 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]andhost.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 | |
|---|---|
messages | Required. { role, content } turns with user, assistant or system roles. |
systemPrompt | Optional system prompt. |
model | "provider/model-id" from the model list, or leave it out for the user's default. |
reasoning | off, 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. |
schema | A JSON Schema with an object root. The model is made to answer in that shape and the parsed result lands in json. |
tools, wantsTools | Tools 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.
#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.
#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,presetParamsandschemacan be changed. Tools, streaming and cancelling stay with the engine. - 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.
#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.
#Timers, updates and panels
| Export | What 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. |
#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.