Directives: live panels in notes
Notes can embed directives — :grove-* syntax that renders as a live panel inside the Markdown and round-trips back to the exact source on save. A note stops being static prose and becomes a live view onto your workspaces, agents, and repos: a daily log with a real kanban board in it, a design doc with a live agent chat mounted inline.
Quick reference
| Directive | Form | What it shows |
|---|---|---|
:grove-workspace[<name>] |
inline | live pill for a workspace — dot, name, state; click focuses it |
::grove-workspace[<name>]{desc=…} |
block | the full workspace card: branch and worktree, PR (open or create), refresh / merge / archive |
:grove-chat |
inline | pill for one agent conversation — click resumes it; unlinked, click starts one |
::grove-chat |
block | a live, interactive agent chat embedded in the note — a real terminal, mounted inline |
::grove-diff |
block | changed files for a workspace or branch vs its base; expand any row to read that file's diff inline |
::grove-github{issues} / {prs} |
block | live GitHub issue / PR list for the repo |
::grove-github{issue=N} / {pr=N} |
block | a single GitHub issue / PR card (inline : form renders a pill) |
:::grove-board … ::: |
container | kanban board built from the body Markdown |
:::grove-html … ::: |
container | raw HTML from the body, rendered safely in a sandbox; {data=… refresh=…} feeds it a live data source |
Syntax
Three forms, all sharing an optional [label] and {key=value} attributes:
- Inline —
:name[label]{k=v}— flows inside a paragraph (pills, buttons). - Leaf block —
::name{k=v}— a standalone panel on its own line. - Container block —
:::name{k=v}…:::— a panel that operates on a chunk of Markdown.
The slash menu (/) inserts any of them with correct syntax, and agents can author them too — the MCP server exposes the directive catalog as a tool, so an agent writing a status note can drop in a working board.
The workspace directive
:grove-workspace names one unit of work and carries everything about it. The inline : form is a compact pill; the block :: form is the full card.
workspace=(or the[label]) — the workspace, resolved by name.desc=— a one-line description under the card head.pr=— pin an explicit PR. Omit it and the card resolves the PR from the workspace's branch — opening it if one exists, or offering Create PR if not.
A workspace is a branch and a worktree — not a conversation. Clicking a linked pill focuses the workspace (scoping the file tree and changes to its repo), and Start workspace creates the branch and worktree, nothing more. Chats are their own directive; drop a :grove-chat beside a card when a unit of work wants one attached.
The chat directive
grove-chat is one agent conversation, in two presentations. It isn't tied to a workspace: a chat is found by its own id, so the same directive works for a chat running in a worktree and one running at the project root. A chat simply belongs to wherever it was started.
:grove-chat{chat=<id>} link an existing chat — click resumes it
:grove-chat{session=<uuid>} adopt a session started outside Grove
:grove-chat unstarted — click starts one and links it back
:grove-chat[Label]{chat=<id>} label it however you like
chat=— the chat's id, and the only identity it has. Stable for the life of the conversation.session=— a Claude/Codex/opencode session Grove doesn't track yet. Clicking imports it as a chat, the same way the resume picker does.workspace=— where an unstarted pill should start its chat. Optional: without it the chat lands wherever the note lives.agent=— which agent to launch (Claude Code by default).
Without a label, the pill shows the chat's own name — the one the agent gives it after the first turn. A [label] in the note wins when you want it to read as something else.
The block form ::grove-chat embeds a live agent in the note — the same terminal-mode chat a chat pill runs, mounted inline. You talk to it at its own prompt; it edits files for real. Close and reopen the note and the conversation resumes where it left off.
The diff directive
::grove-diff shows a live diff as part of your prose: every changed file as a row with +/− counts, refreshing as the agent edits. Click a row's caret to expand that file's diff inline.
No workspace required — a bare ::grove-diff shows the current changes (the note's workspace if it has one, otherwise the project's working tree vs its base). Target something else with workspace=, branch=, or base=.
The board
:::grove-board reads its body as the board: ## Heading becomes a column, - card / - [ ] card become cards.
:::grove-board
## To Do
- wire the parser
## Doing
- [ ] write the panel
## Done
- [x] schema nodes
:::
Drag cards between columns, toggle checkboxes, add or delete cards and columns — each edit rewrites the body Markdown in place, so the raw view always shows the current state. The board is the Markdown.
HTML
grove-html is the escape hatch for anything the built-ins don't cover. :::grove-html::: renders its body as HTML in a fully sandboxed frame (it can't touch the app, your files, or your data — safe even for agent-authored HTML), auto-sized and themed to match Grove.
Live data
An HTML panel can render a live source without weakening the sandbox: Grove does the fetching and hands the payload in, so the frame itself never gains network, filesystem, or app access.
data=— what to read: a file path (note-relative like./stats.json, or absolute), anhttps://URL, or agrove:source (below). File reads stay inside the note's project and its connected code roots — a path outside renders an error instead. URLs are fetched by Grove itself rather than by the panel, so ordinary JSON APIs work whether or not they send CORS headers; the URL has to be a publichttp(s)address, so a panel can't reachlocalhostor anything on your private network.refresh=— how often to re-read:30s,5m,1500ms, or bare seconds (30). Minimum one second; leave it off to fetch once. Onlyms/s/mare understood — an unrecognized value like1hquietly means "fetch once". An unchanged payload is never re-delivered, so an idle source costs nothing.
Quote any value containing =. Directive attributes only accept unquoted values up to the first =, so anything with a query string must be wrapped in quotes:
:::grove-html{data="https://example.com/api?limit=5" refresh=5m}
Unquoted, the = ends the attribute and the whole directive silently falls back to plain text — no error, just literal :::grove-html… in your note.
Inside the frame, a small grove API delivers it — JSON arrives parsed, anything else as a string:
:::grove-html{data=./stats.json refresh=30s}
<pre id="out" style="margin:0">loading…</pre>
<script>
grove.onData((d) => {
document.getElementById("out").textContent = JSON.stringify(d, null, 2);
});
</script>
:::
grove.onData(cb) fires on every payload — immediately if one already arrived — and returns an unsubscribe; grove.data holds the latest. A failed fetch shows a small error line under the panel and keeps the last good payload on screen.
This composes with agents: point a panel at a file an agent keeps up to date, and the note stays live on its own.
grove: sources
data= can also name a curated, read-only query Grove runs with the app's own auth — no tokens in your notes, no agent in the loop. The note declares the query; the sandboxed HTML only ever receives the results, so embedded markup can never choose or widen what it reads. GitHub sources ride your gh login, with the same repo scoping as grove-github:
data= |
What arrives |
|---|---|
grove:github/pr/123 |
one PR — state, title, draft, review decision, labels |
grove:github/issue/45 |
one issue |
"grove:github/prs?state=open&label=bug&limit=10" |
a PR list, merged across the note's repos |
"grove:github/issues?assignee=me" |
an issue list |
(The two query-string forms carry an =, so they need the quotes shown.)
:::grove-html{data=grove:github/pr/123 refresh=60s}
<div id="pr">loading…</div>
<script>
grove.onData((pr) => {
document.getElementById("pr").textContent =
"#" + pr.number + " " + pr.title + " — " + pr.state;
});
</script>
:::
The built-in grove-github cards stay the polished default UI. grove: sources are for custom presentation over the same live data — a merge-readiness wall, a PR dashboard styled your way.
Behavior you can rely on
- Round-trip safe. A directive survives the WYSIWYG → disk auto-save unchanged. Unknown or misspelled
grove-*names render as a visible dashed chip and still round-trip — nothing is silently dropped. - Prose-safe. Ordinary colons (
foo:bar,std::vector,src/x.ts:42) are never treated as directives. Only thegrove-prefix is. - Actions confirm first. A directive's mutating actions (rebase, merge, archive, create-PR) require an explicit click, and the destructive ones confirm — pasting a directive into a note never auto-triggers anything.
- GitHub directives are read-only and ride your
ghCLI login — no separate token. Filters:state,label,assignee,limit.
See also
- Markdown editor — the surface directives render in, and the templates that ship with working directives.
- MCP — how agents author directives.