Two ways to extend
Extensions come in two tiers. Addons are cherry π's own parts,
shipped inside cherrypi.ape. Apps are
.pi files anyone can write.
| Compared | Addons | .pi apps |
|---|---|---|
| Made by | cherry π | Anyone |
| Ship | Inside cherrypi.ape. You can't add one. | As one .pi file, or a folder while you write it |
| Plug into | Registries the core owns: P2P, signers, model backends, knowledge, worlds, terminal carriers, picache verbs, desktop methods, goal gates, app providers | Verbs, Picache tools, panes, terminal programs, hooks, layouts |
| Run | In the harness, and may start a sidecar | In a capsule, holding only what you granted |
| Turn on | picache addons enable <id> | picache app install <file>, then grant |
The core is never a plugin: authority and policy, receipts, confinement, the addon loader and the verb registry stay in cherry π itself.
Addons
An addon is a first-party part of cherry π that plugs into a
registry the core owns. Addons are found only inside the APE, so
nobody can drop one in. Code of your own goes in a
.pi app.
List and turn on addons
picache addons # every addon in this build
picache addons enable cellardoor # grant what it needs and start it
picache addons disable cellardoor # stop it
Each line of the list shows the addon's id, its state, its version and what it needs.
Enabling an addon grants every capability it declares. Only you
can enable or disable one, and anyone else is told
operator_decides.
Your choice is kept as the setting
addon.<id>.enabled, so it holds across restarts.
Two buttons do the same for you: Turn P2P on in Machines, and Open a world on this layer in Actions, which needs Cellar Door enabled.
The addons in this build
| Id | What it does | Starts by itself | Needs |
|---|---|---|---|
knowledge | Keeps the agents' knowledge store, and indexes the capability atlas and the shipped docs. Picache's knowledge tool and picache kb need it. | Yes | Nothing |
p2p_holepunch | Runs the P2P node and its sidecar. Joining the public network is your call. | No | sidecar.run, network.peers, p2p.peers.read, p2p.session.open, p2p.session.send |
cellardoor | Runs one Cellar Door sidecar for each layer with a world open (section 03). | No | sidecar.run |
factory | The software factory: picache factory, its product panes, Try, and the gate a factory build passes before its goal is accepted. | Yes | Nothing |
sakura | The Sakura terminal carrier, which Open in browser and browser attachments use. | Yes | Nothing |
p2p_holepunch conflicts with p2p_libp2p
and p2p_iroh, so only one P2P backend can run.
What an addon's state means
| State | Means |
|---|---|
discovered, configured | In the build, and not turned on. |
waiting | Turned on, but a capability it needs isn't granted. Not an error. |
enabled, running | Started, and its parts are registered. |
error | A step failed. What it registered is removed. |
disabled | Stopped by you or at shutdown. |
Addons start in dependency order. Two addons that conflict are refused, and so is a missing dependency or a cycle.
A failure never stops the harness. It is recorded in three places:
- a line in
<state root>/logs/addons.log - a receipt,
addon.activation_failed - an event of the same name to the desktop.
Where addons plug in
An addon can register only in these places. Anything else is
refused with registry_unknown. When an addon stops,
everything it registered goes with it.
| Registry | For |
|---|---|
p2p.backend | A P2P transport: connect by key, listen, carry sessions |
p2p.discovery | A source of peers: the LAN, mDNS, a DHT topic |
signer | A signing service for the keys this machine holds |
model.backend | An inference backend for model steps |
knowledge.store | Where agents' knowledge is kept and searched |
knowledge.source | Knowledge an agent may be given: a shelf, docs, an atlas |
knowledge.embedder | Embeddings for semantic search. Keyword search doesn't use it. |
world.provider | A world run per layer: open, close, health, one-time console tickets |
terminal.carrier | A way to reach a running terminal: attach, read, input, resize, detach, resume |
picache.verb | A picache verb, under the same policy and receipts as the built-in ones |
control.method | A method the desktop calls on this machine's harness |
goal.gate | A check a goal must pass before it is accepted. A goal naming a gate that isn't registered is refused. |
app.provider | Apps reached under the provider's name: their panes, programs, widgets and data |
An addon can declare only these capabilities:
sidecar.run, network.peers,
p2p.peers.read, p2p.presence.read,
p2p.session.open, p2p.session.send and
p2p.bond.manage. None of them can be granted to an
app.
.pi apps
A .pi app is one SQLite file that holds the whole
app: its manifest and signature, its Lua handlers, its assets and
its own data. The field manual
shows what is signed and what isn't.
Install an app
picache app install notes.pi # a packed app
picache app install ./notes # a folder you're writing, run in place
picache app grant notes appdata:own # grant one capability, for this session
picache app grant notes appdata:own --always
picache <id> <verb> # an app's verb, such as picache notes add
Whether you're asked depends on who signed it.
| The app is | Install |
|---|---|
| Signed by this machine or a paired one | Installs at once |
| Unsigned, signed by someone else, or a folder | Asks you first. --yes answers ahead. Without a terminal it's refused. |
| Changed since it was signed | Refused, with the reason (app_signature_invalid) |
A packed app is copied to <state root>/apps/<id>.pi.
A folder runs where it is and is marked unsigned wherever it
shows.
Grants are tied to the app's exact content. If the file changes,
none of it runs until you review it and run
picache app accept <id>. For a folder you're
editing, set apps.dev_changes to follow
to keep its grants. Each edit is still receipted.
Use the Extensions pane (Actions → Open Extensions) to do the same with buttons: install, grant, deny, open an app's pane, remove.
App commands
| Command | Does |
|---|---|
picache app list | Installed apps |
picache app show <id> | One app: what it asks for, what it adds, its grants |
picache app install <file.pi | folder> [--replace [--reset]] | Install. --replace upgrades and keeps its data, and --reset starts empty. |
picache app remove <id> | Remove it, its grants and settings. A folder itself is left alone. |
picache app pack [folder] [--out <file.pi>] | Pack a folder into a .pi, signed by this machine |
picache app grant <id> <capability> [--always] | Grant for this session, or always |
picache app deny <id> <capability> | Take a grant back |
picache app accept <id> | Accept a changed app after reviewing it |
picache app settings <id>, set <id> <key> <value> | Read or change the app's own settings. A secret is piped in (pbpaste | picache app set <id> <key>), shown only as set or not set, and --clear forgets it |
picache app test <id> <hook|verb|tool|pane> <name> [--q <json>] [--fixtures <file>] | Run one part in a real capsule |
picache app serve <id>, unserve <id> | Serve its pages at https://<id>.pi (section 11 of the manual) |
Installing, removing, packing, testing, granting, accepting,
changing settings and serving are yours alone. Anyone else is told
operator_decides.
Write the manifest
An app folder has manifest.lua,
handlers/main.lua, and optionally
schema.sql and assets/. The manifest is
data, never code: a function or require in it is
refused.
return {
schema = "cherrypi.app.v1",
id = "notes", -- lowercase, up to 64 characters
name = "Notes",
version = "0.1.0",
capabilities = { "app.capability", "tools.custom", "appdata:own" },
storage = { tables = { { name = "notes", mirror = "none", schema_version = 1 } } },
settings = { { key = "sort", type = "choice", default = "newest", choices = { "newest", "oldest" } } },
contributions = {
verbs = { { name = "add", summary = "add a note" } },
tools = { { name = "search", summary = "search notes",
parameters = { type = "object", properties = { q = { type = "string" } } } } },
},
}
| Field | Rules |
|---|---|
schema | Always cherrypi.app.v1 |
id | Starts with a letter and holds only lowercase letters, digits and _ . -. At most 64 characters, and not the name of a picache verb. |
name, version, description | Shown in the list and the Extensions pane |
runtime | Leave it out, or "lua", the only runtime. |
capabilities | Everything the app may ask for (below) |
storage | Its own tables (below) |
settings | Up to 32: string, choice, boolean, number or secret. Kept as app.<id>.<key> and handed to every call. A secret (an API key your app calls a service with) has no default. It's sealed with your other keys, never in the settings, and opened only into your app's own call (Where your secrets go). |
web | { root, index }: pages to serve. Needs domain.serve. |
contributions | What it adds (below) |
What an app can add
Each contribution has a name (lowercase, at most 48),
a summary, and the capability it runs under, which the manifest
must also ask for.
| Kind | Runs under | You reach it |
|---|---|---|
verbs | app.capability | picache <id> <verb> |
tools | tools.custom | As Picache's tool app_<id>_<tool>, and over MCP, while granted |
panes | app.capability | A pane drawn from A2UI, from the Extensions pane |
panes with program = true | app.terminal | Its own program in a terminal pane, since a program there hears every key |
hooks | app.hook | Heard on calls and model requests (below) |
layouts | app.layout | A saved layout it may open, with the commands its terminals may run |
handlers/main.lua returns a table with a function for
each: verbs.<name>(q),
tools.<name>(q), hooks.<name>(q),
and for a pane render and action, or
start, key, resize and
tick for a program.
A layout lists every command its terminals may run, and
layout.apply asks you each time. A plain shell or an
agent pane is refused, and so is anything over 16 panes.
Hooks
A hook hears a call at one of four points. Its answer is labelled as the app's, and it can never approve, change a call or answer a question cherry π asks you.
| Point | Hears | May answer |
|---|---|---|
before_call | A call before it runs | Block it, warn, or propose another verb |
after_result | The result | A note |
context | What a model is about to be given | Text to add |
route | A model request: user, continuation, retry or direct | Seat, model, thinking, tokens, temperature or a handoff, each under its own route.* grant |
A hook has two seconds to answer. An answer that is slow, fails or
is unclear counts as no opinion. Its text is cut at 500 bytes. A
block refuses the call with hook_blocked, naming the
app.
Capabilities
An app holds only what you grant, checked at every call. Each capability it asks for is granted separately.
| Group | Capabilities |
|---|---|
| Adding things | app.capability, tools.custom, app.terminal, app.hook, app.layout, app.world |
| Its data and pages | appdata:own, domain.serve |
| Other apps | app.list, app.inspect, app.build |
| Loops | ai.redloop.read, ai.redloop.manage, loop.durable |
| Models | route.seat.agent, route.seat.small, route.seat.cloud, route.params, route.load, route.handoff, route.read.prompt, route.read.messages, route.read.tools |
| Agent services | skills.propose, skills.retrieve, memory.query, memory.propose, search.files, decision.answer |
Some are never an app's at any level: your files
(drive.*), the
addon capabilities, and the core's own,
such as store.write and approval.resolve.
A manifest that asks for one is refused.
A grant is for this session or always. A session grant ends when the harness restarts, and you're asked again. Deny takes it back, and a program pane running under it ends at its next call.
Ask cherry π to do something
A handler can't reach cherry π directly. It answers
{ ask = { verb, args }, cont }, and the harness runs
the verb and calls the handler again with the result. One call
may ask up to 16 times, and every ask is receipted.
| Verb | Under | Does |
|---|---|---|
apps.db.query, apps.db.exec | appdata:own | Read or write the app's own tables |
apps.list | app.list | Installed apps |
apps.inspect, apps.rows, apps.query | app.inspect | Look inside another app's file, read-only |
apps.edit, apps.pack | app.build | Change or pack another app. Asks you every time. |
loops.read, loops.manage | ai.redloop.read, ai.redloop.manage | Read, start, stop and define loops. Defining or starting one asks you. |
route.history | app.hook | Recent routing decisions, up to 50 |
layout.apply | app.layout | Open one of its layouts. Asks you every time. |
world.open | app.world | Open the world on a layer. Asks you every time. |
The capsule
Handlers are Lua, and run in a
capsule sealed to the one
capability the call is under. On a machine that can't confine,
apps don't run (app_unconfinable).
- They get Lua's string, table, math and utf8 libraries, plus JSON, and have no file, process or network access.
requirereaches only the app's own handler files.- An answer has 10 seconds and at most 256 KiB.
- Work longer than one call is a loop that the harness steps and recovers, so no app code is left running. A loop has at most 50 steps.
An app's own data
Tables declared in storage and created by
schema.sql are the app's to write. They sit outside
the signature, so writing them never breaks it.
schema.sqlmay hold onlyCREATE TABLEandCREATE INDEX.- Each read or write is one statement. Writes go only to the declared tables.
- Writes are journaled for sync with paired machines, except in a table whose
mirroris"none". - Declaring tables needs
appdata:own.
install --replace carries the rows into the new
version and runs its migrations. A version that drops a column or
changes a table without a migration is refused before anything is
written. The old file is kept in
<state root>/apps/backups/.
Meta, the first app
Meta inspects and packs .pi files, itself included.
Install it with picache app install apps/meta from a
checkout. It adds the verbs list,
inspect, rows, query,
pack and edit, an Inspector pane and a
SQL program pane. It asks you each time it signs.
Cellar Door worlds
A world is a place you walk through in a terminal pane: scenes of animated art, doors between them, and spots to chat. Cellar Door runs it, and cherry π gives it a layer, panes and, when the layer is shared live, the other members.
Cellar Door comes in two parts.
| Part | What it is | Comes as |
|---|---|---|
The cellardoor addon | The engine. It runs one headless Cellar Door sidecar for each layer with a world open. | Inside cherrypi.ape for macOS (ARM) and Linux (ARM, x64) |
A world app (.pi) | A world with its layout: which panes open, and the commands they may run | A developer setup (below) |
What a world is
A world is a folder with a world.json and its scenes.
Put it in the layer's folder, either at the top or in
world/.
- Scenes are the places, each a file under
scenes/. - A scene holds objects:
art,chatspots anddoors. - A door leads to another scene, or to another world by its address.
world.json names no layer, pane or path of cherry π's,
so the same world runs in Cellar Door on its own.
No world ships with cherry π. AFTERHOURS, from the Cellar Door repository, is the one to try: three scenes, 20 animated pieces, three chat spots and doors in a loop.
Open a world
picache addons enable cellardoor # once
Then, in the layer, choose Actions → Open a world on this layer. Its sidecar starts, and you're told how to walk in.
An app with app.world can open one with the
host verb world.open. You're asked every time, and shown
the layer and its folder.
A layer has one world at most. It closes when the layer closes, and its sidecar is stopped.
Walk in
cherrypi-world attach # in a pane on that layer
cherrypi-world attach --wait # wait up to 15 s (at most 60) for it to start
The pane becomes a console on the world, drawn in text. Ctrl+] leaves it and gives you the pane back.
| Key | Does |
|---|---|
| W S or ↑ ↓ | Forward, back |
| A D or ← → | Step left, right |
| Z X | Turn left, right |
| Space or E | Go through the door in reach |
| C | Chat at the spot in reach. Enter sends, Esc closes, Ctrl+U clears. |
| Q or Esc | Leave the world |
Who can walk in
Two kinds of pane can attach:
- Your own shell, as you.
- A pane an app's layout opened, as that app, while it holds
app.world.
Anything else is refused with world_attach_refused.
The console never names a layer or a person. The harness works
that out and vouches for it, and each console gets a one-time
ticket that lasts 10 seconds. Attaching gives you presence in the
world and no right to change it.
Where a world's files go
| What | Where |
|---|---|
| The sidecar's state | .cellardoor/ in the layer's folder, beside the world, never inside it |
| Its control socket | /tmp/cherrypi-<uid>/<tag>.world.sock, named for <state root>/worlds/<layer>, in a folder only you can open |
| Its output | <state root>/worlds/<layer>/sidecar.log |
| Starts, restarts and stops | <state root>/logs/addons.log, and the events world.sidecar_exited and world.sidecar_killed |
| Opening, closing, each console let in | Receipts: world.open, world.close, world.admit |
A sidecar that exits is started again after 1, 2, 4… up to 60 seconds. Closing sends it SIGTERM, then SIGKILL five seconds later if it's still there. The sidecar runs unconfined, as a job does.
A world app's layout
A world app brings its layout: a world pane, a room for chat, files beside it. It declares the layout and every command the layout's terminals may run.
capabilities = { "app.layout" },
contributions = { layouts = {
{ name = "world", asset = "layouts/world.json",
runs = { { "bin/studio", "-world", "assets/worlds/afterhours",
"-control", "world.sock" } } },
} }
Apps run Lua only, so a .pi can't carry Cellar Door's
program. The layer's folder has to be a Cellar Door checkout with
the program built.
Share a world
When a layer is shared live, its world is shared too. Every member runs the whole world on their own machine. There's no world server and no relay.
| What | How it crosses |
|---|---|
| Edits and chat proposed to a sidecar | The log: one order on every member, kept in the layer's shared history. Each sidecar replays it. |
| Where each person is | Motion: straight to the other members, up to 20 frames a second, kept nowhere |
| Everything else | Nothing. Each machine works it out from the world's files, the log and the clock. |
The harness writes each event with the machine it came from. The world never says who it is. A row that lies about who wrote it is skipped on every member, so every copy stays the same.
Motion goes over the connections the layer already uses. Only the layer's other members can send it. A frame is at most 1 KiB, and anything faster than 20 a second is dropped. Losing a frame is harmless, because each one carries the latest position.
If your harness restarts, the sidecar replays the log from the start and carries on.
A world whose characters move by themselves, such as followers,
is not shared, because on two machines they'd drift apart. It
runs on each machine alone, and you're told why
(world_rules_unshared).
Edits reach the log from Cellar Door's Studio, started with
-propose and the sidecar's socket. A console proposes
nothing, so chat typed in a console stays on that machine.