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:
- Find the Crystal/GPCR boundary — explore how much standard Crystal syntax and annotations suffice before needing DSL restrictions or a sub-language
- 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)
- 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) |
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→ /pikchrshow
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 |
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→ /pikchrshow
"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 |
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)→ /pikchrshow
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
- Box:
color COLORmatchesfillso the border is invisible. No inline label (it would inheritcolorand become invisible against the fill). - Ellipse: centered on the top edge. The upper half protrudes above the block as the hat dome; the lower half overlaps the box body but is the same colour so the seam is invisible.
color COLORhides the ellipse border. Pikchrarcobjects produce open SVG paths withfill:noneregardless of thefillattribute — a closedellipsegives a filled dome. - Notch/tab: small rounded boxes create the stack connector shape using only closed Pikchr primitives.
- Text: overlaid separately with
color white.
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
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)→ /pikchrshow
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
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→ /pikchrshow
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
scale = 0.75 ellipse "join [hello] and [world]" wid 2.8 ht 0.42 fill 0xFF8C1A color white→ /pikchrshow
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:
- Graph-level comment — a
# commenton the line before@[GPCR::Graph]appears as italic text above the enclosing light box for the graph (see blink graphical example above) - Inline comment — a
# commentat the end of a block call line appears as italic text to the right of that block in the graphical view
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:
- Language models call tools reliably, and the JSON-schema arguments bound (grammar-constrained) sampling to valid edits — the shallow, constrained schema is what makes weaker models viable
- Voice and graphical editors emit the same tool calls — the closed spoken grammar (wiki: GP-Crystal Design Overview, with spoken forms in the Primitive Catalog) compiles utterances into this same op family
- Partial or slightly-wrong arguments can be interpreted flexibly
- The tool set is small and stable, so it stays 1:1 with the block model
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:
- Crystal-native — plain Crystal syntax rendered via node-kind renderings:
if/else,loop,.times,until, operators (arithmetic, comparison, boolean, string+, indexing), assignment,def/call,return. Zero cost: it is the language, and it needs no other name. - Primitives — the places Crystal-native code enters GPCR. Runtime primitives are
defs whose bodies touch the event loop, an IO, or hardware:sleep; event-busreceive-event/send-event;write-stream(the gpcr output stream); monotonic clock read;rand; hardware shims per node profile (LED, buttons, pins); file open/read/write;parse-source(source text → node data viaCrystal::Parser). Compile-time primitives are macros: the reflection walk that derives block faces from annotations and reifies def bodies into node data. A primitive's face renders fully graphically at call sites; its body renders as a code fence. The GP-Crystal Primitive Catalog is the complete inventory; growing it is a design event, not an implementation detail. - Blocks — everything else, defined in GPCR:
@[GPCR::Block]defs whose bodies use only renderable elements — calls to primitives and other blocks, plus Crystal-native forms. The compiler enforces that contract. The standard library palette lives in the GP-Crystal Block Catalog; blocks render graphically, are edited by the same statement ops and spoken grammar as user code, and the Crystal compiler inlines them away.
Three restrictions keep the line honest:
- No arbitrary
requireinside a script — therequireblock is a mediated primitive - No direct
Fiber.neworspawn— the runtime manages fibers per graph @[GPCR::Graph]on every independently-runnable method — every graph is a named def
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:
- Comment placement inside C-shapes — the likely rule is that a comment attaches to the statement it precedes and renders at that statement's position; adopt it when the renderer meets a real example.
- Localised block labels — a label is one annotation string; a locale layer can replace labels wholesale without touching def bodies.
- Anonymous graphs — today every graph is a named def; a form for unnamed graphs can be added if a use appears.
- Small-target runtime subset — which runtime features (GC, stdlib parts) a microcontroller build omits gets decided when a real board build forces the choices.
- Where Crystal's static types rub GP's dynamic value model — watched during library implementation; the compile-time typing rules in the Block Catalog are the working answer.
Minimising the textual-only layer
JSON::Serializabledrives the JSON edit protocol — no hand-written serialisation@[GPCR::Graph(x:, y:)]is the only layout annotation needed; individual block positions within a graph are implicit from stack order- Crystal macro AST inspection identifies C-shape body slots from
do...endblock arguments — no separate parser @[GPCR::Primitive(...)]/@[GPCR::Block(...)]are the only authored layer between adefand the block model — the machine form is macro-derived, never written by hand
Non-goals
- Translate arbitrary Crystal syntax into graphical blocks
- Make every Crystal API visually representable
- Replace the GP or MicroBlocks editors
- Pretend Crystal and GP have identical type systems
- Run on a foreign VM (the MicroBlocks board VM) — boards are reached by native compilation with a possibly reduced runtime (see Execution model); GPCR borrows GP/MicroBlocks vocabulary, not their deployment model
Crystal-native code is allowed, but it enters only through primitives.