Cells & Morphs (experimental)
Morph the desktop app with one JSON file: panels you work in (notes, boards, timers, feeds), chips, commands, chords and rules for the agent. No code to write.
Experimental. Cells & Morphs ship behind an experimental label: the vocabulary grows every release, and a cell written today keeps working (an older app renders a newer cell's fallback).
A cell is one JSON file. The desktop app reads it, validates it, and morphs: a panel appears where a built-in one would, a chip in the status bar, a /command in the composer, a key chord, suggestions on the empty tab, the app's accent. Save the file and the change shows within a second. Delete it and everything goes back.
The agent builds cells for you. Open Cells & Morphs from the ... menu (or /morphs) and click New cell, then say what you want on screen — "a notebook", "my tracker as a board I can drag", "a pomodoro that chimes".
Work it directly
A cell's panel is yours to use, not a row of buttons that write prompts. Every control has three ways to act, and the author picks per control:
- Do it here. A field stores what you type; a list edits itself (rename, delete, reorder, tick); a toggle, a slider and a checkbox write straight to the cell's state; a timer counts down and fires when it hits zero; a button can
set,append,remove,toggleorincstate; acallwrites to the cell's own API (a tracker transition, a label, a post) — run on the sidecar, confirmed by you unless the author says otherwise, the board refreshed after. - Ask the agent. A
promptis staged in the composer for you to send;send: truesends it at once. Only where the agent is genuinely needed. - Show.
open: "surface"brings the cell's own panel forward (its pane tab, centre tab or floating card);open: "<popup>"raises one of its dialogs.
A search box that stores q and a feed whose URL reads {{state.q | url}} is search → results with no agent in the loop. A gallery item or a row with attach puts its image into your composer as a real attachment.
A cell has two layers. The UI layer morphs what you see, and every piece lands where a shipped feature would: a tab in the context pane, a section above the file tree, a button in the title bar, an entry in the command palette. The agent layer shapes the agent: rules it follows, lookups it may call, guards on its tools, state it may write.
The UI layer
| Morph | What it puts in the app | Key |
|---|---|---|
| Surface | A panel: stats, progress, badges, key/value rows, a board of grouped rows, action buttons, plus fields (typed input stored or staged), notes (your own editable list), check / toggle / slider (bound to state), timer, code, gallery (images), kanban (columns you drag between), selects, tables, tabs, lists, charts, frames. place decides where: tab (a full pane of its own — the default for a panel with things to do), side (a tab in the context pane), sidebar (a section above the file tree), float (a corner card). chrome: "plain" drops the card frame; you have the same switch for every cell | surface |
| Popup | A dialog, a right-side sheet, or a bottom drawer of blocks and actions, opened by id from a button, an action, or a palette entry | popups |
| Palette | An entry in the ⌘K command palette | palette |
| Status | One chip in the status bar. Its text can count live rows: {{pulses.jira.count}} open | status |
| Pulse | A JSON feed the app fetches on a heartbeat and pours into the board as rows | pulses |
| Command | /name in the composer. Enter sends the prompt; anything typed after the command is appended | commands |
| Reflex | A key chord that stages a prompt: mod+shift+1 | reflexes |
| Welcome | Suggestion buttons that lead the empty tab | welcome |
| Toolbar | Buttons in the title bar beside the app's own. One click shows the panel, edits state, calls the API, stages a prompt, opens a link, or copies text | toolbar |
| Trigger | A reaction to an event: a turn ends, files change, a pulse's rows arrive or its beat fails, a timer hits zero, the clock reaches 09:30 on weekdays. The reaction is a notice, a chime, an OS notification, or a prompt staged in an empty composer | triggers |
| Pigment | While the cell is live, the app takes the cell's accent colour — and, with theme, wears a theme (nord, dracula, one of yours); your own comes back when the cell goes off. backdrop puts a picture behind the whole thread | pigment |
| Helper | The local process the cell needs (a script that reads Spotify, tails a build, polls a sensor). The app starts it when the cell goes live and stops it with the cell | helper |
An action may carry layout: { "sidebar": false, "map": false, "context": true, "terminal": false } — a Focus button that arranges the window, only on the click. A desk may carry mode, model and skills: the tab it opens starts in that mode, on that model, with those skills loaded ($skill tokens in the opener) — a review desk in architect mode on a bigger model, a ticket desk in plan mode with the team's conventions.
Every control acts on your click, keystroke, or Enter. A trigger may speak (a notice, a chime, an OS banner) or offer (a prompt in an empty composer); it never sends a prompt by itself. A cell adds to the app and never removes, hides, or renames anything the app already has.
Settings, conditions, and where a cell lives
Settings. A cell may declare vars — its parameters. {{var:HOST}} works anywhere in the file: a URL, a header, a prompt, a row template. Your values are typed once in Cells & Morphs and kept out of the file, so one shared cell fits every teammate. A var with neither a value nor a default holds the cell's feeds until you fill it in.
Conditions. when puts a cell to sleep unless its context holds: a branch pattern (release/*), files that must exist, an OS, environment variable names, weekdays, an hours window. A sleeping cell stays listed with its reason and comes back on its own.
Rules per mode. instructions may be a map by mode — plan, architect, default, * as the floor — so the agent gets planning rules while planning and building rules while building.
Rules with scope. rules is a list of { text, when } — the pattern every editor converged on (Cursor's globs, Copilot's applyTo, Windsurf's activation modes): a rule reaches the agent only when its when holds. paths are globs over the files the turn names (src/api/**, *.test.ts); mode lists modes; branch is a glob; desk: true means only inside a desk tab. Every condition present must hold. instructions is the always-on floor; rules are the scoped layer above it. Rules are the agent layer: held until you approve, held again when edited.
"rules": [
{ "text": "Validate at the boundary; never trust a request body.", "when": { "paths": ["src/api/**"] } },
{ "text": "List risks before steps.", "when": { "mode": ["plan"] } },
{ "text": "Stay on the ticket; note anything else for later.", "when": { "desk": true } }
]Three homes. .empryo/morphs/ is yours in this repo (Empryo keeps .empryo out of git). .cells/ is committed — everyone who clones the repo gets the cell. ~/.empryo/morphs/ is yours in every repo. The most specific wins by id: a project copy shadows the team's, which shadows the global. "Make my copy" forks a shared cell into the project scope; removing your copy brings the shared one back.
Where your settings live. The file is the declaration. Your switch, your var values and the live state of a shared cell (team or global) live in this project's overlay — .empryo/cells-state.json, never the committed file, never the other repos. So a global Jira cell keeps a different PROJECT_KEY per repo, is off in one repo and on in the rest, and its sprint phase moves here without moving anywhere else. The rule is VS Code's: workspace over user over the file. A global cell also takes a value or a switch for every project — the fallback wherever it runs; the card says which one is in force ("Off here", "On everywhere", "here and for every project") and one click drops an override. /cells off <id> everywhere and empryo cells off <id> everywhere do the same from a keyboard. The agent's cells set on a shared cell writes the overlay; the file's state stays the reset.
The agent layer
| Morph | What it gives the agent | Key |
|---|---|---|
| Instructions | Rules in the agent's context while the cell is live, at project-instruction weight | instructions |
| Lookup | A read-only HTTP GET the agent may call through the cells tool, with named parameters URL-encoded into {{holes}}. Same address rule as a pulse. The host must be literal | lookups |
| Guard | A rule a tool call passes through before it runs: tool (name or glob), match (glob or /regex/ over the command, the path, or the URL), decision deny or ask, reason | guards |
| State | Key/values the agent writes with cells set and every template reads as {{state.key}}. The file keeps it; the panel repaints | state |
None of this is a hook. A hook is a shell command that observes or vetoes the agent. A cell gives the agent capabilities and a shared, visible workspace. Guards are the one overlap, and they stay data: no shell, no rewriting a call.
Everything in this layer needs the repo trusted, like pulses. An untrusted checkout contributes nothing to the agent.
The agent layer needs its own yes
Enabling a cell turns its UI on. Its agent layer — instructions, lookups, guards — is held until you approve it in Cells & Morphs, where you read exactly what it declares. The approval records a hash of that layer on your machine, never in the shared file. If the file later changes the layer (a git pull of a shared cell, an edit by any tool), the layer is held again and the panel says so; the UI keeps working meanwhile. A change that only touches the UI does not ask again.
This is the same rule MCP clients adopted against tool poisoning and rug pulls: the text that shapes the agent is approved as written, and any change to it is approved again.
Desks — one click from a board row to its own tab
The research is old and consistent: switching tasks leaves attention behind (Leroy 2009), a short ready-to-resume note when you leave cuts the cost of coming back (Leroy & Glomb 2018), and only one programmer in ten resumes editing within a minute of an interruption (Parnin & Rugaber 2011). Developers lose 6–10 hours a week to it (Atlassian DevEx 2025). Desks are the cell answer.
A row with desk is a place: { "text": "PROJ-142 Login loops", "desk": { "id": "PROJ-142", "prompt": "Work on PROJ-142." } } — or, from a pulse, "map": { "desk": "{{key}}", "prompt": "Work on {{key}}." }. Click it: a tab opens named after the row, with the prompt staged (never sent). The row rides every turn of that tab as a <desk> block, so "continue" means the same thing tomorrow. Click the row again from anywhere and you are back in the same tab, with a strip above the composer: away for how long, the last thing said here, one Resume button. Bindings survive restarts; closing the tab releases the row.
A row with tab focuses that tab on click — engine://attention rows carry tabId, so an Inbox cell (Start from… → Inbox) lists failed reviews, finished agents and working lanes, each row a jump.
Credentials
A pulse or lookup may send headers: "headers": { "authorization": "Bearer {{secret:JIRA_TOKEN}}" }. {{secret:NAME}} reads Empryo's key store (/keys), {{env:NAME}} the environment. Values are filled on the sidecar at request time and never reach the renderer or the model — the showcase lists the names and whether each is set.
Three rules keep a credential a credential:
- Approval covers reach. A cell that carries a secret is held until you approve it in Cells & Morphs — the banner names the secrets and the hosts they go to. Change either (a
git pullthat points the token at a new host) and the cell is held again. - Empryo's own keys are refused.
ANTHROPIC_API_KEY,OPENAI_API_KEY, search and login keys — any of Empryo's registered secrets — resolve to nothing for a cell, whichever door it uses; the showcase marks them *refused*. - Credentials stay on their origin. A redirect elsewhere is followed without them; a missing secret makes the pulse say so instead of sending an empty bearer;
host,cookie,origin,refererare not a cell's to set.
Organisation policy
In the global config (~/.empryo/config.json, never a repo's): "cells": { "requireSigned": "official" | "trusted" | "any", "trustedKeys": ["<base64 Ed25519 public key>"], "directories": ["https://git.example/cells/index.json"] }. requireSigned loads unsigned or tampered cells OFF with the reason on the card — trusted accepts Empryo's keys and the organisation's own trustedKeys; directories lists internal indexes under Start from… → Directory, labelled by host.
The agent opens desks too
cells has a desk action: "let's work on PROJ-142" → the agent opens (or returns to) that row's tab for you. engine://desks lists every tab bound to a row (title, working or idle, tab) — a "where was I" cell is one pulse.
The engine as a source, and buttons that act
A pulse's url may read Empryo itself: engine://git/changes, git/branches, diff/changes, sessions/recent, memory/recent, review/ledger, agents/background, tasks/open, history/recent, genome/stats, errors/recent, mcp/servers. Rows are the app's own data, mapped with the same templates, no repo trust needed — a "This workspace" cell that lists your uncommitted files beside your MCP servers is six lines.
An action, row or toolbar button with send: true sends its prompt at once instead of staging it. Slash commands work, so a cell can drive the engine's own verbs: { "label": "Review my changes", "prompt": "/review", "send": true }.
Every host
The desktop draws a cell's surfaces; the agent layer — the cells tool, the cell's instructions, its lookups, its guards — is the same in the TUI and in --headless runs. /cells in the TUI lists cells, approves or revokes an agent layer, turns a cell on or off; empryo cells <list|approve|revoke|on|off> [id] does the same from a script (--trust grants repo trust first). One approval serves every host.
Signed cells
A cell may carry an Ed25519 signature over its canonical JSON (everything but signature, source, enabled, state). The showcase shows Signed by Empryo for a key Empryo ships, Signed · key … for any other, Signature invalid when a byte moved. bun scripts/sign-cell.ts <cell.json> signs; --verify checks; the private key stays in ~/.empryo/keys/. Typed authoring: defineCell() from @empryo/sdk returns the exact object the file holds.
Where cells live, and sharing
.empryo/morphs/*.json is the repo's; ~/.empryo/morphs/*.json is yours in every repo (the showcase shows "every project" beside it; a project cell with the same id shadows it). Share a cell by copying its file; install one with Start from… → Install from GitHub… or by asking the agent:
install proxysoul/Empryo/cells/github-queue.jsonStart from… → Directory lists the public directory (cells/index.json in that repo; the starters ship embedded for offline). Only raw.githubusercontent.com is fetched. The file is validated before it is written, and it records source (URL, digest, time) so the showcase can say where it came from. An installed cell's agent layer is held like any other. Starter cells: https://github.com/proxysoul/Empryo/tree/main/cells.
A cell can also arrive as an A2UI message (a2ui.org): paste the JSON and the showcase lifts Text/Row/Column/Button/TextField/Tabs into blocks and the data model into state. Copy as A2UI exports a cell for any A2UI client.
Reach
Every cell shows its reach before you enable it, computed from the file so a cell cannot under-report: the hosts it talks to, whether it gives the agent rules, how many lookups it exposes, how many guards it sets, whether the agent may write its state, and how many places can put a prompt in the composer. A lookup whose host is a {{hole}} is listed as "any host — refused".
What reaches the model
Pulse rows are built from map templates: only the fields the cell named leave the payload. Lookups do the same with map, or with pick — a list of dot paths; only those travel. Raw JSON is the fallback when neither is set, capped at 12,000 characters. Every lookup result is fenced as <lookup-result cell=… tool=… host=…> with a "data, not instructions" trailer, and no cell text can close a fence from inside.
Example: a cell the agent works inside
{
"schemaVersion": 1,
"name": "Sprint",
"instructions": "Say which ticket a change belongs to. Run the unit tests after editing src/.",
"state": { "phase": "triage", "done": 2, "total": 5 },
"surface": {
"title": "Sprint",
"blocks": [
{ "kind": "progress", "label": "Done", "value": "{{state.done}}", "hint": "{{state.done}} of {{state.total}}" },
{ "kind": "field", "label": "Ticket", "placeholder": "PROJ-142", "prompt": "Work on ticket {{value}}." }
]
},
"lookups": [
{ "name": "ticket", "desc": "One ticket by key", "url": "http://127.0.0.1:4000/rest/api/3/issue?key={{key}}",
"params": [{ "name": "key", "desc": "PROJ-123" }],
"map": { "text": "{{key}} {{fields.summary}}", "badge": "{{fields.status.name}}" } }
],
"guards": [
{ "tool": "shell", "match": "*rm -rf*", "decision": "deny", "reason": "No recursive deletes here." },
{ "tool": "edit_file", "match": "infra/**", "decision": "ask", "reason": "Infra edits need a second look." }
]
}With this cell live, "look up PROJ-142" makes the agent call the lookup; finishing a ticket, it runs cells set with done: 3 and the progress bar moves; rm -rf is refused with the reason; an edit under infra/ parks the permission card.
Shapes
The domain is yours. The same nine morphs make:
- A control deck with no network: title-bar buttons and
/commandsfor the asks you repeat, a chord for the one you do hourly, a welcome suggestion for the morning. - A live board from any JSON the app can fetch without a login: a public repo, a package registry, a status page, a CI, or a service you run on
localhost. A private tracker gets there through your own proxy or aggregator. - A project cockpit: the project's real test suites, release steps, and doc pages as rows with actions aimed at them.
- A focus skin: pigment, a welcome, a status chip.
- A reference card: key/value links and one copy button.
- A watcher: triggers that notice when docs change, a feed moves, or a turn ends.
Start from… in the catalog installs one of these switched off and asks the agent to make it yours.
Example: the one-page work hub
Tickets, pull requests, and docs in one panel, so the agent can work on a ticket without you switching apps.
{
"schemaVersion": 1,
"name": "Work hub",
"accent": "#60a5fa",
"surface": {
"title": "Today",
"body": "{{pulses.jira.count}} tickets · {{pulses.prs.count}} PRs",
"actions": [
{ "label": "Plan my day", "prompt": "Look at my board and propose an order for today.", "tone": "primary" }
]
},
"status": { "text": "{{pulses.jira.count}} tickets", "tone": "accent", "prompt": "Walk me through my board." },
"pulses": [
{
"id": "jira",
"url": "http://127.0.0.1:4000/rest/api/3/search?jql=assignee%3Dcurrentuser()",
"every": 60,
"root": "issues",
"map": {
"group": "Jira · {{fields.status.name}}",
"text": "{{key}} {{fields.summary}}",
"badge": "{{fields.priority.name}}",
"href": "https://acme.atlassian.net/browse/{{key}}",
"prompt": "Work on ticket {{key}} from my board: read it, find where it lives in this codebase, propose a fix plan."
}
},
{
"id": "prs",
"url": "https://api.github.com/repos/acme/app/pulls",
"every": 60,
"root": "",
"map": { "group": "Pull requests", "text": "#{{number}} {{title}}", "detail": "{{body}}", "href": "{{html_url}}" }
}
],
"commands": [
{ "name": "ticket", "desc": "Work on a ticket from the board", "prompt": "Work on this ticket from my board: read it, find where it lives, propose a fix plan." }
],
"toolbar": [
{ "label": "Plan my day", "glyph": "list", "prompt": "Look at my board and propose an order for today." }
],
"triggers": [
{ "on": "pulse.changed", "pulse": "jira", "notify": "{{added}} new ticket(s)", "stage": "Look at the new tickets on my board." }
]
}The Jira pulse points at 127.0.0.1:4000 on purpose. Jira's API needs a login, and the app never holds one, so the feed comes from something you run: your own aggregator, a proxy, a script that writes JSON. GitHub's public API needs no login, so that pulse is direct.
With this cell live:
- Type
fix PROJ-142. The row for PROJ-142 (title, link, the PR that closes it) rides into the turn. The agent starts with the ticket. - Type
/ticket PROJ-142. The command's prompt sends with the key appended. - Ask "why is my jira pulse empty?". The agent calls
cellsand reads the pulse status:ok 4 rows,error: HTTP 403,error: no rows matched the map, orblocked.
Triggers
| Field | Meaning |
|---|---|
on | session.start, turn.start, turn.done, files.changed, pulse.changed, pulse.error, error |
match | For files.changed: a glob over the changed paths. *.md matches a basename anywhere; src/** a prefix. |
pulse | For pulse.*: which pulse. Omitted means any pulse of this cell. |
notify | A notice, prefixed with the cell's name. Click it to stage the prompt when there is one. |
stage | A prompt for the composer. Placed only when the composer is empty and no turn is running. |
cooldown | Seconds between firings of this trigger, 5 to 3600, default 30. Any two firings in the app are at least 3 s apart. |
Holes: {{path}} and {{count}} on files; {{pulse}}, {{added}}, {{removed}}, {{count}} on pulse.changed; {{pulse}} and {{error}} on pulse.error; {{error}} on error; {{name}} always. The first load of a pulse is not a change.
Toolbar
Up to four buttons. label is the tooltip. glyph is one of sparkles zap bug rocket book git list bell chart globe shield wrench clock flag star terminal or any Lucide icon name; an unknown glyph shows the cell mark. Each button has one of prompt, href, copy, open (a popup id).
Icons
Any Lucide icon, by its kebab-case name: music, git-pull-request, circle-check, triangle-alert. Put icon on an action, a board row, a stat, a badge, a kv row, a links tile, a tab, the surface (surface.icon, in place of the cell mark) or the status chip. The app ships the whole set, so a cell names an icon and never carries an SVG. A pulse map can set a row's icon from the data: "icon": "{{state | map:open=circle-dot:merged=git-merge:closed=circle-x}}". An unknown name paints nothing; the label beside it still does.
Blocks
stats, progress, badges, text, kv, divider, and:
| Block | Fields | |
|---|---|---|
field | label, placeholder, button, prompt with {{value}}. Enter or the button stages the prompt with the typed value. | |
select | label, options[] of label + prompt or href. | |
table | columns[] (≤5), rows[][] (≤12); cells are templates. | |
list | items[] (board rows, ≤200), virtual, height. virtual windows the rows. | |
tabs | tabs[] of label + blocks[] (leaf blocks only, ≤6 tabs). | |
chart | variant bars or line, label, points[] of label + value (a number or a template), max. Drawn by the host. | |
frame | src (https://…, or ui://server/app for an MCP App), title, height. A sandboxed iframe: no same origin, no navigation. | |
markdown | text (≤8000) rendered by the chat's own renderer — a README in a panel; templates work. | |
image | src (https), alt, href, height. One image, fetched by the renderer with no referrer; its host is reach. | |
links | items[] (≤12) of label + href or prompt, glyph, hint — a launchpad of tiles. | |
countdown | label, until (ISO date-time), done, tone. Counts down; warns inside a day; shows done once passed. | |
levels | live (a boolean or a template), bars (4–64), values[] (0–100, optional), label. An equaliser strip that dances while live is truthy — decorative without values, real levels with them. | |
callout | title, text, icon, tone. A tinted box with an icon: "Build failed", "3 reviews waiting". | |
avatars | size s/m/l, items[] (≤12) of name, image, hint, href. A stack of people; initials when there is no picture. | |
steps | items[] (≤12) of label, hint, icon, state done/active/todo/error (a template works: `"{{status | map:ok=done:running=active}}"`). A pipeline drawn as a rail. |
accordion | items[] (≤8) of title, hint, icon, open, blocks[] (leaf blocks, ≤8). Folding sections; the first is open unless open says otherwise. |
divider takes an optional label ("Today") that sits in the rule.
MCP Apps (SEP-1865): when src is a ui:// resource of a configured MCP server, Empryo is the host. The view loads from the server, sandboxed with the CSP the resource declares (restrictive default), and talks JSON-RPC over postMessage: ui/initialize gets Empryo's theme as the standard style variables, tools/call and resources/read go to the same server through the host (app-only tools stay invisible to the agent), ui/open-link opens a browser, ui/message stages text in the composer (never sends), ui/update-model-context reaches the model next turn, ui/notifications/size-changed sizes the frame. A view may act only from a live cell in a trusted repo whose agent layer is approved.
stats takes a variant: tiles (default), hero (one big number), inline (a strip). A row may carry promoted: true — emphasised on arrival, position unchanged. Right-click a row to pin it; shift-right-click hides it; both are yours and stay on your machine.
A progress.value may be a template that resolves to a number. surface.virtual: true windows the board itself.
Backdrops
surface.backdrop is an image (https, or http://127.0.0.1) or a template that resolves to one: "{{pulses.np.rows.0.image}}". The app paints it blurred and dimmed behind the panel, drifting slowly, and crossfades to the next picture when it changes — the cover of what is playing, the poster of the film, the chart of the run. pigment.backdrop does the same behind the whole thread while the cell is live. Nothing paints until the hole resolves to an image; the image's host counts as reach.
Helper
A cell that needs the machine — Spotify's track over AppleScript, a build's state, a sensor — declares the process that serves it:
"helper": { "run": "bun .empryo/morphs/now-playing.ts", "port": 4877, "hint": "reads Spotify over AppleScript" },
"pulses": [ { "id": "np", "url": "http://127.0.0.1:4877/", "every": 5, "map": { "text": "{{title}}", "image": "{{art}}" } } ]The app starts the helper when the cell goes live, stops it when the cell goes off or to sleep, restarts it with backoff when it dies, and hands it PORT and EMPRYO_CELL. The panel's footer shows helper on :4877, helper held, or helper stopped with the last line of its stderr on hover. You never open a terminal for it.
It is code from the repo running on your machine, so two gates hold it: the repo must be trusted, and the cell's reach must be approved in Cells & Morphs — the run line is part of that approval, and a changed line holds the helper again. run is one interpreter from bun, node, python3, python, deno, one script inside the project (beside a global cell for ~/.empryo/morphs), and plain arguments. No shell, no pipes, no ... The agent writes the script beside the cell when it builds one.
Pulses
A pulse is a URL, a heartbeat, a root path, and a map.
| Field | Meaning |
|---|---|
url | https:// anywhere, or http:// on 127.0.0.1 / localhost only. No LAN addresses, no cloud-metadata addresses, no credentials in the URL. |
every | Seconds between fetches, 15 to 3600 for a remote host; 3 upward for 127.0.0.1 and engine:// (the only cost is your machine's). Out-of-range values clamp. |
root | Dot path to the array in the response: issues, data.page.items. Empty when the response is the array. A root that is one object becomes one row. A feed of plain values (["a","b"]) maps through {{value}}. |
map | Templates for each row field. {{dot.path}} reads from the row: #{{number}} {{title}}. text is required. |
limit | Rows per pulse, up to 40. |
The app fetches with If-None-Match, so an unchanged feed costs one 304. A failed fetch keeps the last good rows and reports the error. Responses over 512 KB and requests over 8 seconds fail. Redirects are followed up to three hops, and every hop must pass the same address rule as the URL itself.
Pulses run only after you trust the repo (the same gate as MCP servers and hooks). Until then the cell shows blocked.
Templates
These fields may hold {{holes}}: status.text, surface.body, stats value and hint, progress hint, text blocks, kv values.
| Hole | Value |
|---|---|
{{pulses.<id>.count}} | Rows the pulse mapped |
{{pulses.<id>.status}} | ok, loading, error, blocked, idle |
{{pulses.<id>.rows.0.text}} | Any field of any mapped row |
{{name}} | The cell's name |
Templates
A hole is a dot path into the row ({{fields.summary}}) or the cell's context: {{pulses.<id>.count}}, {{pulses.<id>.rows.0.text}}, {{state.<key>}}, {{name}}.
Filters run left to right, a fixed set: upper, lower, trim, truncate:40, default:—, plural:ticket (or plural:person:people), pct:state.total, ago, date, compact, map:open=🟢:closed=⚪, pick:a:b:c, shuffle:a:b:c. {{pulses.jira.count | plural:ticket}} reads "3 tickets". An unknown filter empties the hole; nothing evaluates.
pick chooses one option by the value: the same value always lands on the same option, a different value lands elsewhere. shuffle chooses a random option each time the value changes, never the same twice in a row, and holds it while the value stays. Both give a look that follows the data with no helper and no state:
"pigment": { "tintApp": true, "theme": "{{pulses.np.rows.0.text | shuffle:tokyo-night:nightfox:ember:vesper:nordic:synthwave}}" }Every new song re-rolls the app's theme; the same song keeps it. Theme ids are the ones /theme lists.
A pulse that fails backs off — twice the interval per failure up to ten minutes, with jitter — and returns to its interval on the next success. Refresh in the panel asks now.
Limits
The loader clamps long strings with an ellipsis, drops extras, and drops one broken block, row, action, or pulse. The cell stays.
| Field | Limit |
|---|---|
name / description | 60 / 240 |
surface.title / body | 120 / 400 |
surface.blocks | 16 |
surface.actions | 8. Each has one of prompt, href, copy. |
surface.items | 40 rows |
pulses | 4 |
commands | 6. name is 2 to 24 letters, digits, dashes. A built-in with the same name wins. Two cells with the same command or chord: the first file owns it. |
reflexes | 8. keys is two of mod, shift, alt (or alt alone) joined with + and one final letter or digit. mod+<key> alone belongs to the app and the OS; mod+shift+c/f/n/p/s/t/v/w/z are refused. |
toolbar | 4 |
triggers | 8 |
popups | 6, each ≤12 blocks and ≤6 actions |
palette | 8 |
instructions | 4000 characters |
state | 32 keys of string, number, or boolean |
lookups | 6, each ≤6 params. Response ≤256 KB, 8 s, no redirects. |
guards | 12 — taint: "<host glob>" limits a guard to calls carrying data that came from that host |
welcome | 4 |
minRuntime / fallbackText | A cell written for a newer app shows its fallback text instead of nothing. This app is runtime 2. |
Row and action links open in your browser, so they may point anywhere http or https reaches. javascript:, file:, and data: links are dropped.
Manage
Cells & Morphs lists every cell with its hooks, its state (live, off, or broken with the reason), and a switch. Search, filter by live/off/broken or by morph, delete with a two-step confirm. Share copies a cell's JSON. Import from clipboard installs one. Start from… installs a template switched off and opens the agent to fill in your URLs.
The /morphs row in the composer reports how many cells are live and names the first broken one.
What a cell cannot do
- Run code of its own. A cell is data; the app owns every component. A
helperis the one exception, and it runs only in a trusted repo after you approved that exact run line. - Send a message, click, or type on its own.
- Send a message or run anything on an event. A trigger notifies or offers; you press Enter.
- Reach the network from the app except through a pulse, and only after you trust the repo.
- Break the app. Each hook renders inside a boundary: a bad cell renders nothing and is logged.