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.
Use the shipped session broker — do not hand-roll a WebSocket client. The session contract
carries a keep-alive obligation, and a hand-rolled client that misses it appears to work, then
dies silently at the server's idle timeout — typically mid-collaboration. The zero-dependency
reference broker, scripts/playground-session-broker.mjs (Node ≥ 22), ships in the
starter-graph template and in the minigraph-playground example. It holds the session, keeps
it alive with the web UI's own ping cadence, 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 — the broker owns the session's
lifecycle, never its commands. See scripts/README.md next to the script, and pass --target
for your app's port (e.g. --target http://127.0.0.1:8303 for the starter template's default).
For reference, the WebSocket contract the broker implements (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. Skipping this step is the classic hand-rolled-client failure — the session dies at the idle timeout. - A restart of the app destroys the session (and any unexported graph — export first);
reconnect and parse the new id. The shipped broker does this for you: after a restart,
GET /sessionreturns the newsessionIdwith the old one underpreviousSessionIds. Re-import the exported graph into the new session (import graph from {name}) before handing the id to the humans, so the model syncs back to them onsession subscribe.
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). - [ ] Static reference data is a node, not code. A decision table (a rule by state, a rate by band) is a skill-less node; agraph.data.mapperdecision node resolves it withf:lookup({table-node}, {value}, text({default}))(the common case), or agraph.taskhands the whole table to a generic function (input[]={table-node} -> table). Never hard-code it as a ladder of IF-THEN-ELSE ingraph.mathor inside a composable function: the table is more readable, and the product owner certifies it on the graph (static decision table). - [ ] 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. A static decision table is such a data node — a skill-less node holding the table that agraph.taskmaps whole into a generic function — and is wired under the island too (pattern). 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}— that is the production path; for a prototype or a demo, deploy from an external manifest without a rebuild. - 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.
Rapid prototyping — deploy without a rebuild¶
The production path keeps the manifest and the models inside the artifact (classpath:/graphs.yaml,
classpath:/graph), so a deployment is a build. For a prototype or a live demo, the CompileGraph
gate also accepts a manifest and a model folder outside the jar: the manifest is an ordinary
property (graph.model.automation), so a JVM system property overrides it for one run, and the
manifest's location may be a file:/ folder. Since 4.12.19 the property accepts a comma-separated
list of manifests, each carrying its own location, and when two manifests list the same graph id the
later manifest wins — so the bundled graphs stay deployed beside the prototype. Nothing in the
project changes — the same gate compiles the same JSON.
- Export as usual —
export graph as {name}writes/tmp/graph/{name}.json(the temp location). - Stage a deploy folder with its own manifest — the copy is the visible promotion step:
- Restart the app with the manifest override — the only change is on the command line. Name
the bundled manifest first and the deploy folder's second: the bundled graphs stay executable
(a prototype can delegate to them through
graph.extension, and they to it), and each manifest keeps its ownlocation: (Before 4.12.19 the property took one manifest, so the override replaced the bundled set.) - Verify the gate in the startup log, then in the Playground and over REST:
Loading graph manifest file:/tmp/graph/deploy/graphs.yaml Deployed graph model folder - file:/tmp/graph/deploy Compiled graph {name} Graph models compiled: {bundled + 1}list graphsnow shows{name} - {root purpose}, andPOST /api/graph/{name}answers.
Two rules to read before relying on it:
- Later manifest wins. Only the ids the listed manifests name compile. When two manifests list
the same graph id, the later manifest owns it: its copy replaces the earlier one, and if that copy
is rejected by the gate the id answers 404 rather than silently serving the copy you meant to
replace. The startup log says so —
Graph {name} from file:/tmp/graph/deploy replaces the copy from classpath:/graph, thenCompiled graph {name}orRejected graph {name} - {reason}. A manifest that cannot be loaded is skipped with a warning, so a typo in the external path never takes the bundled graphs down. - A deployment is still a restart.
CompileGraphruns once at startup (@BeforeApplication); there is no runtime reload. Export before the restart, and follow the broker choreography in Hosting the session yourself: the broker reconnects and reports a new session id;import graph from {name}into it, then re-invite the humans.
The same two manifests are how to iterate on a graph that is already deployed without a rebuild:
import graph from {name} (it falls back to the deployed copy), make the corrections, dry-run the
cases, export graph as {name}, stage the export in the deploy folder and list the id in its
manifest, restart with both manifests, and test the deployed behaviour with curl — the external
copy has replaced the bundled one — before bundling the updated JSON into the application.
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 from templates/starter-graph. It is the copy-out starter for Layer 3 and it already
ships the whole playground-enabled surface below — the dev-mode endpoints, app.env=dev, the
standard graph-executor flow, and the session broker script — so a fresh project can be
co-authored with an AI agent from the first run. Copy the directory, rename the ids, replace the
graph model, and you are done; the manifest and route list here are then a checklist for what
you must not delete, not a trimming exercise.
If you instead derive from a fuller app (examples/minigraph-playground, which additionally
demonstrates LLM streaming and Event-over-HTTP), 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) |
One endpoint serves every graph.
POST /api/graph/{graph_id}takes the graph id from the URL path, so a Layer 3 application needs exactly one REST entry no matter how many graphs it deploys. Do not add a per-graph endpoint — list the new id ingraphs.yamland it is live.Declare the graph engine and nothing else.
minigraph-playground-enginebringsevent-script-engineandplatform-coretransitively, so one dependency covers all three layers. Until 4.12.14, listing them individually could also hide the Playground: its page was the engine jar's staticclasspath:/public/index.html,platform-corecarries a placeholder welcome page at the same path, and the first jar on the classpath won —mvn test,curland the companion endpoint all still passed, so only a browser revealed it. Since 4.12.15 the Playground page istemplate/playground.html, served byget.index.htmlonly whenapp.env=dev; both jars' staticindex.htmlare plain pages, so a production deployment never shows the Playground UI — and theget.index.htmlroute below is what makes the UI appear in dev mode.
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 when app.env=dev, a plain service page otherwise
- 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 rest.yaml against templates/starter-graph/src/main/resources/rest.yaml.
See also¶
- MiniGraph command grammar +
minigraph-commands.json— the source of truth. - Built-in skills reference — per-skill properties and examples.