

Canopy
Sharing Graphs
Last updated
Export and import
A graph’s design — its name, description, nodes, edges, and ensembles —
can leave one machine as a single JSON file and be recreated on
another. Sharing a graph becomes sending a file, not narrating the
graph_node add/graph_edge add/graph_ensemble add calls that built
it, and the file is also a diff: review a change to a graph, or keep
one in a repo next to the code it operates on.
canopy graph export <graph_id> [--output <path>]
canopy graph import <path> [--workdir <dir>] [--name <name>]
export writes to --output, or to stdout (so it can be piped) when
omitted. import always creates a new graph — it never updates,
merges, or overwrites an existing one; --workdir defaults to the
current directory, and --name overrides the file’s own name. If the
resolved name is already taken in the target workdir, import still
succeeds under a numeric suffix ("My Graph (2)") and reports which
name it used. The same two operations exist over MCP as
graph export (graph_id) and
graph_edit import (document, workdir, name?), so a graph is drivable end
to end through MCP as well as the CLI.
Every daemon database connection waits up to 5 s for a concurrent writer
before giving up, so imports normally queue behind a busy database rather
than failing. If the wait still expires, graph_edit import returns
graph_edit: database busy after 5 s — nothing was written, retry:
the import is all-or-nothing, nothing partial is left behind, and the
call is safe to retry as-is.
What the file excludes is deliberate: no ids (edges reference
nodes by name, which is what makes the file reviewable and
hand-editable — node names must therefore be unique within an exported
graph, or export refuses with the names it found), no workdir, no
specs, and no run/status state. A graph file is a shape and a set of
instructions, not somebody else’s backlog or history.
platform/model are always included for every agent node and
ensemble member (format_version: 2), so an exported file round-trips
its harness bindings through import unchanged. A node that uses its
platform’s default model exports "model": null explicitly, and an
ensemble member without a binding or override exports explicit nulls
for the unset fields. Import accepts both format_version: 1
(members with no binding, reported as missing a platform) and 2
(bindings restored verbatim); it never rejects a platform the
importing machine has not configured — validating that a pair is
usable belongs to graph_control preflight. import’s response always lists
every agent node left without a platform, so there’s exactly one
thing to check before running an imported graph:
nodes_missing_platform in the MCP response, or the same list
printed by the CLI.
An ensemble’s kind is carried in the document for cascade and
round_robin; export omits it for parallel, which is what an
absent kind means on import. Import refuses a kind value it does
not know — naming the ensemble and the value — and refuses a document
that carries any field it does not know how to restore, as part of
its all-or-nothing contract. format_version 6 remains the newest
accepted version (it also carries the bundled prompt_presets, below);
1, 2, 3, 4 and 5 documents import unchanged (they carry
fewer fields, never more).
A node’s prompt_preset names a preset that must exist on the
importing machine: a file <canopy_dir>/prompts/<name>.md (the same
file the engine reads at spawn time) or one of the builtins (implementer,
reviewer, resilience, which are seeded on daemon start and fall back
to embedded constants). Since version 6 the document bundles every
custom preset its agent nodes reference — a prompt_presets array of
{"name", "content"} entries carrying the full text, so a shared
recipe arrives with everything its nodes need. On import, each bundled
preset is installed into the prompts dir when no file with that name
exists locally; a local file with identical content is a no-op, and one
with different content refuses the import — naming the preset — so a
preset you edited locally is never overwritten. Builtin presets are
never bundled. A preset the document references but neither bundles nor
has locally still refuses the whole import — naming every unknown
preset and writing nothing — rather than importing a graph whose agent
would silently run on an empty prompt:
Unknown prompt preset(s): my-auditor, my-reviewer. Add file(s) under /home/you/.canopy/prompts or use a builtin (implementer, reviewer, resilience).
Ensemble members are not checked — a member
carries no preset of its own. Nothing else about prompt resolution
changes: at spawn time an explicit prompt_template still wins, then
prompt_preset, then the fallback template, and a blank
prompt_preset names no preset and takes that fallback exactly as
before.
A crew-bound design travels by name, never by its members:
graph export writes crew: "<name>" on a bound ensemble (with no
members array), on a bound agent node’s config, and on a bound agent
hook — and, since version 5, a graph’s event-keyed hooks ride along
in the document as well, which is what gives a bound hook something to
travel in. graph_edit import keeps the reference when a crew of that name
exists locally: the ensemble binds and re-materializes the crew’s
members, and a bound node or hook resolves its pair at dispatch from
the crew. When the name is missing locally the import still succeeds,
lists every unresolved binding under missing_crews (next to
nodes_missing_platform) in the MCP response or the CLI output, and
leaves the binding pending — a pending ensemble cannot dispatch until
a crew of that name is created, and graph_control preflight reports it as
would_fail.
A crew-bound ensemble travels without members (the crew supplies them)
but not without its wiring: an absent members key is simply an empty
list, and the ensemble’s entry_from_node/entry_condition, every
extra_entry_sources item, and any on_pass_to/on_fail_to exit
chained into another ensemble are all recorded at import. They are wired
through the same sync path the engine already uses the moment the crew’s
members are materialised — during the import when the crew exists
locally, or on the crew’s first sync (dispatch, or crew create/
update) when it is created later. Either way the result is a fully
wired ensemble, so a shared design reaching a machine that has the crews
arrives runnable; a document that names neither a crew nor any members is
still refused, naming the ensemble.
An ensemble round-trips as one ensemble — not as its
expanded member/quorum nodes — via its own ensembles array entry.
The entry_from_node, on_pass_to, and on_fail_to fields use a bare
string for a plain node. When wiring to another ensemble’s quorum, export
uses { "ensemble": "<name>" } so an ensemble target is distinct from a
plain node with the same name; import resolves that table to the target
ensemble’s join node and restores its member fan-out edges.
An ensemble may be entered from more than one node
(graph_ensemble update’s add_entry_from). Export writes every such
source in extra_entry_sources, an array of
{"from_node": "<node name>", "condition": "<pass|fail|always|error>"} entries
beyond the primary (entry_from_node + entry_condition). Import restores each as
an edge to every member, so multi-source entry — e.g. a committer entered on
pass by an unlock check and on fail by a commit-verification check — survives
the round trip. The field is omitted when an ensemble has only its primary source.
Here is a complete, hand-writable example: an implementer, a 2-model ensemble of reviewers, and a committer the quorum routes to on pass.
{
"format_version": 5,
"name": "implement-and-review",
"description": "Implement a spec, get two model opinions, then commit.",
"nodes": [
{
"name": "implementer",
"kind": "agent",
"position": 1,
"config": {
"platform": "opencode",
"model": null,
"prompt_template": "Implement: {{spec_content}}",
"timeout_minutes": 30
}
},
{
"name": "committer",
"kind": "agent",
"position": 4,
"config": {
"platform": "opencode",
"model": "opencode/muse-spark-1.3-contributor-free",
"prompt_template": "Review the feedback and commit if satisfied.",
"commit_rights": true,
"timeout_minutes": 15
}
}
],
"edges": [],
"ensembles": [
{
"name": "reviewers",
"kind": "cascade",
"prompt_template": "Review this diff for correctness: {{previous_feedback}}",
"entry_from_node": "implementer",
"entry_condition": "always",
"on_pass_to": "committer",
"min_pass": 1,
"timeout_minutes": 20,
"members": [
{"platform": "copilot", "model": null, "prompt_override": null},
{"platform": "opencode", "model": "opencode-go/qwen3.7-plus", "prompt_override": null}
]
}
]
}
Note what is absent: no id anywhere, and the ensemble’s own
member/quorum nodes never appear in nodes — only its
entry_from_node/on_pass_to (both plain node names) and its
members array do. implementer uses its platform’s default model,
hence the explicit "model": null; a format_version: 1 document
with {} members still imports, with every unbound node reported as
nodes_missing_platform. reviewers runs as a cascade: one member at
a time, first usable verdict wins. reviewers has a single entry source, so
extra_entry_sources is omitted; an ensemble entered from several nodes carries
each additional one there.
Validate before you share
Before sending a file — or installing one you received — check it without touching the database:
canopy graph validate <path> [--json]
The same check exists as the MCP
graph validate (path? or document?, exactly one of the two). Both
detect the document kind on their own: an object with nodes is a graph
document, one with kind plus config is a node document. Neither needs
a workdir, and neither writes anything.
A graph document runs every structural check import runs —
format_version, no unknown field anywhere in the document, known
config keys per node kind, the mutex shape, ensemble shape, and
template bindings — through the same code, plus one sharing rule of its
own: every prompt_preset a node names must be bundled in the document
(prompt_presets, version 6) or a builtin (implementer, reviewer,
resilience). A bundled entry itself must be installable anywhere too —
a plain non-blank name, not a path, not a builtin, not repeated, with
non-empty content — the same entry rules import enforces before
writing anything. Validation ignores what this machine happens to have
installed, so a green report means the document itself carries
everything its nodes need; import can still refuse to overwrite a
locally edited preset file whose content differs from the bundled one.
A node document is one shareable node:
{
"format_version": 1,
"name": "my-check",
"kind": "check",
"config": { "command": "true" }
}
Its rules: format_version is 1, name is a slug ([a-z0-9-]{1,48}),
kind is a known authorable kind (join is engine-managed and refused),
the config holds only keys that kind reads, no platform, model, or
cli key appears anywhere in the config (harnesses are chosen by the
installer, never the author), and a bundled preset carries the same
name as the config’s prompt_preset when both are present.
On success the report carries ok, the kind, the name, the
node/edge/ensemble counts, and the agent nodes that will still need a
harness (needs_harness); a node document also carries node_kind
(agent, check, gate, router) and reports one node, no edges and
no ensembles. On failure it carries every problem found — not only the
first — each with a JSON path (nodes[3].config.prompt_preset) and a
message. The CLI exits 0 on success and 1 on failure; --json prints
the machine-readable report.
Install a graph
To find what is published, search the catalogue first with the
catalogue tool’s search action (q, optional
sort/page/limit; see MCP Tools).
A published graph becomes a local graph in one command, through the
graph catalogue at graphs.univerlab.org — fetching through the
catalogue is what counts the install:
canopy graph install <ref> [--workdir <dir>] [--name <name>] [--as <prefix>] [--replace] [--overwrite-presets] [--yes] [--show-prompts] [--json]
canopy graph install draft:<id>
The ref is <slug> for an official graph or <owner>/<repo>/<slug>
for a community one, each with an optional @<ref> suffix selecting a
version (my-graph, acme/widgets/my-graph,
acme/widgets/[email protected]). The slug is 1–48 lowercase letters,
digits, or -; owner and repo follow GitHub’s naming rules. Anything
else is refused with the accepted forms in the message.
A draft shared from the editor at graphs.univerlab.org/editor
installs with canopy graph install draft:<id> (a node draft with
canopy node install draft:<id>); <id> is 26 lowercase base32
characters ([a-z2-7]), and anything else after draft: is refused
with the accepted forms. The catalogue serves the draft directly at
<catalogue_url>/i/draft/<id> — no redirect, and a redirect is
refused rather than followed — under the same User-Agent, 15 s timeout
and 1 MiB cap as any other ref. A draft lives 30 minutes, installs
once, and carries no review: the summary and the confirmation question
both say This is an unreviewed draft, not a published document., and
the review and the report name the source draft. The review fetch is
what consumes the draft, so a refused install leaves it gone — the
refusal says so and asks you to copy a new link from the editor — and
a draft that expired or was already installed reports draft not found: drafts last 30 minutes and are deleted by their first install.
Review before anything is written. A shared graph is code: check and gate nodes run shell commands with your permissions, hooks run commands too, and agent nodes carry prompts that steer an agent able to edit files and run tools. So the install first fetches and validates the document and prints a review summary of everything it will run:
- every shell command — check/gate
config.commandand hookcommand— labelled by node or hook, verbatim; - every agent instruction —
prompt_template,prompt_override, the bundled or builtin preset content behindprompt_preset, and hookprompt— with its total line count and its first 3 lines;--show-promptsprints each one in full; - attention marks for command shapes worth a second look: a pipe
into
sh/bash(typicallycurl … | sh),sudo,rm -rf,chmod 777,git pushor--force, writes under~/.ssh,~/.configor/etc,eval,base64 -d, and a pipe intopython -cornode -e. A mark is a flag for your eyes, never a block — the same marks the catalogue pages show.
Only then does it ask Install <name> from <source>? [y/N], where the
source is official, owner/repo (plus the ref, when given) or
draft — a draft’s question also carries the unreviewed-draft line.
Official graphs are asked about exactly like community ones — official
is not exempt. Answering anything but y/yes installs nothing. When
stdin or stdout is not a TTY (a pipe, a script, an agent) the install
refuses after printing the summary, exiting 1 with
refusing to install without confirmation: re-run with --yes after reviewing the commands above — re-run with --yes to proceed without
the question, or --json to get the review as JSON on stdout (the
question then goes to stderr). The summary is printed in every mode;
--yes skips the question, never the review.
The fetch is one GET with a canopy/<version> User-Agent, following
redirects (at most 3, only ever to https — a catalogue URL you set to
http yourself may redirect within http, and any other scheme is
refused) with a 15 s timeout and the body capped at 1 MiB — no other
network traffic. A draft is the exception: it is served directly, and
a redirect is an error, not a hop. HTTP errors are reported with the
status and the URL tried; a 404 says the graph was not found in the
catalogue, and a 410 says <name> was removed from the catalogue after a community report: <reason> (<issue>) — a document the catalogue took
down after a report is never installed.
The fetched document goes through the same validation as
validate: any problem aborts the
install and lists every problem. Its bundled presets (version 6) are
written to ~/.canopy/prompts/ — a file already there with identical
content is a no-op, while one with different content owned by another
source aborts the whole install before anything is written, naming the
file and the source that owns it, unless --overwrite-presets (or
--replace) is passed.
Every preset written is stamped with the ref it came from, so a later
install from another source ships the same name without silently
taking it: reinstalling from the same source updates in place, while a
different source is refused. --as <prefix> renames the colliding
presets instead (review becomes alice-review, with the node
configs rewritten to match). The import itself is the
import path: always a new graph (a numeric
suffix when the name is taken), --name overriding the document’s
name, --workdir defaulting to the current directory (which must be
absolute and exist).
The report carries the graph id and name, its source, the workdir, the
presets written, the review, and the agent nodes still needing a
platform/model with the next command to assign them (--json prints
the machine-readable report). The same operation exists as the MCP tool
graph_edit install (ref, workdir, name?, as?, replace?, overwrite_presets?, accept?):
called without accept: true it installs nothing and returns
{ needs_review: true, review: { source, commands, prompts, attention } } — the same review printed above; called with
accept: true it installs and echoes that review in the result. An
agent driving the MCP tool must show the review to its user before
passing accept: true. A draft keeps the two-call shape: the daemon
parks the body the review call fetched, so accept: true installs it
without a second request — and a draft that reports draft not found
is gone for good; ask for a new link from the editor rather than
retrying. The catalogue base URL is the graphs.catalogue_url setting
(default https://graphs.univerlab.org,
see Tuning the engine).