A minimal working example of a control plane: a daemon watches UI actions, routes them through a rules engine, and pushes back components to render.
See SPEC.md for the full protocol specification.
make build # build all Docker images
make all # start the registry
make stack # interactively choose a daemon + rendererThen open http://localhost:3000 (React renderer) or http://localhost:8081 (HTML renderer).
Once the stack is running, post a FORM component:
make formA form appears in the renderer. Submit it (click "Send Message") and watch:
- The action travels from renderer → daemon → registry
- The registry rules engine evaluates the action against registered rules
- The
form-submitrule fires and generates a CARD component with the submitted data - The new CARD appears in the renderer — no page refresh needed
This is the core teaching pattern: renderer sends an action, the rules engine decides what to render next.
┌─────────────┐ subscription ┌─────────────┐ subscription ┌─────────────┐
│ Registry │ ──────────────► │ Daemon │ ──────────────► │ Renderer │
│ :4000 │ │ :3001 │ │ :3000/8081 │
│ │ ◄────────────── │ │ ◄────────────── │ │
│ Rules + │ handleMessage │ Middleware │ sendMessage │ UI │
│ State │ mutation │ + State │ mutation │ │
└─────────────┘ └─────────────┘ └─────────────┘
All connections use the graphql-transport-ws subprotocol over WebSocket.
| Service | Role |
|---|---|
Registry (registry/simple-registry.js) |
Stores component state, runs the rules engine, publishes componentUpdate subscriptions, exposes REST POST /render |
Rust Daemon (daemon/rust/component-daemon/src/main.rs) |
High-performance middleware; subscribes to registry, broadcasts to renderers, forwards actions back to registry |
Node Daemon (daemon/simple-daemon.js) |
Same role as Rust daemon — an alternative implementation to show the pattern isn't language-specific |
React Renderer (renderer/frontend) |
Subscribes to daemon, renders CARD / FORM / NOTIFICATION components, sends user actions back via GraphQL mutation |
HTML Renderer (renderer/html) |
Lightweight alternative renderer; plain HTML + JS, no build step |
Client (curl) Registry Daemon Renderer
| POST /render --> | | |
| | store component | |
| | publish sub event | |
| | ── subscription ──►| |
| | | cache & forward |
| | | ── subscription ──►| render
- A component is created via
POST /render, a GraphQL mutation, or a rule firing. - Registry publishes a
componentUpdatesubscription event. - Daemon's persistent subscription receives the event and caches the component state.
- Daemon broadcasts the component over its own subscription to all connected renderers.
- Renderer updates the UI.
Renderer Daemon Registry Daemon (sub) Renderer
| sendMessage ──►| cache action | | |
| | open mutation WS ─────►| | |
| | connection_init | | |
| | ◄─ connection_ack ─────| | |
| | handleMessage ────────►| run rules | |
| | | publish ──────────►sub event ─────►| render
- User interacts with a component (submit form, click button).
- Renderer sends an ACTION envelope to the daemon via
sendMessagemutation. - Daemon caches the action and opens a short-lived WebSocket to call
handleMessageon the registry.- A separate WS per mutation isolates failures and ensures
graphql-transport-wshandshake.
- A separate WS per mutation isolates failures and ensures
- Registry loads component state, evaluates all registered rules.
- Matching rules generate new component(s); registry publishes them — Component Flow resumes.
Action envelope shape:
{
"direction": "ACTION",
"payload": {
"id": "action-<ts>",
"componentId": "<component-id>",
"actionType": "SUBMIT",
"data": { "name": "Alice", "email": "alice@example.com" },
"timestamp": "2025-08-16T12:34:56.000Z"
},
"metadata": { "acknowledged": false, "correlationId": "...", "error": null }
}Rules live in ComponentRegistry as a Map<string, Rule> where each rule is:
{
condition: (state, action) => boolean, // should this rule fire?
generate: (state, action) => ComponentSpec // what component to create?
}Default rules:
| Rule | Condition | Generates |
|---|---|---|
card-click |
CARD component + CLICK action | NOTIFICATION |
form-submit |
FORM component + SUBMIT action | CARD with submitted data |
Run the unit tests to see them in isolation:
make test
# or: cd component-system/registry && npm test# Step 1: post a FORM component
make form
# Step 2: get the FORM component id
curl -s -X POST http://localhost:4000/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"query { components { id type } }"}' \
| jq -r '.data.components[] | select(.type=="FORM") | .id'
# Step 3: send a SUBMIT action (replace <id> with the value above)
make action FORM_ID=<id>Or use the make action shortcut which builds the full envelope automatically.
# Terminal 1 — registry rule evaluation
make rules-logs
# Terminal 2 — all services
make logsExpected log sequence for a SUBMIT action:
- Daemon:
Received ACTION message - Daemon:
Mutation WS connected for handleMessage - Registry:
Handling message→Processing action - Registry:
Evaluating rules→Rule 'form-submit' triggered - Registry:
Publishing new component <new-id> - Daemon:
Received component from registry: <new-id> - Renderer: new CARD appears
# CARD with clickable buttons
curl -X POST http://localhost:4000/render \
-H "Content-Type: application/json" \
-d '{"type":"CARD","data":{"title":"Hello","content":"A card component","buttons":[{"text":"Click me"}]}}'
# NOTIFICATION
curl -X POST http://localhost:4000/render \
-H "Content-Type: application/json" \
-d '{"type":"NOTIFICATION","data":{"status":"SUCCESS","title":"It works","message":"Component flow is operational"}}'{
"type": "CARD",
"data": {
"title": "Card Title",
"content": "Description text",
"buttons": [{ "text": "Button Label", "action": "ACTION_NAME" }]
}
}{
"type": "NOTIFICATION",
"data": {
"status": "SUCCESS | ERROR | WARNING | INFO",
"title": "Notification Title",
"message": "Notification message"
}
}{
"type": "FORM",
"data": {
"title": "Form Title",
"fields": [
{ "name": "fieldName", "label": "Field Label", "type": "text | email | password" }
],
"submitText": "Submit"
}
}| Variable | Default | Description |
|---|---|---|
REGISTRY_URL |
ws://registry:4000/graphql |
Registry WebSocket endpoint (daemon config) |
COMPONENT_TTL_MS |
600000 (10 min) |
How long the registry keeps a component in memory |
LOG_JSON |
unset | Set to 1 for structured JSON logs from the Node daemon |
PORT |
3001 |
Daemon HTTP/WS port |
Both daemons bind to port 3001 inside Docker. The docker-compose.yml maps the Rust daemon to host port 3001 and the Node daemon to host port 3002, so you can run both simultaneously — they just serve from different host ports.
| Symptom | Likely Cause | Fix |
|---|---|---|
| WS close code 1006 immediately | Protocol mismatch | Confirm client uses graphql-transport-ws subprotocol |
No connection_ack from registry |
Registry not running or network issue | make logs-registry; check port 4000 is reachable |
| Daemon not receiving components | Subscription disconnected | Check daemon logs for reconnect loop; WS 1006 hints at subprotocol mismatch |
No rules triggered in registry logs |
Rule condition not matched | Confirm actionType string and component.type match rule expectations (case-sensitive) |
| Registry shows action but daemon silent | Mutation WS failed | Check daemon logs for Mutation WS lines; ensure connection_ack was received before mutation |
| Renderer shows no components | Not connected to daemon | Open browser console; look for WebSocket errors on port 3001 |
| Duplicate components in renderer | Rapid re-fires or repeated form posts | Add client-side deduplication by component id |