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.
Copy & share
Loading sections…
Connect MCP or install the Empryo skillSection exports contain only that heading’s content. Markdown and text links fetch the selected content directly, without the rest of the page.
Experimental. Cells & Morphs ship behind an experimental label: the vocabulary grows every release, and a morph written today keeps working (an older app renders a newer morph's fallback).
A morph 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.
Two words, on purpose. A morph is the thing: the file you build, enable, install and share, and everything it puts on screen. Its cell is the work underneath — the rules, lookups, guards and state that reach the engine, the part you approve or revoke (/cells on the TUI manages exactly that). Nothing on screen is ever called a cell.
What Empryo offers is the engine (agents, lanes, runs, goal loops, teams, the app's own data as feeds), the runtime (blocks, verbs, views, pulses, state, intents) and the app's surfaces. What a morph *is* is yours: a ticket board, a DJ deck that runs agents as music, a chapter wall for a novel, a plant monitor. Nothing here assumes a domain, and the agent that builds morphs is told not to either.
The agent builds morphs for you. Open Cells & Morphs from the ... menu (or /morphs) and click New morph, then say what you want on screen — "a notebook", "my tracker as a board I can drag", "a pomodoro that chimes".
Use with an agent. Any agent can write a morph from these resources: the task map empryo.com/docs/tools/morphs/llms.txt (one section per task, as markdown), this page as markdown at /docs/tools/morphs.md (cite a section as /docs/tools/morphs.md#guards), a ranked section search at /api/docs/search?q=<terms>&scope=morphs, and the exact allowed keys, types, enums and limits in the Morph JSON Schema. A retrieval prompt: "Fetch https://empryo.com/docs/tools/morphs/llms.txt, pick the sections for the task, fetch each .md#anchor, write the morph, then validate it against https://empryo.com/morphs.schema.json."
Try it in five minutes
Open Cells & Morphs (/morphs), pick Start from… → The lab (every part) and turn it on. That one morph paints every part a morph can have — numbers, a chart, controls, notes, pictures, tabs, an embedded app, and a small piece of the morph's own code — so you can see what each looks like before you ask for a real one. Then say what you actually want; the agent writes that morph and you delete the lab.
For the board: Start from… → Spec board (canvas), make a specs/ folder with one markdown file per feature (status: todo at the top), and press /canvas. Drag a card to *In progress* — the file changes and an agent starts on it in its own tab.
Work it directly
A morph'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 morph's state; a timer counts down and fires when it hits zero; a button can
set,append,remove,toggleorincstate; acallwrites to the morph'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 morph'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 morph 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), dock (a strip right above the composer), canvas (the whole workspace, in place of chat — a board you work from). chrome: "plain" drops the card frame; you have the same switch for every morph | 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. Its source may be an API, the engine itself (engine://…), or a folder of the repo (files://specs/*.md — one file per card, frontmatter as its fields) | pulses |
| Run | A button, a row action or a kanban column that puts the engine on that card: a tab of its own opens, on the model and mode the morph asked for, and works while you stay on the board | run |
| 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 morph is live, the app takes the morph's accent colour — and, with theme, wears a theme (nord, dracula, one of yours); your own comes back when the morph goes off. backdrop puts a picture behind the whole thread | pigment |
| Helper | The local process the morph needs (a script that reads Spotify, tails a build, polls a sensor). The app starts it when the morph goes live and stops it with the morph | 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. It may also carry run — the one control that starts work rather than describing it (see Runs). 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 morph adds to the app and never removes, hides, or renames anything the app already has.
Settings, conditions, and where a morph lives
Settings. A morph 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 morph fits every teammate. A var with neither a value nor a default holds the morph's feeds until you fill it in.
Conditions. when puts a morph 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 morph 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 morph. ~/.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 morph 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 morph (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 morph 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 morph 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 morph 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 morph 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 morph 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 morph 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 morph, 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.
A held morph is never silent: the moment a morph needs your yes — its agent layer, or a helper it wants to run — the app raises a notice naming the morph, and clicking it opens Cells & Morphs on that morph with the approval card on top. The morph's own cell.held trigger fires at the same moment.
Which sessions morphs reach
By default a morph reaches the agent only in morph mode — the dedicated Cellwright tab that builds morphs. An ordinary coding session carries no cells tool, no morph instructions, no board context: the morph is your UI, not the model's. Flip Morphs reach every session in the panel (global config "cells": { "reach": "all" }) for the other flow — "work on PROJ-142 from my board" from any tab. Guards protect in both settings.
Hidden from the agent
Any morph can be hidden from the agent from its card in Cells & Morphs — Agent access → hidden. A hidden morph still paints (its board, chip, buttons, popups all work), but the agent cannot see it at all: not in cells list, no rows, no lookups, no state writes, no instructions, no desk, no guards (a denial would name it). Private messages, a personal board, anything with a credential — hide it. The switch is yours, per project (.empryo/cells-state.json), never written into the morph file; it survives git pull and wins over every other setting, including reach-every-session and auto-approve.
Hidden closes every door the app controls, not only the cells tool: the morph's file and the state that carries its live values are refused to the agent's file and shell tools — whatever the repo's trust — with a reason that names no morph, and engine sources leave hidden morphs out. What it cannot do: unlist a committed file's name, or take back what the agent already saw earlier in a session. A morph whose *existence* must stay unknown belongs in the global folder, not the repo.
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 morph 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 morph (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 morph 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 morph 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 morph, 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 morph's to set. - The host is literal. A
{{hole}}in the host part of a pulse, call or lookup URL (https://{{state.host}}/…) is refused at validation — neither a feed row, the morph's state nor the agent can pick where a credential goes. Only{{var:NAME}}(your own value from Cells & Morphs) may stand in a host. A filled hole in the path cannot climb out of it either: a row or a parameter changes what a request says, never where it lands.
Instructions, per mode
instructions is normally one line the agent always reads (it arrives on the wire as instructions, or as instructionsByMode when you split it). It can also be written per mode — { "plan": "…", "architect": "…", "*": "…" } — so a morph says one thing while you are planning and another while code is being written; * is the floor every mode inherits. Like every part of the agent layer it waits for your approval and is held again when the file changes.
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"], "reach": "morph" | "all", "autoApprove": false }. requireSigned loads unsigned or tampered morphs 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.
What the agent is holding
When you ask for a morph, the agent that answers is the Cellwright — a dedicated agent, not the coding one wearing a hat. Its whole toolbelt, so you know what it can and cannot do while it builds:
| It can | With |
|---|---|
| Read your project to write a morph that fits it | read, grep, find, list_dir, explore_script |
| Understand the code it is building around | navigate, genome_query, genome_analyze, genome_impact, analyze |
| Write and revise the morph file | edit_file, multi_edit, undo_edit |
| See what the morph became — live rows, pulse health, the picture | cells (list, board, debug, shot, refresh, lookup, set, scope, desk, install) |
| Check an API's real shape before mapping a feed | web_search, fetch_page |
| Read history, ask you a question, keep a checklist, remember | git, ask_user, task_list, memory |
What it deliberately does not have: running your project's code, shell, tests, or delegating to other agents. It builds the morph and shows you; the work a morph starts is done by the ordinary coding agent in its own tab.
Build your own picker
When the app's own control is a menu a morph cannot reach, build the picker instead of linking at it. Three parts you already have:
- A feed —
engine://sessions/recent(alsoworktrees,agents/custom,tabs/open,git/branches,review/ledger…) lists what exists. - A popup —
"popups": [{ "id": "sessions", "shape": "sheet", "blocks": […] }]holds the list, and a links tile can carry"open": "sessions"so a button on your board opens it. - A verb — a row action or tile whose prompt is the command that acts:
"/session load {{id}}","/review","/canvas". It runs through the app's own binding, so the thing actually happens.
The roadmap starter does exactly this for sessions: a rail tile opens a sheet, the sheet lists your recent sessions with when they were touched, and each row loads that session.
The agent drives its own work
Beyond reading (debug) and looking (shot), the agent has hands and ears on a live morph: press runs a control exactly as a click would — an intent by name, a surface action or button block by label, a row by its text, "<row> › <action>" for a row's own control — and reports how the morph's state, pulses and rows moved; simulate raises an app event (turn.done, tool.call, run.done, pulse.changed…) against the morph's triggers with the holes filled as asked, reports which fired and what each did, and fires them for real (cooldowns and all); watch listens for a few seconds to everything the morph can hear from the engine and to its own wire, and hands back a timeline. Build → debug → press/simulate → watch → shot is the loop, and none of it goes through a back door — it is a hand on the same buttons.
The agent looks at its own work
Two questions, two answers. cells debug <id> says what the morph is — the validated structure, what dropped, which feed is empty. cells shot <id> says what it looks like: the app paints the morph and hands the agent the picture. widths="dock,side,tab,canvas" (or plain numbers) takes several, so a panel can be checked where it will actually live. A title that ran out of room, a block that painted empty, a control with no label — invisible in the file, obvious in the picture. Desktop only; elsewhere debug is the whole story.
The agent checks its own work
cells debug <id> returns the validated wire of a morph — what the renderer will actually paint, not the JSON that was written: its place, the block tree, a kanban's columns with the number of cards in each (plus any row group matching no column, which is how a card that can never appear announces itself), the first rows with their mapped fields, every pulse's status and row count, the helper, and the computed reach. It is the loop the Cellwright builds in: write the file, debug it, fix what dropped.
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" morph is one pulse.
Grid — putting things where you want them
Every other block says *what* to paint. grid says *where*:
{ "kind": "grid", "columns": 3, "gap": "l", "items": [
{ "span": 2, "label": "This week", "blocks": [ { "kind": "chart", "points": [] } ] },
{ "blocks": [ { "kind": "stats", "items": [] } ] },
{ "blocks": [ { "kind": "progress", "label": "Build", "value": 62 } ] }
] }One to six equal tracks, or "columns": "auto" for as many as fit at min pixels each — that one reflows on its own when the pane narrows. A morph can span more than one track and carry a label (a section heading without a divider). Grids nest one level deep: a grid holds blocks, not more grids, so the layout stays something you can picture.
It works anywhere blocks work — a panel, a popup, the canvas rail, the inspector. Anything inside is gated exactly as it is at the top level: a call, a run or a view in a grid morph still counts in the morph's reach and still waits for your approval.
How a board arranges itself
layout decides the shape, and the default is the one you want:
layout | What you get |
|---|---|
board (default) | Columns side by side, each scrolling on its own. Nothing floats, nothing overlaps. A pipeline, a backlog, a queue. |
grid | Cards reflow into as many columns as the width allows. A gallery, a fleet, a wall of readouts. |
rows | One column, full width, capped for comfortable reading. A log, a feed, a list. |
free | The loose canvas: pan, zoom, place things where you like. Only worth it when *position* means something — a map, a graph you arrange by hand. |
Panning and zooming exist only in free. In the other three the board behaves like every other pane in the app: it scrolls, the columns hold their tracks, and a card can never land on top of another one.
The canvas — a board instead of the chat
"place": "canvas" gives a morph the whole window. Chat is not closed — it steps aside, and every tab keeps working behind the board. Press Chat (or /canvas again) to swap back.
A canvas morph is a board plus its cards. What you get on top of a docked board:
- Move around. Drag the empty space to pan, or scroll. Zoom with ⌘/Ctrl + scroll, or the +/− buttons — whatever is under the pointer stays under it.
- Keyboard too. Tab to a card, then
Enteropens its tab,[and]move it a column left or right,rstarts its run. No mouse needed. - A card shows its work. If a tab is open for that card, the card says working while it runs and shows what it cost when it stops.
- Buttons on the card. Whatever the morph hung on the row — a link, a call to your tracker, a popup, a state change — is a button right there. Plus Run if the row has one, and Open for its tab.
- Pick the model once. The header has a model picker; every run started from that board uses it.
- Own the board.
"board": "none"on the surface makes the rows data only — templates, alistwithfrom, and views still read them, but the app paints no columns; the morph's blocks take the centre, so aviewcan be the whole board. On a pulse,"paint": falsedoes the same for that feed alone. A kanban with"lanes": []is a plain kanban, never zero rows.
It stays smooth because moving the board never redraws it — the whole board is one layer that slides — and cards off screen are not drawn at all. A board of two hundred cards pans like a board of three.
A folder of files as the data
A feed's url can be files://specs/*.md — files in the project you already have open. No network, no keys. One file per card: the key: value block at the top becomes the card's fields, the first # heading becomes its title, and path, file, name, summary and updated come for free. files://docs/**/*.md walks subfolders. Hidden files and node_modules are skipped, 200 files and 64 KB each are the ceiling, and the path can never leave the project.
{ "id": "specs", "url": "files://specs/*.md", "every": 15,
"map": { "text": "{{title}}", "detail": "{{summary}}", "group": "{{status}}", "path": "{{path}}", "desk": "{{name}}" } }Files with no fields at all still work. {{summary | match:shipped=shipped:wip=doing:paused=parked | default:next}} reads the file's own words — the first phrase it finds wins — so a folder of ordinary notes becomes a board without anyone adding a header to them first. Drag a card once and the status is written in.
Drag a card and the move is written back into the file it came from: "onMove": { "write": { "field": "status", "pathField": "path" } } upserts status: doing in that spec's frontmatter — one fence, body untouched, and it shows up in git diff.
A pipeline, not just a list
A board can have a second axis. Give the board lanes and every lane is a row of cards crossing the columns: the lane is the thing moving (a spec, a ticket, a release), the column is the stage it is in. laneField names the row field that decides which lane a card belongs to (lane by default), the same way field names the one that decides its column. Cards say which lane they belong to with lane, and a card can carry progress (0–100) for a thin bar — a round, a step, a percentage.
The canvas can also hold two side panels, both ordinary blocks:
rail— a column on the left for what is next, what is queued, the buttons you press often.inspector— a panel on the right about the card you just clicked. Clicking a card writes its id and its fields into the morph's own state ({{state.selected}},{{state.selected_text}},{{state.selected_lane}}, and one per mapped field), so the panel's blocks — and any feed whose address reads that state — are about the thing in your hand.
That is the whole shape of an orchestrator: a backlog on the left, work crossing stages in the middle, evidence and history on the right, and the buttons that start agents on the cards themselves.
Motion, when the movement is the point
A morph's own code (a view block) is handed the app's animation engine — the same one the app animates itself with, already loaded, nothing to install:
export default ({ html, hooks, anime }) => function View() {
const box = hooks.useRef(null);
hooks.useEffect(() => {
if (anime.reducedMotion) return; // asked for less movement: don't
const a = anime.animate(box.current, { opacity: [0, 1], translateY: [8, 0], duration: 320 });
return () => a.pause(); // stops with the view
}, []);
return html`<div ref=${box}>arrived</div>`;
};animate, createTimeline, stagger, svg, utils, eases and reducedMotion are all there. Animate movement and fading — those are free. Animating width, height, top or left makes the app re-measure the page on every frame, which a board of cards cannot afford. Keep the handle and stop it when the view goes away, and skip the animation entirely when the machine asks for less movement.
The ordinary parts already move on their own (a progress bar fills, a levels strip dances, a timer counts). Reach for this when the motion *is* the point.
Runs — cards that do the work
Any action, row action, or a kanban's onMove may carry a run:
"run": { "prompt": "Implement the spec in {{path}}.", "model": "anthropic/claude-sonnet-4-5",
"mode": "plan", "skills": ["frontend-design"], "desk": "{{id}}", "background": true,
"loop": true, "maxIterations": 4,
"team": [ { "task": "Write the tests for {{path}}", "agent": "tester" }, { "task": "Review the design" } ] }loop: true makes the run a goal loop instead of one turn: coder ⟷ reviewer until the reviewer says PASS, or maxIterations rounds (default 5), on the desk tab, in the background. team makes the lane dispatch workers in parallel — each a task templated over the card, optionally a custom agent by name — and integrate what they bring back before it finishes. A button block, an action or an intent may also carry "desk": { "id": "{{id}}", "mode": "plan" } on its own: open the row's tab without a prompt. Boards *of* agents read engine://agents/background, engine://desks and engine://review/ledger, and react with run.done, agent.done, review.done and goal.done triggers.
On your click — or the moment a card lands in a column named by runOn — a lane of its own starts on that row, on the model and mode the morph asked for, and the prompt is sent.
With background: true (the default) nothing about your screen changes: no tab opens in front of you, nothing is written into the composer, no transcript to dismiss. The card becomes the report — a live dot while it works, the last thing that lane said, what it cost when it settles — and a Watch button appears on it for when you do want to read along. background: false puts you in the tab; stage: true writes the prompt into the composer instead of sending it.
A card can carry up to four verbs (Start, Plan, Review, …), each with its own desk, so one card can have several lanes of its own.
The lane a run opens is the coding agent — never morph mode, whatever your default mode is set to. Morph mode is the Cellwright, an agent for building morphs; a card that says *implement this spec* is not its work. A run or a desk may still ask for a mode — "mode": "default" | "plan" | "architect" | "auto" | "socratic" | "challenge" — and that is what the lane opens in.
A prompt that starts with / is a command, not text: it goes through the same submit path a typed message takes, so "prompt": "/review" really reviews and "/dispatch …" really dispatches.
When the run finishes, the board hears about its own card: { "on": "run.done", "match": "<desk id>" } (and run.failed), with {{desk}} {{cell}} {{row}} {{cost}} {{duration}} {{files}} {{error}} in the holes — so the card can move itself to *In review*, post to an API, or just say so.
prompt, label and desk are templated over the card before it goes: {{path}}, {{title}}, {{text}}, {{id}}, {{column}} (the column it was dropped in) and every field the row's map named. A hole the card does not carry resolves to nothing, never to braces.
Nothing here starts by itself: a run is always a user gesture, it spends the user's own tokens, and the reach card counts every place a morph can start one (starts). The Spec board starter (Start from… → Spec board) is the whole loop in one file: specs/*.md → columns → drop on *In progress* → an agent works while you keep triaging.
The engine pushes too
An engine:// pulse no longer waits for its beat. The moment the engine announces something a feed reads — a turn starts or ends (tabs/open, desks, attention, usage/models, sessions/recent), a background agent moves (agents/background), a review or goal settles (review/ledger, attention), a task changes (tasks/open), a file is edited or the tree changes (git/changes, diff/changes, git/branches, worktrees), an error lands (errors/recent), MCP/skills/LSP state moves — the pulses that read it are marked stale and the wire is pushed, so a board of agents or of changes is as live as the app itself. every is only the floor for what nothing announces. Triggers hear the same stream at the coarse end: tool.call (a tool started; match = tool glob; {{tool}}), agent.step ({{title}} {{tool}} {{state}} {{detail}}), context.changed (a lane's context gauge crossed a tenth; {{pct}} {{tokens}} {{window}}; match = the band, "70").
Live sources — the feed pushes, you don't poll
A feed's url can be wss://… (or ws://127.0.0.1:… for something running on your own machine). The app holds that connection open while the morph is on, and every message the server sends becomes rows straight away — no interval, no waiting. Everything else works exactly as it does for a fetched feed: the same board, the same chip, the same triggers. The only difference you see is the footer, which says live instead of "every 60s".
{ "id": "builds", "url": "wss://ci.example.com/stream", "mode": "append", "limit": 40,
"subscribe": { "watch": "{{state.repo}}" },
"headers": { "authorization": "Bearer {{secret:CI_TOKEN}}" },
"map": { "text": "{{job}}", "detail": "{{status}}", "tone": "{{level}}" } }mode: "replace"(the default) — each message is the whole list: a dashboard.mode: "append"— each message is one new row on top, up tolimit: a log, a chat, a tail.subscribeis sent the moment the connection opens (a string, or an object sent as JSON). It can read the morph's own state, so "watch the branch I picked" works.- Same safety as any feed: encrypted off your machine, plain only to your own machine, a fixed address, keys only after you approve them, and the repo has to be trusted first. Live adds three of its own: messages over 128 KB are ignored, a shouting feed is throttled so it cannot make the app spin, and a dropped connection retries with a growing gap.
- The card lists it as what it is —
live:ci.example.com— and the connection closes the moment the morph goes off, sleeps, or you edit its file.
Slots — a morph instead of part of the app
place puts a morph where the app left room for it. A slot goes further: the morph *becomes* that part of the window, and what the app would have drawn there is not drawn at all.
slot | What it replaces |
|---|---|
transcript | The message list. You still type — the composer stays. |
composer | The input row. You still read — the transcript stays. |
empty | The empty tab, before the first message. |
Three rules keep that safe:
- You can always undo it. Every takeover draws one small restore button. Press it and the app's own version comes back. A morph cannot remove that button.
- Never mid-answer. A morph that replaced the input row steps aside while a turn is running, so Stop is always the app's own button.
- It leaves with the morph. Turn the morph off — or let it sleep, or change the file — and that part of the window is the app's again.
Together with place: "canvas" that is the whole range: a strip above the input, a pane, one part of the window, or the entire thing.
The engine as a source, and buttons that act
A pulse's url may read Empryo itself: engine://git/changes, git/branches, git/log, worktrees, diff/changes, sessions/recent, memory/recent, review/ledger, agents/background, agents/custom, tasks/open, history/recent, genome/stats, genome/active, errors/recent, mcp/servers, skills/installed, hooks, usage/models, lsp/servers, tabs/open, cells/live, attention, desks, orchestration/runs (every orchestration run: status, done/total nodes, tokens, live agents, the gate question and its options), orchestration/live (one row per node of every live run: state idle/running/ok/fail/gate/detached, steps, tokens, model, lastTool, edges — the rows of a DAG board), link/rooms (councils and debates on the agent link: members, topic, last line), link/traffic (recent agent-to-agent messages: from, to, room, text, files). Rows are the app's own data, mapped with the same templates, no repo trust needed — a "This workspace" morph that lists your uncommitted files beside your MCP servers is six lines.
A control whose prompt is a command runs that command: { "label": "Routing", "prompt": "/agent-features" } opens routing, "/review" reviews, "/usage" opens the usage panel. It goes through the app's own binding, so nothing is written into the composer and nothing waits for Enter — the button does the thing. A prompt this host binds nothing for falls back to the composer, where the user is the send.
An action, row or toolbar button with send: true sends its prompt at once instead of staging it. Slash commands work, so a morph can drive the engine's own verbs: { "label": "Review my changes", "prompt": "/review", "send": true }.
Every host
The desktop draws a morph's surfaces; the agent layer — the cells tool, the morph's instructions, its lookups, its guards — is the same in the TUI and in --headless runs. /cells in the TUI lists morphs, approves or revokes an agent layer, turns a morph 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 morphs
A morph 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 morphs live, and sharing
A new morph is yours in every repo by default: ~/.empryo/morphs/*.json (the showcase shows "every project" beside it). A morph that is about one repository — its board, its tests, its deploys — lives in that repo's .empryo/morphs/*.json and shadows a global morph with the same id; .cells/*.json is committed for the whole team. Move a morph between the three from its card. Share a morph by copying its file; install one with Start from… → Install from GitHub… or by asking the agent:
install proxysoul/Empryo/morphs/github-queue.jsonStart from… → Directory lists the public directory (morphs/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 morph's agent layer is held like any other. Starter morphs: https://github.com/proxysoul/Empryo/tree/main/morphs.
A morph 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 morph for any A2UI client.
Reach
Every morph shows its reach before you enable it, computed from the file so a morph 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 morph 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 morph text can close a fence from inside.
Naming the facts a row carries
A map key the row model knows (text, detail, badge, group, tone, href, id, desk, tab, image, attach, icon, prompt) fills that part of the card. Any other name is a fact carried on the row — reachable as {{owner}} in a run's prompt, as {{state.selected_owner}} once the card is clicked, and as the pathField a board write uses.
Both spellings do the same thing:
"map": { "text": "{{title}}", "group": "{{status}}", "path": "{{path}}", "owner": "{{owner}}" }
"map": { "text": "{{title}}", "group": "{{status}}", "fields": { "path": "{{path}}", "owner": "{{owner}}" } }Group them under fields when there are several and you want them apart from the card's own parts. A feed the app cannot read at all (a map value that is not a template) is dropped and said out loud — the morph's card carries a notice naming how many feeds were lost, instead of painting an empty board with no explanation.
Example: a morph 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 morph 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 — these are shapes people have made, not a menu. The same 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.
- A deck: a view with hands — buttons that fire intents, a rail of live rows, agents started from the thing on screen, whatever that thing is.
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 morph 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 | The agent: turn.start, turn.done (success), turn.stopped (you stopped it), turn.failed, tool.done, file.edited (the agent's own edit), agent.asks (a question or a permission), review.done, goal.done, agent.done / agent.failed (a background agent), plan.reviewed, compaction.done, model.changed, idle (with after minutes, default 15). The app: session.start, files.changed, error, tab.open, tab.close, window.focus, window.blur. Pulses: pulse.changed, pulse.error. This morph: clock (with at/days), timer.done, cell.held. |
match | A glob. files.changed / file.edited: over the paths (*.md matches a basename anywhere; src/** a prefix). tool.done: the tool name (project, edit_*). review.done / goal.done: the verdict (FAIL). plan.reviewed: the action. model.changed: the model id. agent.done / agent.failed: the agent's title. |
pulse | For pulse.*: which pulse. Omitted means any pulse of this morph. |
notify | A notice, prefixed with the morph'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. |
state | A state operation on this morph, the same shape as a button's ({ "inc": { "key": "turns" } }, { "set": { "last": "{{tab}} · {{cost}}" } }). Strings are templated from the event. |
call | Reach out: an HTTP write — a Slack or Discord webhook, n8n, a deploy hook. url, method, headers, body (strings templated from the event), done. Runs on the sidecar under the same gate as a pulse; host and method are part of the morph's reach, approved with the agent layer and held again when they change. A trigger's call never asks — the cooldown is its rate limit. |
open | "surface" or a popup id: bring the morph forward when the event fires. |
after | idle only: minutes without a turn, 1–240 (default 15). Set cooldown to about after × 60 for one nudge per idle spell. |
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: {{name}} and {{tab}} always. {{cost}}, {{duration}}, {{files}}, {{tools}}, {{steps}} on turn.done; {{error}} on turn.failed / turn.stopped / error; {{tool}}, {{ok}}, {{summary}} on tool.done; {{path}}, {{count}} on files; {{question}} on agent.asks; {{verdict}}, {{reason}} on review.done / goal.done (plus {{iteration}}); {{title}}, {{task}} on agent.*; {{action}} on plan.reviewed; {{before}}, {{after}} on compaction.done; {{model}} on model.changed; {{pulse}}, {{added}}, {{removed}}, {{count}} on pulse.changed; {{pulse}}, {{error}} on pulse.error. The first load of a pulse is not a change.
"triggers": [
{ "on": "turn.done", "call": { "url": "https://hooks.slack.com/services/…", "body": { "text": "{{tab}} finished in {{duration}} ({{cost}}, {{files}} files)" } }, "state": { "inc": { "key": "turns" } }, "cooldown": 5 },
{ "on": "review.done", "match": "FAIL", "notify": "Review failed: {{reason}}", "sound": "error", "open": "surface" },
{ "on": "agent.asks", "system": true, "notify": "{{tab}} needs you: {{question}}" },
{ "on": "idle", "after": 20, "cooldown": 1200, "stage": "What should I pick up next from my board?" }
]Toolbar
Up to six 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 morph 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 morph mark) or the status chip. The app ships the whole set, so a morph 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, divider, and:
text takes size (s/m/l), mono and tone. kv takes boxed (each row in its own box), align: "right" and mono; a row takes tone (outlines that one row), tag (a small caps chip at its end — "TAKEN", templated) and mono. These are the typography and density knobs a 320 px inspector needs: a small caps heading, boxed key/value rows with right-aligned values, one row toned and tagged, a mono caption, a buttons row at the foot.
| 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); morphs are templates. | |
list | items[] (board rows, ≤400), virtual, height. Feed-driven with from: "<pulse id>": that pulse's rows, live — where: { "<fact>": "<template>" } keeps rows whose fact equals the value, sort: "rank" / "-rank", limit. select: true makes a click write state.selected* exactly as a canvas card does, so an inspector beside the list follows it. Every field of a static row is templated (badge, group, lane, href, fields). | |
heading | text, level s/m/l, caps, hint, icon, tone. A section title at panel scale — s + caps is the small uppercase label a designed inspector has above every box. | |
button | Any action verb (call, run, desk, open, state, prompt/send, href, copy, attach, layout) plus label, icon, tone primary/secondary/quiet/destructive, width full/auto. A button wherever a block goes — the inspector, the rail, a grid morph, a popup. Inside an inspector its {{holes}} read the selected card ({{id}}, {{text}}, every named fact) and {{state.*}}. | |
buttons | items[] (≤6 actions), align start/end/stretch. A row of buttons — a wide primary and a quiet one beside it. | |
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. | |
view | module (≤64 KB, an inline ES module), css (≤16 KB, scoped to the view), title, height. The free mode: the morph's own component, rendered in the surface with the app's primitives — with hands (intent, desk, popup, select, refresh) and a styling contract (tokens, scoped css). See Views. |
divider takes an optional label ("Today") that sits in the rule.
Views — the free mode
The schema is deliberately small; a view block is where everything it cannot say goes. It works like module federation: the morph ships an ES module, the app loads it and renders the component it returns inside the surface — same DOM, same theme tokens, the app's own primitives. No iframe, no second document, no server. The module exports one factory that receives the app's shared scope and returns a component:
export default ({ html, hooks, ui, icon }) =>
function Tally({ cell, setState, stage, notify }) {
const n = cell.state.n ?? 0;
return html`
<${ui.Card}>
<${ui.CardHeader}><${ui.CardTitle}>${icon("hash")} Tally: ${n}</${ui.CardTitle}></${ui.CardHeader}>
<${ui.CardContent}>
<${ui.Button} onClick=${() => setState({ inc: { key: "n" } })}>+1</${ui.Button}>
<${ui.Button} variant="outline" onClick=${() => stage(`Summarise my ${n} tallies.`)}>Ask</${ui.Button}>
</${ui.CardContent}>
</${ui.Card}>`;
};| Shared scope (what the app hands the module) | |
|---|---|
h | React.createElement |
html | htm bound to h — JSX without a compiler |
hooks | useState, useEffect, useMemo, useRef, useCallback |
three | three.js (r185), when the view asks for it — "libs": ["three"] on the block, or simply naming three in the source. The app bundles it and loads it lazily the first time a view asks; the view never imports, it receives — so the approved source stays the only code the morph ships. new three.Scene(), a WebGLRenderer({ canvas }) on a <canvas> the view renders, a frame loop driven by on(): a WebGL field that moves with the engine is ~40 lines. |
tokens | The theme as CSS var(…) strings — background, foreground, card, muted, mutedBackground, border, ring, accent, primary, destructive, success, warning, radius, fontMono. Use them in inline styles or name the variables in the block's css; a theme change re-resolves them, the view does nothing. |
ui | The app's primitives on its theme: Button, Badge, Card/CardHeader/CardTitle/CardContent, Input, Textarea, Switch, Checkbox, Slider, Separator, Tabs/TabsList/TabsTrigger/TabsContent |
icon(name) | Any Lucide icon by kebab name |
| Props (what the component receives, live) | |
|---|---|
cell | { id, name, accent, state, pulses, vars } — the morph's live wire; the view re-renders on every change, so a pulse row or an agent's cells set shows at once |
setState(op) | One state operation, the same shape as a button's state ({ set }, { inc }, { append }, { toggle }, …). Your own edit; the wire comes back changed. |
stage(text, send?) | Stage text in the composer; send: true sends at once under the same consent seam as an action |
notify(text) | A notice, prefixed with the morph's name |
open(href) | An http(s) link in your browser |
intent(name, row?) | Hands. Run one of the morph's declared intents by name, over the row the view hands over (the {{holes}} in its call, run, desk or prompt fill from it). The verb is the file's, approved as written; the code only picks which and supplies the data. Resolves false when no such intent exists. |
desk(id, row?) | Open (or return to) a desk — a tab of its own — for a row |
popup(id) | Open one of the morph's popups, or "surface" |
select(row) | Write state.selected* for a row, exactly as a canvas card click does |
refresh() | Re-beat the morph's pulses now |
on(type, cb) | The engine, live. Subscribe to what the app announces as it happens — the same stream its own panels paint from: text-delta, tool-call / tool-result, step, turn-start / turn-done, agent-step, background-status, review-status, goal-status, task-update, context-status, pty-data, file-edited, fs-changed, tab-model, ask-user, plan-review, marionette-*, genome-status, worktree-agent, mcp-state, notice / warning / error … (50 names; props.events lists them). Returns the unsubscribe — bind it in hooks.useEffect and return it. Auth legs and invoke replies are never on this stream. |
cell.rows | The rows of every pulse, by pulse id; cell.intents the names the file declares |
Styling. A view block may carry "css": "…" — a stylesheet the app wraps in @scope so every rule stays inside that view: .card:hover { border-color: var(--morph-accent) } works, hover and focus and pseudo-elements cost no JavaScript, and nothing leaks into the app. The css is hashed with the module (a stylesheet can hide a control, so it is approved with the code). Views that throw paint their error in place, and cells debug reports every view's fate — rendered, held, blocked (with the policy), or threw (with the message).
Intents — hands for views
"intents": {
"approve": { "call": { "url": "https://hub.example/nodes/{{id}}/approve", "method": "POST" } },
"start": { "run": { "prompt": "Build {{text}}", "loop": true, "maxIterations": 4 } },
"evidence": { "open": "evidence" },
"take": { "state": { "set": { "owner": "{{who}}" } } }
}An intent is an action declared once by name at the top of the file (≤16): the same verbs a button has — call, run, desk, open, state, prompt/send, href, copy — with label defaulting to the name. A view invokes one with intent("approve", { id: node.id }); a button block or an action may not (they carry their own verb). Reach is computed from intents exactly as from buttons — a call's host, a run, a desk all count and are approved with the agent layer, re-held when they change — so code gets hands without loosening a gate: it picks which declared verb and hands over data, and can never invent a host, a header or a prompt the file did not write.
A view is code in the app — not a sandbox. It runs in the app's own process with everything the app can reach; that is the trade behind "a canvas, not a frame". So it is gated like a helper's run line and harder: it counts as reach ("runs its own code in the app"), the morph's agent layer is held until you approve that exact source in Cells & Morphs (any change holds it again), it runs only in a trusted repo (the same door as MCP servers and hooks), and an organisation can require a signature or turn views off with "cells": { "views": "any" | "signed" | "off" } in the global config — a blocked view is dropped and the card says why. Approving a view means trusting its author; for code that must be contained, use a frame with an MCP App. A view that throws is caught at the block, never the surface. The module has no imports — everything it needs comes through the shared scope; the same file works in every Empryo that exposes that scope. Where a view needs a server, use a frame with an MCP App. The rule that keeps the schema small: data by default, a view when data is not enough, hosted code when a view is not enough.
The row keys the model owns — id text detail badge group lane progress tone href prompt send icon image attach promoted desk tab actions fields — are properties; every other map key is a named fact in fields. title is not reserved: it is a fact like owner.
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.
Nothing is enforced
Every piece of app chrome around a surface is the morph's to keep or drop. "chrome": "plain" drops the card frame; "header": false drops the header (the morph mark, eyebrow, refresh and fold — and on a canvas the app's own header with its card count, model picker, zoom and Chat button); "footer": false drops the footer (helper and pulse status, chord hints); "chrome": "bare" drops all of it at once — the surface is exactly the morph's blocks, edge to edge. On a canvas the card count only shows for a board the app paints and the model picker only for a morph that can start a run. You always keep a way back: the Cells & Morphs panel, /canvas, the title-bar button.
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 morph is live. Nothing paints until the hole resolves to an image; the image's host counts as reach.
Helper
A morph 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 morph goes live, stops it when the morph goes off or to sleep, restarts it with backoff when it dies, restarts it when its script file changes (edit the script, no toggle), and hands it PORT, EMPRYO_CELL and EMPRYO_PARENT_PID. The panel's footer shows helper on :4877, helper held, or helper stopped with the last line of its stderr on hover; cells debug prints the stderr tail. A helper may answer 304 to If-None-Match — that is "unchanged", never an error. Helpers a dead sidecar left behind are reaped before a new one binds: every pid is kept in .empryo/cells-helpers.json, and a declared port held by any .empryo/morphs/ script is freed first. 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 morph'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 morph for ~/.empryo/morphs), and plain arguments. No shell, no pipes, no ... The agent writes the script beside the morph 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, every hop must pass the same address rule as the URL itself, and a public host can never redirect the app to a local address — localhost is only ever the address the file names. A name that resolves to a private or local address is refused at fetch time, on every hop.
Pulses run only after you trust the repo (the same gate as MCP servers and hooks). Until then the morph 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 morph's name |
Template holes and filters
A hole is a dot path into the row ({{fields.summary}}) or the morph'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 morph stays.
| Field | Limit |
|---|---|
name / description | 60 / 240 |
surface.title / body | 120 / 400 |
surface.blocks | 24 (also rail, inspector). A view block's module is ≤64 KB, its css ≤16 KB. |
surface.actions | 8. Each has one verb. intents 16. |
surface.items | 400 rows on the board. A row's named facts: ≤24, each ≤2 KB — one long fact clamps on its own, it never drops the row's other facts. |
pulses | 8. limit ≤120 rows from a remote host, ≤400 from loopback, engine://, files:// or a socket; a body ≤512 KB remote, ≤2 MB local. An empty payload is ok, 0 rows; only a payload with items none of which mapped is an error. A 304 is "unchanged". |
kanban | 12 columns, 24 lanes; lanes: [] means no lanes. |
commands | 6. name is 2 to 24 letters, digits, dashes. A built-in with the same name wins. Two morphs 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 | 6 |
triggers | 12 |
popups | 6, each ≤12 blocks and ≤6 actions |
palette | 12 |
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 | 6 |
minRuntime / fallbackText | A morph 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 morph 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 morph'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 morphs are live and names the first broken one.
How morphs compare
Nothing else combines the whole loop — external data → native workspace UI → your own edits → persistent, repo-owned state → the agent sees and acts on the same state. The pieces exist elsewhere; the combination is the point.
| System | What overlaps | What a morph adds |
|---|---|---|
| GitHub Copilot canvas extensions | The closest: a bidirectional side-panel surface with persisted state and agent-callable capabilities, committed to the repo (.github/extensions), shipped in plugins | A canvas is code the agent generates (extension.mjs + package.json) and the host runs; a morph is data the host validates. That is why a morph can show its reach before you enable it, hold its agent layer for approval, be signed, be hidden from the agent, and run guards — and why it needs no build, no dependency install and no trust in generated JavaScript. A morph also morphs the whole app (chips, commands, chords, triggers, welcome), not one panel. |
| Cursor canvases | Persistent React artifacts beside the chat; skills regenerate them; shared as read-only snapshots | Agent-generated code again, conversation-born; state is the artifact, not a shared object the agent reads back through a typed tool. |
| MCP Apps | Interactive views, forms, live state, tool calls inside an AI client | A morph is declarative JSON, persistent, repo-scoped and host-native; it shapes the workspace *and* the agent. A morph can also host an MCP App (frame with a ui:// source) when a view really needs code. |
| A2UI | Safe declarative JSON rendered with native components | A2UI is a protocol for agent-generated UI; a morph is an installed, durable application with feeds, rules, guards, state, commands and triggers. Cells & Morphs imports and exports A2UI. |
| Cursor plugins | Bundles of rules, skills, agents, commands, MCP, hooks | One shared object that spans native UI, visible state, live feeds, security rules and agent context — approved as written, hidden from the agent when private. |
| Appsmith / ToolJet / Retool | Low-code boards, forms, dashboards, API bindings | Those are separate internal apps. A morph lives inside the coding workflow and inside the agent's context. |
| Backstage plugins | Developer dashboards and integrations | Portal plugins are organisation-scale and need building. A morph is one file per repo. |
| VS Code extensions | Any panel, command or integration | TypeScript, packaging, APIs, reloads and broad trust. A morph is one JSON file, no server, no extension code, native UI, local-first. |
The trust model is the part that has to stay clear: feeds, secrets, instructions, guards and actions cross different boundaries, and each has its own gate — repo trust for the network, an approval hash for the agent layer, reach and hidden for what a model may see, signatures for who wrote it.
What a morph cannot do
- Run code of its own. A morph 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.
- Switch itself on. A morph the agent installs lands off — you read its reach in Cells & Morphs and flip it on.
- Stall the agent. A guard pattern that could run away is refused, not run.
- Break the app. Each hook renders inside a boundary: a bad morph renders nothing and is logged.