Use when drawing/generating a diagram for the Otto Canvas, creating or editing canvas scenes, or driving Discovery Chat on a Product story, from an agent session over HTTP. Covers the FILE-BACKED canvas model with THREE modes — Mermaid (edit a per-scene `canvas.mermaid`; any diagram type), Excalidraw (edit a per-scene `canvas.json`; fully editable shapes), and D2 (edit a per-scene `canvas.d2`; modern declarative diagrams — architecture, sequence, SQL tables) — top-notch pretty-Mermaid styling, the D2 shape/class syntax, the Excalidraw simplified element format, the OTTO_API_TOKEN auth model, the Canvas + Discovery-Chat endpoints, and the canvas.mjs helper.
Resources
3Install
npx skillscat add itzikiusa/otto-os/crates-otto-skills-assets-skills-development-otto-canvas Install via the SkillsCat registry.
Otto Canvas (Mermaid + Excalidraw + D2 modes) + Discovery Chat over HTTP
Otto's Canvas is file-backed with THREE modes the user picks per scene;
the agent edits the scene's file and the board re-renders (manual edits write
back to the same file too):
- Mermaid mode — the agent edits a per-scene
canvas.mermaid. The UI renders
it with Mermaid's own renderer, so ANY diagram type works (flowchart, sequence,
class/UML, ER, state). Best for rich auto-laid-out diagrams. - Excalidraw mode — the agent edits a per-scene
canvas.json(an Excalidraw
scene). The UI loads it in the real Excalidraw editor, so the user can also draw /
move / restyle shapes by hand. Best for freeform, hand-editable boards. - D2 mode — the agent edits a per-scene
canvas.d2. The UI renders it with
D2's own layout engine (client-side WASM), with an optional hand-drawn "sketch"
render mode. Best for architecture diagrams, SQL schemas (shape: sql_table),
and anything that benefits from containers/nesting.
The scene's doc_json stores { "type":"otto-canvas", "version":1, "format":"mermaid"|"excalidraw"|"d2", "source":"…", "sketch"?:bool }
(source is the file's text; sketch only applies to D2). The agent refines the
SAME file across the conversation, so follow-ups change the diagram instead of
regenerating it, and the work is never lost. Discovery Chat is a conversational
product-discovery agent on a story that can propose a canvas you then apply.
Excalidraw mode — edit canvas.json
In Excalidraw mode you EDIT canvas.json. Write the COMPLETE diagram as{"type":"excalidraw","elements":[ … ]} using the SIMPLIFIED element form (the app
expands it into a real Excalidraw scene — do NOT include seed/versionNonce):
- Shape:
{"type":"rectangle"|"ellipse"|"diamond","id":"n1","x":int,"y":int,"width":int,"height":int,"backgroundColor":"#hex","strokeColor":"#hex","fillStyle":"solid","label":{"text":"…","fontSize":16,"fontFamily":2}} - Arrow (routed by id):
{"type":"arrow","start":{"id":"n1"},"end":{"id":"n2"},"label":{"text":"yes"}} - Text:
{"type":"text","x":int,"y":int,"text":"…","fontSize":20}·fontFamily:3= code.
Lay nodes ~80px apart with no overlaps, size boxes to text, colour-code by role.
D2 mode — edit canvas.d2
In D2 mode you EDIT canvas.d2 — ONE complete, valid D2 diagram (no ``` fences
inside the file). Key syntax (full reference: references/d2-cheatsheet.md):
- Containers (nesting):
server: { api; db }— reference nested shapes with
dotted paths (server.api). - Edges + labels:
a -> b: label; chained:a -> b -> c. - Layout:
direction: right(pipelines/architecture) ordirection: down
(hierarchies, the default). - Shapes:
shape: sql_table(schemas, with typed rows),shape: sequence_diagram
(message flows),shape: cylinder(data stores),shape: queue,shape: person,
pluscircle/diamond/hexagon/cloudfor everything else. - Classes (reusable styles):
classes: { critical: { style: { fill: "#fee2e2"; stroke: "#dc2626" } } }
thendb.class: critical— the D2 analog of Mermaid'sclassDef. - Inline styling:
style.fill/style.stroke/style.font-coloron any shape. - Icons (optional):
icon: https://icons.terrastruct.com/....
Reach for this skill whenever you're asked to draw/diagram something on the
canvas ("show how service A calls B", "sketch the auth flow", "diagram the
order pipeline"), create/edit a scene, or run a discovery chat on a story.
Drawing a diagram — edit the source (Mermaid is the default)
When the canvas "Ask AI" turn runs, you are placed in the scene's directory with
the source file already there. Read it, make the requested change by editing it
in place, and save it — keep it the COMPLETE, valid diagram (no fences inside the file). Make minimal, additive edits across the conversation; don't rewrite from scratch unless asked. Driving the API directly instead? Return the diagram from `assist` (amermaid block) and the server commits it for you.
Mermaid is the default because you edit TEXT (the layout engine places nodes) —
far faster and more reliable than hand-computing coordinates for dozens of shapes.
Make the Mermaid top-notch (pretty by default):
flowchart TD(top-down) orLR(pipelines). Short labels, each with a leading
emoji icon, e.g.A["🚀 Start"](🌐🔐📦💳🧾📣🗄️⚡✅❌⏳).- Decisions are rhombus nodes
B{"❓ Valid?"}with LABELLED edgesB -->|yes| C/B -->|no| E; include error/retry paths, not just the happy one. - Group related steps with
subgraphlanes (e.g.Client/API/Data). - Colour-code by role with
classDef+class(put these at the END):classDef start fill:#dcfce7,stroke:#16a34a,color:#064e3b;classDef process fill:#eef2ff,stroke:#6366f1,color:#1e1b4b;classDef decision fill:#fef9c3,stroke:#ca8a04,color:#422006;classDef io fill:#f3e8ff,stroke:#9333ea,color:#3b0764;classDef data fill:#ecfeff,stroke:#0891b2,color:#083344;classDef error fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;
then assign withclass A,B start;. Code/commands go in a node label with<br/>between lines.
- Be accurate + reasonably complete, but keep node text short.
The board is editable. Subgraph lanes,
classDefcolours, node shapes and
edge labels all parse into editable nodes (and round-trip back to Mermaid when
the user hand-edits), so keep the source clean flowchart Mermaid — that's what
the user actually sees and edits. Sequence/class/ER diagrams render read-only.
Auth model (do this first)
Everything is a call to the running ottod daemon:
- Base URL:
OTTO_BASE(defaulthttp://127.0.0.1:7700), API prefix/api/v1. - Token: export
OTTO_API_TOKEN(a Bearer token). Every route below sendsAuthorization: Bearer $OTTO_API_TOKEN. Mint one with theotto-apiskill'sotto-setup-token.shif you don't have it. Verify withnode scripts/canvas.mjs whoami(the helper hitsGET /auth/me). - Roles: reads need workspace Viewer; all writes (create/update/delete a
scene, assist, every discovery-chat mutation) need workspace Editor. - Errors: non-2xx returns
{"code","message"}(unauthorized401,forbidden403,not_found404,invalid400,conflict409). The helper
printsHTTP <code>+ the body and exits non-zero — never swallow that.
You need a workspace id for the collection routes and (for discovery) a
story id. Resolve a workspace id with the otto-api client (otto ws-id).
The canvas.mjs helper (your main tool)
A zero-dependency Node ESM script (uses global fetch). Run it from this skill
dir. Full usage is in its header (node scripts/canvas.mjs --help):
node scripts/canvas.mjs list-scenes <wsId>
node scripts/canvas.mjs create-scene <wsId> "<title>" [storyId] # empty scene
node scripts/canvas.mjs get-scene <sceneId>
node scripts/canvas.mjs add-mermaid <sceneId> "<mermaid src>" # append a mermaid node
node scripts/canvas.mjs add-d2 <sceneId> <file.d2> # set the scene's D2 source from a file
node scripts/canvas.mjs add-slide <sceneId> "<slide title>" # slide revealing all nodes
node scripts/canvas.mjs assist <sceneId> "<prompt>" [mode] # assist on a scene
node scripts/canvas.mjs assist --preview "<prompt>" [mode] # assist with no sceneadd-mermaid / add-slide do a read-modify-write on the legacy multi-node Scene
schema: GET the scene, JSON.parse its doc_json, append a node/slide, thenPUT the whole doc back. add-d2 instead targets the file-backed single-source
doc directly (reads the diagram from a local file — D2 sources are multi-line and
awkward to pass inline) and replaces doc.source wholesale.
Canvas doc (file-backed) + legacy Scene schema
The canvas stores the agent-edited source in doc_json:{ "type":"otto-canvas", "version":1, "format":"mermaid", "source":"…", "positions":{…} }.
The UI parses source into editable flow nodes (positions keeps the user's
hand-arranged layout, which Mermaid itself can't carry). You don't hand-editdoc_json over the wire — call assist and the server commits the new source for
you (the UI also writes it back when the user drags/adds nodes).
The schema below is the legacy non-Excalidraw Scene form (still accepted by
older scenes / canvas.mjs add-mermaid):
A Scene is { schema: 1, title, nodes[], edges[], slides[], appState? }.
- node —
{ id, kind, x, y, w, h, z?, rotation?, label?, parent?, <payload>, style? }.x/y/w/hare scene-space (pre-zoom).kind∈shape | text | sticky | freehand | code | json | mermaid | image | group | frame, and the matching payload
key carries the content:shape: { variant: rect|roundrect|ellipse|diamond|triangle|cylinder|parallelogram, fill?, stroke?, sketch? }text: { value, align?, size? }·sticky: { value, color? }code: { value, lang? }·json: { value }(raw JSON text)mermaid: { src, kind? }— a whole diagram in one node (the killer path)image: { attachmentId?, dataUrl? }·freehand: { points: [[x,y,pressure?]…], color?, size? }frame/groupnest children via theirparentid (frames define slide viewports).
- edge —
{ id, source, target, sourceAnchor?, targetAnchor?, kind?: arrow|line|dashed, label?, style? }.
(Connect freeform nodes; mermaid diagrams carry their own internal arrows.) - slide —
{ id, title?, frameNodeId?, mermaidNodeId?, reveal: RevealStep[], notes? }.
ARevealStepis{ nodeIds?: string[], mermaidMessageRange?: [from,to] }. To
present a sequence step-by-step, setmermaidNodeIdand reveal message ranges. - appState —
{ background?, grid? }.
Prefer one
mermaidnode for sequence/flow/class(UML)/state/ER diagrams —
it's the densest, most reliable form. Use freeformnodes+edgesonly for
layouts mermaid can't express. Full schema:references/scene-schema.md.
Two killer workflows
1) Draw a diagram (the common case)
When asked to visualize a flow ("service A calls B, B does 10 things"):
- Pick/author the mermaid — a
sequenceDiagramis usually right; be
exhaustive, one message per sub-step so nothing is hidden (seereferences/mermaid-cheatsheet.mdandexamples/service-fanout.md). - Create a scene (or reuse one), then add the diagram:
SID=$(node scripts/canvas.mjs create-scene "$WID" "Auth flow" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p') node scripts/canvas.mjs add-mermaid "$SID" "sequenceDiagram A->>B: request B->>B: step 1 B-->>A: response" - Let the agent draft it for you instead of writing mermaid by hand — call
assist, then add the returnedmermaid(ornodes/edges):node scripts/canvas.mjs assist "$SID" "service A calls B, and B does 10 things"assiston a scene now EDITS the scene's source file and commits it
(updatingdoc_json), then returns{ mermaid?, d2?, excalidraw?, format, note }
and broadcasts the live update.assist --preview(no scene) just returns the
blocks without persisting. The defaultformatismermaid.
2) Present a scene as slides
After building a diagram, add a slide so it can be walked through:
node scripts/canvas.mjs add-slide "$SID" "Walkthrough"add-slide appends a slide whose reveal exposes every current node. To step a
sequence message-by-message, set the slide's mermaidNodeId and usemermaidMessageRange reveal steps — see examples/sequence-to-slides.md.
Discovery Chat (drive product discovery from a story)
To research/shape a story conversationally (it can propose a canvas you apply):
# Start a chat on a story, then send a turn:
otto POST /product/stories/$SID/discovery-chats '{"title":"Discovery"}' # → {id: CID, …}
otto POST /product/discovery-chats/$CID/messages '{"body":"How should login lockout work?"}'
otto GET /product/discovery-chats/$CID # chat + transcriptThe agent replies in markdown and MAY emit a fenced json {actions:[…]} block.
Actions are never auto-applied — they come back on the agent message asactions_json; apply ONE at a time:
otto POST /product/discovery-chats/$CID/apply \
'{"action":{"type":"create_canvas","title":"Login flow","mermaid":"sequenceDiagram\n …"}}'Supported action types: apply_draft (sets the story draft), add_questions,add_notes, create_canvas (creates a scene → returns canvas_id). Exact
bodies/responses: references/endpoints.md.
Read-only MCP vs. writes through this skill
Otto's first-party MCP tool server (ottod mcp-tools) is read-only by hard
invariant — it only issues HTTP GETs, never a write. If the integrator wires
canvas read tools, you may see canvas_list_scenes / canvas_get_scene as MCP
tools for inspecting scenes. All writes — create/update/delete a scene,assist, and every discovery-chat mutation — go through this skill'scanvas.mjs HTTP scripts (or the otto client) with OTTO_API_TOKEN. Don't
expect an MCP tool to mutate a canvas; there isn't one and won't be.
References & examples
references/scene-schema.md— the complete Scene schema (mirrors the UI'scanvas/types.ts).references/endpoints.md— every Canvas + Discovery-Chat endpoint with
method/path/body/response.references/mermaid-cheatsheet.md— sequence/flowchart/class/state/ER syntax +
the fan-out pattern.references/d2-cheatsheet.md— D2 shapes/containers/classes/styles,sql_table,sequence_diagram,direction— one page.examples/service-fanout.md— "A calls B; B does 10 things" → exhaustive
sequence diagram on a scene.examples/sequence-to-slides.md— build a sequence, then present it step by step.
Common mistakes
- Forgetting
OTTO_API_TOKEN→ every call 401. Checknode scripts/canvas.mjs whoami. - Hiding fan-out: collapsing "B does 10 things" into one box. Emit all 10 as
distinct messages/nodes — the assist prompt itself demands exhaustiveness. - For a SCENE,
assistnow commits the edited source server-side (and returns
it); onlyassist --preview(no scene) is non-persisting. Don't double-write. - Auto-applying discovery actions: they are proposals.
applyexactly the one
the user approves. - Using a session id where a workspace/story id is needed:
list/createscenes
are workspace-scoped (/workspaces/{ws}/canvas/scenes);get/put/delete
are flat by scene id (/canvas/scenes/{id}). - Hand-editing
doc_jsonas a string: alwaysJSON.parse→ mutate the object →
PUT it back underdoc(the helper does this for you).