Extensions

How cherry π grows: first-party addons, .pi apps anyone can write, and Cellar Door's worlds.

Updated Field manual What if… Brand kit Download Markdown llms.txt llms-full.txt

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.

ComparedAddons.pi apps
Made bycherry πAnyone
ShipInside cherrypi.ape. You can't add one.As one .pi file, or a folder while you write it
Plug intoRegistries the core owns: P2P, signers, model backends, knowledge, worlds, terminal carriers, picache verbs, desktop methods, goal gates, app providersVerbs, Picache tools, panes, terminal programs, hooks, layouts
RunIn the harness, and may start a sidecarIn a capsule, holding only what you granted
Turn onpicache 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

IdWhat it doesStarts by itselfNeeds
knowledgeKeeps the agents' knowledge store, and indexes the capability atlas and the shipped docs. Picache's knowledge tool and picache kb need it.YesNothing
p2p_holepunchRuns the P2P node and its sidecar. Joining the public network is your call.Nosidecar.run, network.peers, p2p.peers.read, p2p.session.open, p2p.session.send
cellardoorRuns one Cellar Door sidecar for each layer with a world open (section 03).Nosidecar.run
factoryThe software factory: picache factory, its product panes, Try, and the gate a factory build passes before its goal is accepted.YesNothing
sakuraThe Sakura terminal carrier, which Open in browser and browser attachments use.YesNothing

p2p_holepunch conflicts with p2p_libp2p and p2p_iroh, so only one P2P backend can run.

What an addon's state means

StateMeans
discovered, configuredIn the build, and not turned on.
waitingTurned on, but a capability it needs isn't granted. Not an error.
enabled, runningStarted, and its parts are registered.
errorA step failed. What it registered is removed.
disabledStopped 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.

RegistryFor
p2p.backendA P2P transport: connect by key, listen, carry sessions
p2p.discoveryA source of peers: the LAN, mDNS, a DHT topic
signerA signing service for the keys this machine holds
model.backendAn inference backend for model steps
knowledge.storeWhere agents' knowledge is kept and searched
knowledge.sourceKnowledge an agent may be given: a shelf, docs, an atlas
knowledge.embedderEmbeddings for semantic search. Keyword search doesn't use it.
world.providerA world run per layer: open, close, health, one-time console tickets
terminal.carrierA way to reach a running terminal: attach, read, input, resize, detach, resume
picache.verbA picache verb, under the same policy and receipts as the built-in ones
control.methodA method the desktop calls on this machine's harness
goal.gateA check a goal must pass before it is accepted. A goal naming a gate that isn't registered is refused.
app.providerApps 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 isInstall
Signed by this machine or a paired oneInstalls at once
Unsigned, signed by someone else, or a folderAsks you first. --yes answers ahead. Without a terminal it's refused.
Changed since it was signedRefused, 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

CommandDoes
picache app listInstalled 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" } } } } },
  },
}
FieldRules
schemaAlways cherrypi.app.v1
idStarts with a letter and holds only lowercase letters, digits and _ . -. At most 64 characters, and not the name of a picache verb.
name, version, descriptionShown in the list and the Extensions pane
runtimeLeave it out, or "lua", the only runtime.
capabilitiesEverything the app may ask for (below)
storageIts own tables (below)
settingsUp 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.
contributionsWhat 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.

KindRuns underYou reach it
verbsapp.capabilitypicache <id> <verb>
toolstools.customAs Picache's tool app_<id>_<tool>, and over MCP, while granted
panesapp.capabilityA pane drawn from A2UI, from the Extensions pane
panes with program = trueapp.terminalIts own program in a terminal pane, since a program there hears every key
hooksapp.hookHeard on calls and model requests (below)
layoutsapp.layoutA 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.

PointHearsMay answer
before_callA call before it runsBlock it, warn, or propose another verb
after_resultThe resultA note
contextWhat a model is about to be givenText to add
routeA model request: user, continuation, retry or directSeat, 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.

GroupCapabilities
Adding thingsapp.capability, tools.custom, app.terminal, app.hook, app.layout, app.world
Its data and pagesappdata:own, domain.serve
Other appsapp.list, app.inspect, app.build
Loopsai.redloop.read, ai.redloop.manage, loop.durable
Modelsroute.seat.agent, route.seat.small, route.seat.cloud, route.params, route.load, route.handoff, route.read.prompt, route.read.messages, route.read.tools
Agent servicesskills.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.

VerbUnderDoes
apps.db.query, apps.db.execappdata:ownRead or write the app's own tables
apps.listapp.listInstalled apps
apps.inspect, apps.rows, apps.queryapp.inspectLook inside another app's file, read-only
apps.edit, apps.packapp.buildChange or pack another app. Asks you every time.
loops.read, loops.manageai.redloop.read, ai.redloop.manageRead, start, stop and define loops. Defining or starting one asks you.
route.historyapp.hookRecent routing decisions, up to 50
layout.applyapp.layoutOpen one of its layouts. Asks you every time.
world.openapp.worldOpen 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.
  • require reaches 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.sql may hold only CREATE TABLE and CREATE 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 mirror is "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.

PartWhat it isComes as
The cellardoor addonThe 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 runA 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, chat spots and doors.
  • 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.

KeyDoes
W S or ↑ ↓Forward, back
A D or ← →Step left, right
Z XTurn left, right
Space or EGo through the door in reach
CChat at the spot in reach. Enter sends, Esc closes, Ctrl+U clears.
Q or EscLeave 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

WhatWhere
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 inReceipts: 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.

One world, two members Two machines share a live layer. Each runs its own Cellar Door sidecar holding the whole world. Between them run two channels. The log carries what changes the world for everyone, the edits and chat proposed to a sidecar, in one order on every member, kept in the layer's shared history; each sidecar replays it. Motion carries where each person is, directly between the members, up to 20 frames a second, kept nowhere. Everything else each machine works out from the world's files, the log and the clock. MACHINE A Cellar Door sidecar the whole world replays the log harness stamps who wrote it checks each row MACHINE B Cellar Door sidecar the whole world replays the log harness stamps who wrote it checks each row LOG · ORDERED · KEPT edits and chat, one order everywhere MOTION · DIRECT · KEPT NOWHERE where each person is, ≤20/s
Two channels keep every member's copy of the world the same.
WhatHow it crosses
Edits and chat proposed to a sidecarThe log: one order on every member, kept in the layer's shared history. Each sidecar replays it.
Where each person isMotion: straight to the other members, up to 20 frames a second, kept nowhere
Everything elseNothing. 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.