Login
GP-Crystal
Login

GP-Crystal (gpcr) is a Crystal DSL and toolset for defining and running GP / MicroBlocks-style graphical programming constructs. It is not a separate language, bytecode, or intermediate representation. The Crystal source file is the canonical artifact — it compiles directly with the Crystal compiler, reads naturally as text, and renders as GP/MicroBlocks-style blocks in a graphical editor.

Three design goals shape every decision:

  1. Find the Crystal/GPCR boundary — explore how much standard Crystal syntax and annotations suffice before needing DSL restrictions or a sub-language
  2. Self-hosting — GPCR should be writable in GPCR itself; only a small enumerated set of primitives — where Crystal-native code enters: the event loop, IO, hardware, and the compile-time reflection macros — sits below that line (see The line: Crystal-native, primitives, blocks)
  3. JSON as edit transport — MCP tools whose JSON arguments carry each edit, for models, voice, and graphical editors; never stored, always discarded after the source file is updated

Documentation map: this README is the introduction and authoring doctrine. The normative, decision-bearing documents live on the project wiki: GP-Crystal Design Overview (resolved design decisions, including the closed spoken grammar), GP-Crystal Primitive Catalog (the primitives — the complete set of places Crystal-native code enters), GP-Crystal Block Catalog (the standard library block palette, defined in GPCR), and Pikchr UI Subset (the dual-renderable UI element set). Each design question is answered in exactly one place and the other documents point at it; a contradiction between documents is a defect to fix, not a conflict to rank.


Three surfaces

Surface Description Stored?
Crystal source Annotated Crystal defs, compiled by Crystal compiler, canonical on-disk form Yes
Graphical blocks Rendered from Crystal source + @[GPCR::Graph(...)] annotations No (derived)
JSON MCP edit-tool arguments for models, voice, graphical editors; applied then discarded No (ephemeral)
Crystal source (.cr) graphical blocks JSON edit protocol
S: box "Crystal source (.cr)" wid 2.4 ht 0.4 rad 0.08 fill 0x4C97FF color white
G: box "graphical blocks" wid 2.4 ht 0.4 rad 0.08 fill 0xFF8C1A color white with .n at S.s + (-1.8, -0.8)
J: box "JSON edit protocol" wid 2.4 ht 0.4 rad 0.08 fill 0xFFAB19 color white with .n at S.s + (1.8, -0.8)
arrow from S.sw to G.n
arrow from J.n to S.se dashed
line from G.e to J.w dotted

Execution model

Each graph (a @[GPCR::Graph]-annotated def) runs in its own Crystal Fiber. The runtime spawns one fiber per graph on startup. A graph with a hat loops: it waits on the hat's event, runs its body, then waits again. A graph with no hat runs once and exits.

Crystal fibers cooperate through an event loop. A fiber runs until it voluntarily yields by blocking — on a Channel receive, a sleep, or an I/O wait. While blocked, the event loop runs other fibers; when the event fires, the blocked fiber is resumed. See Crystal::EventLoop::Polling::Event::Type for the full set of event kinds the loop handles.

Scheduling is cooperative — which is also how MicroBlocks behaves in practice: MicroBlocks tasks yield at the bottom of every loop iteration, and GPCR keeps that behaviour. The forever and repeat forms include a yield point (Fiber.yield) at the end of each iteration, so a tight loop can never starve the other graphs. There is no preemption to emulate; straight-line code between waits is atomic by construction.

Blocking mechanism Crystal form GPCR use
Channel receive ch.receive user events, button presses, inter-graph messages
Timer sleep(duration) the wait primitive (timed yield)
I/O poll socket / file read serial, network, system events
spawn fiber on start run body
scale = 0.8
A: box "spawn fiber" wid 1.8 ht 0.35 rad 0.08 fill 0x4C97FF color white
B: box "on start" wid 2.5 ht 0.35 rad 0.08 fill 0xFFAB19 color white with .n at A.s + (0, -0.1)
C: box "run body" wid 2.5 ht 0.35 rad 0.08 fill 0x4C97FF color white with .n at B.s + (0, -0.1)
arrow from A.s to B.n
arrow from B.s to C.n
arrow dashed from C.w left 0.4 then up 0.45 then right 0.4

"on start": one primitive, one block

The primitive is the Channel receive: receive-event sits below the line because its body bottoms out on a Channel. There's nothing to wrap — the primitive is the receive. on start is then the thinnest possible block: a hat-shaped def whose whole body is one receive-event call.

GPCR_EVENTS = {"start" => Channel(Nil).new(0)}   # unbuffered: send blocks until received

# primitive: blocks the fiber until the named event fires;
# the caller resumes when it returns.
@[GPCR::Primitive(shape: :command, label: "receive event #{name}", category: "Events")]
def receive_event(name : String)
  GPCR_EVENTS[name].receive   # yields to the event loop; resumes when the event fires
end

# block, defined in GPCR: the start hat is one receive-event call
@[GPCR::Block(shape: :hat, label: "on start", category: "Events")]
def on_start
  receive_event "start"
end

# the runtime's start-up (or a test harness) fires it:
GPCR_EVENTS["start"].send(nil)

Both annotations declare the same block face (shape, label, category); the annotation name declares the contract for the body. @[GPCR::Primitive] marks the def as a place Crystal-native code enters — its body renders as a code fence, because it contains AST elements with no graphical rendering. @[GPCR::Block] promises the body uses only renderable elements — calls to other blocks and Crystal-native forms with node-kind renderings — and the compiler checks that promise. Both faces render fully graphically at every call site. We could push the primitive lower — below Channel to a raw event source — but Channel.receive is a fine level for now, and we draw the line only where it has to be drawn. Every other event (button press, a message from another graph) is the same receive-event pattern with a different name.

Events are runtime-provided, not language-defined. The DSL defines exactly one hat form — on-event (the on-X-do-Y convention; when is a Crystal keyword and is never used). The default runtime provides only the start event and message events (broadcast / on message). Button-press arrives as a message generator; GP's flag metaphor is retired as a language element, but a graphical runtime can reintroduce a flag the same way — as one more message generator. New events never grow the language.

Graph positions within the editor canvas are fixed per graph, stored in @[GPCR::Graph(x:, y:)]. Individual block positions within a graph are implicit from stack order — no per-block layout annotation is needed.

Because GPCR compiles to native machine code (Crystal → LLVM), even microcontrollers are in reach without a VM — the efficiency of compiled C. Small targets may mean choosing to leave out runtime features (garbage collection, parts of the stdlib); that is a reduced own runtime, not a foreign one. Exactly which features a small-target build leaves out is decided at the microcontroller milestone, when a real board build forces the choices.


Compiling to visual Markdown

Think of GPCR as Crystal with one added requirement: every construct a GPCR program uses must have a way to render as Markdown + Pikchr. That requirement does double duty — it draws the GPCR / Crystal boundary (a construct is "in GPCR" exactly when it has a rendering — the boundary the design goals keep pointing at) — and it guarantees every valid program is renderable: you can't use something the compiler doesn't know how to draw.

Rendering leans on shapes, the way GP and MicroBlocks do: a block declares which shape it is — hat, command, reporter, boolean — and references it; the drawing for each shape lives in one place and is shared by every block of that type. New blocks are free (they just name a shape); only a new shape ever needs a new drawing.

The GPCR visual compiler runs on Crystal's macro AST — the ASTNode layer available at compile time, the same layer the examples use to detect C-shape bodies from do…end block arguments. It visits each node at compile time and renders it — a block through its shape, structure through the compiler — into a fragment of Markdown prose, embedded Pikchr, or both. Concatenated, the fragments are the visual Markdown for the whole program.

That Markdown is rendered by Fossil when it serves the page — Fossil's Markdown renderer handles the prose and the embedded Pikchr fences together, in one pass, as it already does for any wiki/embedded-doc page. (fossil pikchr on the command line is only a syntax check for a single fence during development/CI — it is not the serving path.) The end state extends Fossil so that a .gpcr file generates its Markdown through this compiler and hands it straight to Fossil's normal Markdown rendering — no separate build step, and the natural seam for live editing inside the server.

That settles the editor question: GPCR does not target the GP or MicroBlocks editors. Its editing surfaces are the Fossil-served graphical view, the spoken grammar, and the MCP statement ops — with the Pikchr UI Subset keeping every rendering dual-representable on the web and lvgl-crystal paths.

As much of GPCR as possible is written in GPCR — the shared goal, and the rendering pipeline itself follows it. The pipeline rests on one small node data model: kind, label parts, children, and the node's source text (which is what makes the fence fallback free). Two producers fill it. At runtime, the parse-source primitive turns source text into node data via Crystal::Parser — the editor path, for rendering and editing any file the server holds. At compile time, the reflection macros — the second kind of primitive, macros rather than defs — walk the program's own defs and annotations in macro-land and emit the same node data as literals, so a compiled gpcr program carries its reified tree and can render itself without the compiler library present. Everything downstream of the producers — stack layout, shape drawing, Pikchr emission — is ordinary defined blocks over that data: string building, arithmetic, write-stream, all renderable elements. It is not odd for a compiler to be written in the language it compiles; all of this is annotated Crystal compiled by the Crystal compiler.

Renderings are affixed in GPCR syntax. A rendering — for a shape, a structure like if/else, or a literal — is declared the same way any block face is: an annotated def, collected by the same macro walk. Compile that declaration and the construct is renderable from then on; a node kind with no declared rendering falls back to the passthrough code fence below. So the renderer registry is not a table hard-coded inside the compiler — it is GPCR code, and the renderable surface grows the way the language does.

Bootstrap incrementally. The compiler needs no complete rendering set on day one. It can start by passing the Crystal source straight through into a Markdown code fence — a correct, if plain, rendering. Then, node kind by node kind, a graphical rendering replaces the passthrough with prose + Pikchr; any node without one yet falls back to the code fence. So the visual surface grows from "source in a code block" toward "fully drawn blocks" and is never un-renderable in between.

The rest of this document works from the drawn end: the next section fixes the canonical block shapes, and the examples show whole graphs rendered this way.


Block shapes

A shape — hat, command, reporter, or boolean — is the silhouette of a block, and it is the only graphical information anywhere. Each shape is drawn once (below the line, for now), and a block references its shape rather than carrying a drawing; the same hat drawing serves every hat. This is straight from GP/MicroBlocks, where a block's spec carries a short type marker and nothing else about its looks.

Shapes belong to block definitions, not instances. A block instance — a call inside a graph — carries no graphical data of its own; it is anchored by its usage: the enclosing graph's canvas position comes from @[GPCR::Graph(x:, y:)], and each instance's place in a stack comes from source order and nesting. A renderer draws an instance by taking its definition's shape and positioning it from that usage anchor — shape from the definition, layout from the source — so layout is derived, never duplicated onto every block. That keeps the graphical surface a 1:1 peer of the textual and JSON surfaces without a separate stylesheet.

Rendering is Fossil Markdown (embedding Pikchr). A graph renders to Markdown: its leading comment (the doc comment before the def) becomes prose, and the blocks become an embedded Pikchr fence — each block drawn from its shape (grammar below), placed by its usage anchor. Fossil renders it when the page is served, in one Markdown pass over prose and fences alike, so the renderer ships with the substrate (no external dependency; fossil pikchr on the CLI is only a fence syntax-check). Because the surface is Fossil-native, a future update can offer live editing inside the Fossil server.

The four shapes, each with its canonical silhouette:

Kind Shape Role
hat Dome top with bottom stack tab Event trigger; starts a graph
command Stack brick with top notch and bottom tab Executes an action; may carry C-shape body slots
reporter Oval pill Returns a value; dropped into input slots
boolean Point-ended hexagon Returns true/false; fits boolean input slots
hat command reporter boolean
scale = 0.75

# Hat: dome top + stack tab
H: box wid 2.2 ht 0.34 fill 0xFFAB19 color 0xFFAB19
ellipse wid 1.6 ht 0.34 at H.nw + (0.8, 0) fill 0xFFAB19 color 0xFFAB19
box wid 0.30 ht 0.055 rad 0.018 fill 0xFFAB19 color 0xFFAB19 at H.s + (-0.52, 0)
text "hat" color white at H

# Command: stack brick with top notch and bottom tab
C: box wid 2.2 ht 0.34 rad 0.035 fill 0x4C97FF color 0x4C97FF with .n at H.s + (0, -0.30)
box wid 0.30 ht 0.055 rad 0.018 fill 0xffffff color 0xffffff at C.n + (-0.52, 0)
box wid 0.30 ht 0.055 rad 0.018 fill 0x4C97FF color 0x4C97FF at C.s + (-0.52, 0)
text "command" color white at C

# Reporter: oval / pill
R: ellipse "reporter" wid 2.2 ht 0.34 fill 0xFF8C1A color white with .n at C.s + (0, -0.30)

# Boolean: closed point-ended hexagon
P: line from R.s + (-1.1, -0.30) to R.s + (-0.93, -0.13) then to R.s + (0.93, -0.13) then to R.s + (1.1, -0.30) then to R.s + (0.93, -0.47) then to R.s + (-0.93, -0.47) close fill 0x59C059 color 0x59C059
text "boolean" color white at R.s + (0, -0.30)

Stack Shape Construction

Stack blocks use the same visual grammar as GP/MicroBlocks-style blocks: a top notch for the previous block's tab and a bottom tab for the next block's notch.

B: box wid W ht H rad 0.035 fill COLOR color COLOR
box wid 0.30 ht 0.055 rad 0.018 fill BACKGROUND color BACKGROUND at B.n + (-0.52, 0)
box wid 0.30 ht 0.055 rad 0.018 fill COLOR color COLOR at B.s + (-0.52, 0)
text "label" color white at B

The hat shape adds the MicroBlocks-style dome on top and keeps the same bottom tab:

H: box wid W ht H fill COLOR color COLOR
ellipse wid HAT_W ht DOME_H at H.nw + (HAT_W / 2, 0) fill COLOR color COLOR
box wid 0.30 ht 0.055 rad 0.018 fill COLOR color COLOR at H.s + (-0.52, 0)
text "label" color white at H

HAT_W and DOME_H control dome proportions. For a compact MicroBlocks-style block with ht 0.34, HAT_W = 1.6 and DOME_H = 0.34 create a broad shallow hat.

The boolean shape uses a closed filled line path to get its point-ended hexagon.


Examples

Blink

Graphical (Fossil Markdown)

The graph's leading comment renders as Markdown prose; the blocks render as an embedded Pikchr diagram. Inline comments appear to the right of the block they annotate.

blink: LED on/off loop after start

on start forever set user LED [on] wait [0.5] seconds set user LED [off] wait [0.5] seconds # hat: blocks on Channel
scale = 0.75
A: box wid 2.6 ht 0.34 fill 0xFFAB19 color 0xFFAB19
ellipse wid 1.6 ht 0.34 at A.nw + (0.8, 0) fill 0xFFAB19 color 0xFFAB19
box wid 0.30 ht 0.055 rad 0.018 fill 0xFFAB19 color 0xFFAB19 at A.s + (-0.62, 0)
text "on start" color white at A
B: box wid 2.72 ht 2.02 fill 0xFFAB19 color 0xFFAB19 with .nw at A.sw + (0, -0.05)
box wid 2.34 ht 1.35 fill 0xFFFDE7 color 0xFFFDE7 with .nw at B.nw + (0.32, -0.46)
box wid 0.30 ht 0.055 rad 0.018 fill 0xFFFDE7 color 0xFFFDE7 at B.n + (-0.62, 0)
box wid 0.30 ht 0.055 rad 0.018 fill 0xFFAB19 color 0xFFAB19 at B.s + (-0.62, 0)
text "forever" color white at B.n + (0, -0.19)
C: box wid 2.15 ht 0.34 rad 0.035 fill 0x4C97FF color 0x4C97FF with .nw at B.nw + (0.42, -0.56)
box wid 0.30 ht 0.055 rad 0.018 fill 0xFFFDE7 color 0xFFFDE7 at C.n + (-0.48, 0)
box wid 0.30 ht 0.055 rad 0.018 fill 0x4C97FF color 0x4C97FF at C.s + (-0.48, 0)
text "set user LED [on]" color white at C
D: box wid 2.15 ht 0.34 rad 0.035 fill 0xFFAB19 color 0xFFAB19 with .nw at C.sw + (0, -0.04)
box wid 0.30 ht 0.055 rad 0.018 fill 0xFFFDE7 color 0xFFFDE7 at D.n + (-0.48, 0)
box wid 0.30 ht 0.055 rad 0.018 fill 0xFFAB19 color 0xFFAB19 at D.s + (-0.48, 0)
text "wait [0.5] seconds" color white at D
E: box wid 2.15 ht 0.34 rad 0.035 fill 0x4C97FF color 0x4C97FF with .nw at D.sw + (0, -0.04)
box wid 0.30 ht 0.055 rad 0.018 fill 0xFFFDE7 color 0xFFFDE7 at E.n + (-0.48, 0)
box wid 0.30 ht 0.055 rad 0.018 fill 0x4C97FF color 0x4C97FF at E.s + (-0.48, 0)
text "set user LED [off]" color white at E
F: box wid 2.15 ht 0.34 rad 0.035 fill 0xFFAB19 color 0xFFAB19 with .nw at E.sw + (0, -0.04)
box wid 0.30 ht 0.055 rad 0.018 fill 0xFFFDE7 color 0xFFFDE7 at F.n + (-0.48, 0)
box wid 0.30 ht 0.055 rad 0.018 fill 0xFFAB19 color 0xFFAB19 at F.s + (-0.48, 0)
text "wait [0.5] seconds" color white at F
text "# hat: blocks on Channel" ljust italic color 0x777777 at A.e + (0.15, 0)

Definitions

Blink shows the Crystal-native and primitive boundaries together: forever is Crystal-native (a loop, rendered as a C-shape), set user LED is a primitive (a hardware shim), on start is a GPCR block in the standard palette, and wait is a primitive because it bottoms out on Crystal's sleep:

# primitive: a command block backed by the hardware op (mb.setUserLED)
@[GPCR::Primitive(shape: :command, label: "set user LED #{state}")]
def set_user_led(state : Bool)
end

# primitive: timed yield through Crystal sleep
@[GPCR::Primitive(shape: :command, label: "wait #{secs} seconds")]
def wait(secs : Float64)
  sleep secs.seconds
end

The annotation carries the visual metadata the macro-AST compiler reads — it references a shape (hat, command) and holds the palette label + category. There is no separate spec object; the definition is the block.

Label slots use Crystal's own string interpolation syntax: #{state}. Inside an annotation the string is stored unevaluated, so the macro receives the label already split into text parts and slot nodes — no marker parsing — and each slot name is checked against the def's parameters at compile time.

Source

Graph-level # comments before the def map to the text shown above the enclosing box. Inline # comments after a block call map to the text shown to the right of that block. @[GPCR::Graph] carries the canvas position; Crystal macros identify C-shape boundaries from do...end block arguments.

# blink: LED on/off loop after start
@[GPCR::Graph(x: 10, y: 30)]
def blink
  on_start  # hat: blocks on Channel until the start event fires
  forever do
    set_user_led true
    wait 0.5
    set_user_led false
    wait 0.5
  end
end

Conditional (if/else)

Graphical

if LED is [on] then set user LED [off] else set user LED [on]
scale = 0.75
A: box wid 2.72 ht 1.0 fill 0xFFAB19 color 0xFFAB19
box wid 2.34 ht 0.42 fill 0xffffff color 0xffffff with .nw at A.nw + (0.32, -0.44)
box wid 0.30 ht 0.055 rad 0.018 fill 0xffffff color 0xffffff at A.n + (-0.62, 0)
text "if LED is [on] then" color white at A.n + (0, -0.19)
B: box wid 2.15 ht 0.34 rad 0.035 fill 0x4C97FF color 0x4C97FF with .nw at A.nw + (0.42, -0.54)
box wid 0.30 ht 0.055 rad 0.018 fill 0xffffff color 0xffffff at B.n + (-0.48, 0)
box wid 0.30 ht 0.055 rad 0.018 fill 0x4C97FF color 0x4C97FF at B.s + (-0.48, 0)
text "set user LED [off]" color white at B
C: box wid 2.72 ht 1.0 fill 0xFFAB19 color 0xFFAB19 with .nw at A.sw + (0, -0.05)
box wid 2.34 ht 0.42 fill 0xffffff color 0xffffff with .nw at C.nw + (0.32, -0.44)
text "else" color white at C.n + (0, -0.19)
D: box wid 2.15 ht 0.34 rad 0.035 fill 0x4C97FF color 0x4C97FF with .nw at C.nw + (0.42, -0.54)
box wid 0.30 ht 0.055 rad 0.018 fill 0xffffff color 0xffffff at D.n + (-0.48, 0)
box wid 0.30 ht 0.055 rad 0.018 fill 0x4C97FF color 0x4C97FF at D.s + (-0.48, 0)
text "set user LED [on]" color white at D

Definitions

if/else is Crystal's native syntax — there is no block to define. The visual compiler renders the If node through its declared node-kind rendering (see Compiling to visual Markdown): the condition renders as a boolean slot, each branch as a C-shape body. The only defs this example needs are the blocks it calls (set_user_led, led_on?), declared as annotated defs exactly like Blink's.

Source

The source uses Crystal's native if/else syntax. A GPCR macro inspects this AST at compile time and derives the graphical structure: the if condition maps to the boolean slot; each branch maps to a C-shape body. The 1:1 mapping is derived, not redeclared.

@[GPCR::Graph(x: 80, y: 30)]
def toggle_led
  on_start
  if led_on?
    set_user_led false
  else
    set_user_led true
  end
end

Reporter block

Graphical

join [hello] and [world]
scale = 0.75
ellipse "join [hello] and [world]" wid 2.8 ht 0.42 fill 0xFF8C1A color white

Definition

# block, defined in GPCR: a reporter face over Crystal-native string `+`
@[GPCR::Block(shape: :reporter, label: "join #{a} and #{b}", category: "Text", returns: :string)]
def join(a : String, b : String) : String
  a + b
end

Comments

Two comment positions are defined:

Pikchr renders to static SVG and does not support hover or tooltips. Note that pikchr.org renders examples with click-to-view-source, which Fossil also supports — clicking a rendered Pikchr image shows its source, which is useful when reviewing graphical representations in docs.

Comment placement inside C-shapes (body slots) and multi-line comments are future improvements (see The line, below).


Control flow and Turing completeness

Control flow is Crystal's own — the GP-style control forms map to native Crystal syntax, rendered through node-kind renderings rather than defined as blocks (the two exceptions are noted in their rows):

Block Crystal form Rendering
forever #{body} loop do … end C-shape
repeat #{times} times #{body} times.times do … end C-shape
repeat until #{condition} #{body} until condition … end C-shape
if #{condition} then #{body} if C-shape
if … then … else … if/else double C-shape
wait #{secs} seconds sleep secs.seconds command (timed yield) — a primitive over Crystal sleep; decimals are native (0.5)
stop-graph return command (cap)
stop-all runtime halt command (cap) — a runtime primitive
set #{variable} to #{value} assignment command
change #{variable} by #{delta} += command
value of #{variable} variable read reporter

Which of these fall straight out of the macro AST and which need a wrapper is design goal 1's central exploration.


JSON edit protocol (MCP tools)

JSON is the edit transport, not the stored format. Each edit command is defined as an MCP tool — a named tool with a JSON-schema argument shape — so the edit protocol is the model-facing surface: a model (or a voice front-end) calls a tool, the server applies the mutation and re-emits the Crystal source. MCP is the canonical definition for now; an LSP editor projection (the same command set exposed as workspace/executeCommand + code actions, alongside hover/definition/diagnostics over the Crystal source) is deferred.

Defining the commands as MCP tools:

The derived block form

The macro walk derives this JSON from each annotated def — shape and palette data from the annotation, inputs from the parameter list. It is generated, never authored: MCP tools receive and emit it as arguments, and the only stored artifact remains the Crystal source.

{
  "shape":    "command",
  "label":    "set user LED #{state}",
  "category": "Output",
  "inputs": [
    { "name": "state", "type": "boolean", "default": false }
  ]
}
Field Type Derived from
shape string enum annotation shape: — "hat", "command", "reporter", "boolean"
label string annotation label: — #{name} slots align with the def's parameters
category string annotation category: (optional)
returns string annotation returns: (reporters/booleans)
inputs InputSpec[] the parameter list — name : Type = default

Blocks are referenced by their def name — there is no separate selector ID or per-runtime availability flag, because GPCR has exactly one runtime: the compiled Crystal program. If a def exists, its block exists.

InputSpec

{ "name": "state", "type": "boolean", "default": false }
Field Type Notes
name string The parameter name; matches #{name} in label
type string Derived from the Crystal type restriction (table below)
default any The parameter's default value, omitted if none
menuName string For type:"menu" — the enum type's name
menuOptions string[] For type:"menu" — the enum's members

Crystal source ↔ JSON ↔ graphical mapping

Crystal type → slot type

Crystal parameter type Slot widget
x : Bool "boolean" Toggle / hexagon slot
x : Float64 (or Int32) "number" Number box
x : String "string" Text box
do … end block body "script" C-shape mouth (nested block sequence)
untyped parameter "any" Accepts any slot type
x : Button (any enum) "menu" Dropdown — menu name is the enum's name, options are its members, default is a member (= Button::A)
x : GPCR::Color "color" Colour swatch

Graphical edit → MCP tool call → Crystal source update

One statement-op family: a def is just a statement kind that creates a block definition, so there is no separate def-level toolset. The same small op family — add, set, delete, move, wrap, rename — serves both block instances (statements in a graph body) and block definitions (def statements), over both address dialects ({index,gen} for voice, {path,expect} for model authors). add with kind :def creates a block definition; set on a def statement edits its slots — label, a parameter, a default, or the canvas position carried by @[GPCR::Graph(x:, y:)]. The op/kind validity table (src/gpcr/apply.cr) says which combinations are legal and produces kind-aware hints for the rest.

Graphical action Statement op Crystal source change
Drag block from palette add, kind :def New annotated def emitted
Delete block definition delete targeting the def def removed
Edit label / input slot / default set on the def's slots Annotation label: or parameter list updated
Move graph on canvas set on the def's position slot @[GPCR::Graph(x:..., y:...)] updated
Insert / remove / reorder a call add / delete / move in the body Statement list updated
Wrap statements in a control form wrap (kinds :call/:if/:while) Statements become the body of the new form
Rename a block everywhere rename (kind :def) Def + all call sites renamed

After applying any mutation the entire script is re-emitted as annotated Crystal source, re-parsed (post-apply parse gate), and re-formatted.


Authoring style

One style: annotated Crystal defs. The annotation carries only what Crystal syntax cannot — shape reference, label, palette category; everything else (name, inputs, defaults, the body) is the def. There is no separate DSL layer: the machine form is fully derivable from the def by the macro walk. When tools round-trip, re-emit, or apply a JSON edit, the output is annotated Crystal source.

Localised block labels are deferred: a label is one annotation string today, and a locale layer can replace it wholesale later without touching any def body.


The line: Crystal-native, primitives, blocks

Self-hosting (design goal 2) is enforced by an honest accounting of what actually sits below the line. Three layers:

Three restrictions keep the line honest:

One question stays open deliberately, resolved by building rather than deciding: whether Crystal's native if/loop/while AST nodes map 1:1 to shapes via macro inspection alone. The aim is to avoid wrapper macros entirely; if implementation hits a wall, that is the moment to decide, not before.

Future improvements

Plan of record is everything above; these are noted for later, none blocking:


Minimising the textual-only layer


Non-goals

Crystal-native code is allowed, but it enters only through primitives.