Skip to content

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.json are 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 with graph.math and everything richer with graph.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):

  1. Connect to ws://{host}/ws/graph/playground.
  2. On open, send {"type":"welcome"}.
  3. The server announces the id as a plain-text frame: session ws-NNNNNN-N started.
  4. 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.
  5. 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

  1. Use the grammar as source of truthcommand-reference.md for the rules, minigraph-commands.json to look up a command's exact syntax, params, and allowed values. Do not infer syntax from a single example.
  2. 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 named end. - [ ] The root node's properties include name={graph-id} and created={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 (or export fails). - [ ] No node is left unconnected. Config nodes (Dictionary/Provider) are referenced by name (dictionary[]=, provider=) and not traversed — wire them under a graph.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 graph precedes run/execute/inspect. - [ ] {…} in a syntax line is a placeholder — substitute the value and do not type the braces (inspect output.body, not inspect {output.body}; execute fetcher, not execute {node}). - [ ] Exactly one command per POST.

Canonical build recipe

A reliable order for building a graph:

  1. 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) and list flows (Event Script flows), then describe graph {graph-id} for the chosen model's contract view (its input.* / output.* data surface) — read-only commands, so no out-of-band brief and no trial execution are needed for extension= targets.
  2. Create nodes: create node root (type Root), the active/skill nodes, and create node end (type End, usually with graph.data.mapper to shape output.body). A mapper can also set the HTTP response status: map an int to output.status (e.g. int(400) -> output.status in a refusal node) — a graph is not limited to 200 + 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 time
    
    name= is what authorizes export 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.
  3. Connect them so traversal flows root → end, with no orphans.
  4. Wire the knowledge layer: whenever the graph has Dictionary/Provider or data-entity nodes, an Island (skill=graph.island) is required — connect root -[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).
  5. Instantiate with mock input: instantiate graph + {constant} -> input.body.{key} lines.
  6. Run and inspect: run (or execute {node}), then inspect output.body; iterate. ({node} is a placeholder — you write e.g. execute fetcher, inspect output.body.)
  7. 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 report ok: true while writing nothing (overwriting requires name={name} on the root node, and exporting as an already-deployed graph id never writes). Then export graph as {name} and verify the file now exists with fresh content — never trust the success line alone. (With the root-node name=/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's resources/graph/, list the id in graphs.yaml, rebuild, restart, then call POST /api/graph/{name}.
  8. 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), then instantiate graph and run as 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