AI agent guide — building graphs via the companion endpoint¶
At a glance
- Read this if you are an AI agent asked to build or modify a MiniGraph. It is the single context you need — you should not need to read the engine source.
- Generate from rules, not guesses. The command grammar and its machine-readable form
minigraph-commands.jsonare the source of truth. Validate every command against them before sending.- Two endpoints, two jobs — see below. Both are dev-only (
app.env=dev), no auth.
Which endpoint?¶
| Goal | Endpoint | Notes |
|---|---|---|
| Execute a deployed graph | POST /api/graph/{graph-id} |
Send the request body; get the response. No session. |
| Build/edit a graph | POST /api/companion/{session-id}/sync |
Synchronous — returns the command outcome in-band {ok, output, error, result}; output is also teed to the human's WS console. |
| Read the live model | GET /api/graph/session/{session-id} |
Returns the current graph as JSON. |
This guide is about the companion flow — co-authoring a graph with a human watching the Playground.
The companion contract¶
An AI agent should use the synchronous /sync endpoint so it sees every outcome in-band and
can self-correct without a human relaying the console:
1. A human opens the Playground (ws://{host}/ws/graph); the first WebSocket frame carries the
session id (ws-<6 digits>-<counter>, e.g. ws-384729-17). Get this id from the human.
2. For each command: POST /api/companion/{session-id}/sync Content-Type: text/plain,
exactly ONE command in the body.
3. The HTTP response returns the outcome IN-BAND as JSON:
{ "ok": bool, "id": "ws-...", "command": "...", "output": ["...console lines..."],
"error": "...", "result": [ ... structured, e.g. a run's output.body ... ] }
- NULL FIELDS ARE OMITTED from the wire (serializer null-omission): a success carries no
"error" key and, unless the command yields data, no "result" key. Treat ABSENT as null —
do not require the keys.
- The "ok" flag is derived from the console lines with whole-output context (import's
normal "Graph model not found in /tmp/... Found deployed graph model" fallback is
correctly reported ok:true). When ok is false, the error field carries the first
failing line — still read the output for the full picture.
- A malformed command answered with a "Syntax: ..." usage hint is a FAILURE: ok:false
with the hint as the error (the command did nothing).
- Repeating an identical command back-to-back is safe: /sync commands are never
dedup-dropped (the engine's 1-second duplicate guard protects only the WS UI path).
- If ok is false, read error/output, fix it, and re-issue — self-correct; no human relay needed.
- Use result to verify a run/inspect (e.g. output.body).
4. The same output is ALSO teed to the human's WebSocket console, so a watcher — and any
`session subscribe`d session (e.g. a product owner) — sees it live: real-time human+AI collaboration.
5. Read the model shape any time with GET /api/graph/session/{session-id}.
Status codes: 200 executed (read ok/error in the body); 400 missing/empty/non-text body;
404 no active session for that id.
Retired: the fire-and-forget
POST /api/companion/{session-id}(no/sync) was removed in 2026-09 — it returned only{status:"accepted"}, leaving the caller blind to errors and forcing sleep-padded drivers. The bare URL answers 404. There is exactly one companion endpoint:/sync.Never use
graph.js. It is deprecated (kept for backward compatibility only): runtime script evaluation fails enterprise security review, and a silent expression defect has been reported in the field. Express decisions withgraph.mathand everything richer withgraph.task— see the skills reference.
Rules of engagement: one command per POST (multi-line commands are fine — see the grammar);
the session must already be open (over the companion endpoint you do not create it — but see
Hosting the session yourself); take turns — co-editing with humans is the design
intent, just don't POST in the same instant a human is mid-keystroke; never expose this beyond a
trusted dev host.
Session topology is off-limits over HTTP: a companion is an assistant to the session in the
URL, not a WebSocket session of its own — the companion endpoint rejects session subscribe /
session unsubscribe / session reset (the read-only session status query is allowed).
Subscriptions are managed from WebSocket-connected sessions only.
Session sync is symmetric: when sessions are joined by session subscribe, every command
except the session topology commands propagates to the primary and all subscribers alike —
AI and humans are equal co-authors of one shared model, and anyone's command (typos included) is
seen by everyone. It is collaboration, not a one-way broadcast.
Hosting the session yourself¶
The flow above borrows a human's browser session. The stronger topology is the inverse: the agent hosts the session and humans subscribe to it. If a human's tab drops (backgrounded past the idle timeout, laptop lid closed), they simply re-subscribe and the current work-in-progress graph syncs back to them — nothing lives in anyone's browser.
The WebSocket contract a host needs (identical in the Java and Rust engines):
- Connect to
ws://{host}/ws/graph/playground. - On open, send
{"type":"welcome"}. - The server announces the id as a plain-text frame:
session ws-NNNNNN-N started. - Keep-alive: send
{"type":"ping","message":"keep alive","time":"..."}on an interval (the web UI uses 20 s); the server answers{"type":"pong"}. Filter ping/pong frames from any console you render. - A restart of the app destroys the session (and any unexported graph — export first); reconnect and parse the new id.
You do not need to implement this: the template ships a zero-dependency reference,
scripts/playground-session-broker.mjs (Node ≥ 22). It holds the session, keeps it alive,
auto-reconnects across app restarts, and exposes a localhost control API (GET /session,
GET /console, POST /start, POST /stop) so the agent reads the session id over HTTP, hands
it to the humans (session subscribe {id} in their browsers), and keeps driving commands
through /sync as usual. See scripts/README.md in the template.
Generate deterministically¶
- Use the grammar as source of truth —
command-reference.mdfor the rules,minigraph-commands.jsonto look up a command's exact syntax, params, and allowed values. Do not infer syntax from a single example. - Validate before sending — check each command against this list (the engine's invariants):
Pre-send checklist - [ ] The root node is named
root; the end node is namedend. - [ ] The root node's properties includename={graph-id}andcreated={current timestamp}— export authorization and content-uniqueness (see the recipe's best practice). - [ ] Node names are lowercase letters, digits and hyphen (types: descriptive labels, conventionally Capitalized). - [ ] Each node has 0 or 1 skill (skill={route}); the skill's required properties are present (see the skill→property matrix). - [ ] Every node in the traversal path connects to ≥1 node (orexportfails). - [ ] No node is left unconnected. Config nodes (Dictionary/Provider) are referenced by name (dictionary[]=,provider=) and not traversed — wire them under agraph.island(root -[contains]-> island -[data]-> dictionary -[provider]-> provider): the island is the graph's entity-relationship knowledge layer (required convention). - [ ] Multi-line commands (create/update/instantiate) are sent as one block; multi-line values use'''…'''. - [ ]instantiate graphprecedesrun/execute/inspect. - [ ]{…}in a syntax line is a placeholder — substitute the value and do not type the braces (inspect output.body, notinspect {output.body};execute fetcher, notexecute {node}). - [ ] Exactly one command per POST.
Canonical build recipe¶
A reliable order for building a graph:
- Plan the nodes and the connections (root → … → end) before issuing commands. Composing
by delegation? Discover the valid targets first:
list graphs(deployed graph models, with each root's purpose) andlist flows(Event Script flows), thendescribe graph {graph-id}for the chosen model's contract view (itsinput.*/output.*data surface) — read-only commands, so no out-of-band brief and no trial execution are needed forextension=targets. - Create nodes:
create node root(typeRoot), the active/skill nodes, andcreate node end(typeEnd, usually withgraph.data.mapperto shapeoutput.body). A mapper can also set the HTTP response status: map an int tooutput.status(e.g.int(400) -> output.statusin a refusal node) — a graph is not limited to200+ an error flag in the body. A non-2xx status routes through the exception path; see workflow-suspension.md for that pattern. Best practice — give the root node these two properties at creation time:name={graph-id} # the id you will export/deploy as created={ISO timestamp} # e.g. 2026-09-03T18:55:00Z — the current timename=is what authorizesexport graph as {graph-id}to overwrite an existing file — without it the export is refused (or, in older engines, silently skipped);created=makes every build's model content-unique, so a re-export is always recognized as a new graph and always saved. Set both up front and export friction disappears. - Connect them so traversal flows root → end, with no orphans.
- Wire the knowledge layer: whenever the graph has
Dictionary/Provideror data-entity nodes, anIsland(skill=graph.island) is required — connectroot -[contains]-> island -[data]-> dictionary -[provider]-> provider; no node is left unconnected. For a graph with none, an island with data-entity nodes documenting the domain is encouraged (convention). - Instantiate with mock input:
instantiate graph+{constant} -> input.body.{key}lines. - Run and inspect:
run(orexecute {node}), theninspect output.body; iterate. ({node}is a placeholder — you write e.g.execute fetcher,inspect output.body.) - Export & deploy: export is file-based — it writes
/tmp/graph/{name}.json. Delete any stale file of that name first (rm -f /tmp/graph/{name}.json): an export onto an existing file can reportok: truewhile writing nothing (overwriting requiresname={name}on the root node, and exporting as an already-deployed graph id never writes). Thenexport graph as {name}and verify the file now exists with fresh content — never trust the success line alone. (With the root-nodename=/created=best practice from step 2 the export overwrites cleanly; the delete-and-verify here is defense in depth.) Deploy the JSON into your project'sresources/graph/, list the id ingraphs.yaml, rebuild, restart, then callPOST /api/graph/{name}. - Dry-running the deployed model in a fresh session: a Playground session opened after the
restart starts empty — no root node — even though the graph is deployed. Run
import graph from {name}first (it falls back to the deployed classpath model,ok:true), theninstantiate graphandrunas usual.
Worked example¶
Building the hello-world graph via the synchronous /sync endpoint, one command per request.
Each call returns {ok, id, command, output} plus error/result when non-null — check ok and
self-correct on failure:
SID="ws-384729-17" # from the WebSocket welcome frame
curl -sS -X POST "http://{host}/api/companion/${SID}/sync" -H 'Content-Type: text/plain' \
--data-binary $'create node root\nwith type Root\nwith properties\npurpose=demo'
# → {"ok":true,"id":"ws-384729-17","command":"create node root...","output":["> create node root...","node root created"]}
# (no "error"/"result" keys on success — null fields are omitted)
curl -sS -X POST "http://{host}/api/companion/${SID}/sync" -H 'Content-Type: text/plain' \
--data-binary $'create node end\nwith type End\nwith properties\nskill=graph.data.mapper\nmapping[]=text(hello world) -> output.body'
curl -sS -X POST "http://{host}/api/companion/${SID}/sync" -H 'Content-Type: text/plain' \
--data-binary 'connect root to end with done'
curl -sS -X POST "http://{host}/api/companion/${SID}/sync" -H 'Content-Type: text/plain' \
--data-binary 'instantiate graph'
curl -sS -X POST "http://{host}/api/companion/${SID}/sync" -H 'Content-Type: text/plain' \
--data-binary 'run'
# → {"ok":true,...,"result":[{"output":{"body":"hello world"}}]} # the run outcome, in-band
Because each response carries ok/error/result, an agent verifies and corrects itself — no
need to relay the WebSocket console. The same lines are still teed to the human's console, so a
watcher (and any session subscribed session) follows along live.
Scaffolding a project from the template¶
Start every knowledge-graph project from examples/minigraph-playground and trim — against
this manifest, not against your build passing (mvn test and curl both stay green with
Playground UI routes missing; only the browser notices).
Boilerplate manifest — what a derived project keeps:
| File | Role | Trim? |
|---|---|---|
pom.xml |
Build; set your own artifact/group ids | keep (edit ids) |
application.properties (main + test) |
App name, rest.server.port (use a distinct test port), rest.automation=true, app.env=dev for the Playground |
keep (edit values) |
rest.yaml |
REST routes | keep — trim by profile, below |
flows.yaml + flows/graph-executor.yml |
Binds POST /api/graph/{graph_id} to the graph executor |
keep |
flows/flow-11.yml and other example flows |
Support-triage demo flows | drop unless used |
graphs.yaml + graph/*.json |
Your deployed graph models — list every id you serve | replace with yours |
Main class annotated @MainApplication |
App entry point | keep (rename) |
rest.yaml — two named profiles. The template's route list mixes three kinds of routes;
know which bar you are building to:
- Example-specific (always safe to drop):
llm.stream.relay,mock.mdm.profile,mock.account.details— they belong to the support-triage demo, not the platform. - Profile
headless-minimal— enough for CI,curl, and an agent-driven dry-run over/sync; the Playground UI will not work. - Profile
playground-enabled— headless-minimal plus the UI plumbing every template ships. If a human will ever open the Playground against your app — and in the hosting topology they will — build to this profile.
Copy-paste boilerplate — the full playground-enabled route set; deleting the routes marked
[playground-enabled] leaves headless-minimal. Keep the template's cors_1 and header_1
blocks verbatim (every entry references them):
rest:
# ── headless-minimal ──────────────────────────────────────────────────────
# Execute a deployed graph: POST /api/graph/{graph-id}
- service: 'http.flow.adapter'
methods: ['POST', 'GET']
url: '/api/graph/{graph_id}'
flow: 'graph-executor'
timeout: 30s
cors: cors_1
headers: header_1
tracing: true
# THE companion endpoint — an AI agent's build/edit channel (outcome in-band)
- service: 'post.companion.command.sync'
methods: ['POST']
url: '/api/companion/{id}/sync'
timeout: 30s
cors: cors_1
headers: header_1
tracing: true
# Read the live session's graph model as JSON
- service: 'get.live.graph'
methods: ['GET']
url: '/api/graph/session/{id}'
timeout: 30s
cors: cors_1
headers: header_1
tracing: true
# ── [playground-enabled] UI plumbing — required when a human opens the UI ─
# Serves the Playground web app
- service: 'get.index.html'
methods: ['GET']
url: '/index.html'
timeout: 10s
cors: cors_1
headers: header_1
- service: 'get.ws.html'
methods: ['GET']
url: '/api/ws/{id}'
timeout: 10s
cors: cors_1
headers: header_1
tracing: true
# The Graph tab's model fetch after `describe graph` / `export graph`
- service: 'show.graph.model'
methods: ['GET']
url: '/api/graph/model/{graph_id}/{sequence}'
timeout: 30s
cors: cors_1
headers: header_1
tracing: true
# Backs the console `upload` command: opens a UI dialog where the human
# operator pastes a JSON string (the JSON-Path payload editor handshake)
- service: 'upload.json.content'
methods: ['POST']
url: '/api/json/content/{id}'
timeout: 30s
cors: cors_1
headers: header_1
tracing: true
# Backs the console `upload mock data` command (mock-input upload dialog)
- service: 'upload.mock.content'
methods: ['POST']
url: '/api/mock/{id}'
timeout: 30s
cors: cors_1
headers: header_1
tracing: true
# The UI's state-machine inspector
- service: 'inspect.state.machine'
methods: ['GET']
url: '/api/inspect/{id}/{key}'
timeout: 30s
cors: cors_1
headers: header_1
tracing: true
When in doubt, diff your trimmed rest.yaml against the template's.
See also¶
- MiniGraph command grammar +
minigraph-commands.json— the source of truth. - Built-in skills reference — per-skill properties and examples.