This file is the entry point for AI agents (Claude Code, similar) working
on the rocketchain repo itself. If you are inside a generated Spring
Boot project, read the AGENTS.md at that project's root instead.
Audience: an agent that needs to TRANSLATE a natural-language flow description into a
.rocketflowJSON file — and then verify it compiles to Java. The repo is the visual editor + codegen for the agentic-helper Java runtime; flows authored here become Spring Boot apps.
A .rocketflow is a JSON DAG of typed nodes connected by exec edges
(orange, control flow) and data edges (blue, value flow). The codegen
turns it into a @Service plus a trigger wrapper (Controller / scheduled
Routine / EventListener). Authoring by hand is fully supported — this
guide explains how.
natural language goal
↓ identify trigger + decompose into steps (§1)
↓ map each step to a node type (§2)
↓ write agent / tool JSON if needed (§3)
↓ wire exec + data edges (§4)
↓ smoke test with the CLI (§5)
.rocketflow → mvn compile → runnable Spring Boot app
Ask: what makes the flow run? The answer maps to a startNode.data.triggerType:
| Trigger | When to use | Generates |
|---|---|---|
controller |
HTTP endpoint (REST API call) | @RestController with POST/GET/... |
routine |
Scheduled task (cron / fixedRate) | @Scheduled(...) bean |
event |
Reaction to a Spring ApplicationEvent |
@EventListener |
If the user says "every hour", "on startup", "when a webhook fires", that's the trigger.
Walk through the user's description and list discrete actions: "fetch X", "if Y is empty then Z", "for each item ask the LLM to ...". Each becomes ONE node. Avoid macro-nodes — small composable nodes are easier to debug and to wire.
Use the catalog in §2. The match is usually obvious: "ask an agent" →
agentNode, "loop over a list" → forEachNode, "store in DB" →
variableNode + a custom helper @Service (called via Variable's
"call" mode — see registry).
- An agent is a JSON file under
prompts/agents/<name>.jsondeclaring an LLM persona (model, instructions, optional tools). - A tool is a function the LLM can call mid-response (autonomous
mode). Defined as JSON under
prompts/tools/<name>.jsonwith ajavaImplBodyfield — the body ofexecute(FunctionCall call).
See §3 for exact shapes.
Every node declares data.handles.{inputs,outputs} with typed handle
ids (exec-in-1, data-out-result, ...). Edges connect a sourceHandle
on one node to a targetHandle on another. The parser infers kind from
the prefix (exec-* = control flow, else data flow). See §4.
Run npx tsx tools/codegen-cli.ts debug <flow>.rocketflow to dump
each pipeline stage's output, or ... smoke <flow>.rocketflow to build a
full Spring Boot project and run mvn compile. Iterate fast — a flow
that compiles is one that the runtime will accept. See §5.
The 19 registered node types live in src/components/reactflow/registry/.
For each, the registry's factoryDefaults is the authoritative shape of
the node's data field — read it when in doubt.
| Type | Use when |
|---|---|
startNode |
Every main flow needs one. Sets trigger + return type. |
endNode |
Terminates an exec branch. Carries the return value if the flow has one. |
inputNode / outputNode |
Sub-flow boundaries (one each, required for type: "sub"). |
valueNode |
Inline literal: number, string, boolean, list. |
variableNode |
Read/write a typed variable (or call a custom helper service). |
agentNode |
Send a prompt to an LLM persona. May expose dynamic prompt vars. |
subflowNode |
Call another .rocketflow as a function. |
ifElseNode |
2-way branch on a Boolean. |
switchNode |
N-way branch on a discrete value (or expression). |
forEachNode / whileNode |
Loop over a collection / while a condition holds. |
tryCatchNode |
Catch a typed exception. |
operatorNode |
Math / comparison / logical / string ops (typed by category). |
allOfNode |
Wait for N parallel branches, then continue. |
forEachCompletableNode / listCompletableNode |
Async collection processing. |
globalVariableNode |
Read project-level constant (<<<globalvar::X>>>). |
eventEmitterNode |
Publish a Spring ApplicationEvent. |
Pattern: classify a step → pick the node
- "Decide between A or B" →
ifElseNode(Boolean) orswitchNode(enum-like) - "Run for every X" →
forEachNode(sequential) orforEachCompletableNode(parallel) - "Ask the LLM to ..." →
agentNode(+ agent JSON) - "Compute X+Y" →
operatorNode - "Call function
foo(a,b)from another flow" →subflowNode(+.rocketflowforfoo) - "Remember a value across iterations" →
variableNode(write then read)
Two prompt forms:
instruction_codeis the AUTHORING form. It uses<<<var::Y>>>for dynamic vars (wired at runtime via input handles on the AgentNode) and<<<globalvar::X>>>for codegen-time substitution (baked into the renderedinstructions).instructionsis the RUNTIME form, after<<<var::Y>>>→{{Y}}Mustache translation. The CLI smoke mode handles this conversion for you (substituteFlowVarsToMustache).
Dynamic prompt var → AgentNode handle:
For each <<<var::Y>>> in instruction_code, the AgentNode must have a
matching data.handles.inputs[] entry with id: "data-in-Y". The
codegen emits a Map<String,Object> populated from those wires.
{
"type": "tool",
"name": "echo", // ^[a-z][a-z0-9_]*$, unique
"description": "Echo back the input string.",
"parameters": { // JSON Schema, fed verbatim to the LLM
"type": "object",
"properties": { "text": { "type": "string" } },
"required": ["text"]
},
"endsTurn": false, // true = calling the tool ends the loop
"javaImplBody": "return call.getArgumentsAsMap().get(\"text\").toString();"
}API gotcha (T8 friction, learned the hard way): the FunctionCall
runtime API does not have call.getArgument("text"). The correct
calls are:
call.getArgumentsAsMap()→Map<String, Object>call.getName()→ tool name (the LLM-visible string)call.getId()→ call identifier
The javaImplBody is inserted verbatim inside execute(FunctionCall call) throws Exception.
Field-injected Spring beans are allowed (just declare them on the class
via... wait, no — codegen owns the class. Put your custom beans in a
separate @Service and look them up by autowiring through a static
ApplicationContextProvider if you really need them, OR keep the tool
body purely functional). Keep tool bodies stateless when possible.
Handle id format (registry source-of-truth: src/components/reactflow/registry/):
exec-in-1 exec-out-1 exec-out-then exec-out-else
data-in-X data-out-X data-in-condition data-out-result
- prefix
exec-*→ control-flow handle - prefix
data-*→ data handle (the suffix afterdata-in-/data-out-is the parameter name on agent / sub-flow nodes) - The parser falls back to
datafor unknown prefixes, soout-1is silently broken — always include theexec-ordata-prefix.
Edge shape:
{ "id": "e1",
"source": "nodeA", "target": "nodeB",
"sourceHandle": "exec-out-1", "targetHandle": "exec-in-1" }The kind field is OPTIONAL — the parser infers execution vs data
from the sourceHandle prefix. Don't write inconsistent values.
Common handle layouts:
startNode:{inputs: [], outputs: [{id:"exec-out-1", type:"exec"}]}endNode(with return):{inputs: [{id:"exec-in-1", type:"exec"}, {id:"data-in-1", type:"<T>"}]}ifElseNode: 1 exec input + 1 data input (data-in-condition, Boolean) + 2 exec outputs (exec-out-then,exec-out-else)subflowNode(for sub-flow with inputsn: Intand outputsdoubled: Int): inputs =[exec-in-1, data-in-n], outputs =[exec-out-1, data-out-doubled]
The CLI is the fastest feedback loop — use it before declaring "the codegen is broken".
# Stage-by-stage dump (parser → walker → AST → emitted Java).
npx tsx tools/codegen-cli.ts debug path/to/flow.rocketflow
# Full project build + mvn compile.
npx tsx tools/codegen-cli.ts smoke path/to/flow.rocketflow \
--subflows=path/to/subflows-dir \
--agents=path/to/agents-dir \
--tools=path/to/tools-dir \
--out=tmp/my-test-out \
--package=com.examplePre-flight checklist before you run:
- Every node has
id,type,position, anddata.handles - Every edge's
sourceHandleandtargetHandleexist on the referenced nodes -
startNodeexists in main flows;inputNode+outputNodeexist in sub-flows - Every dynamic agent var (
<<<var::X>>>) has a matchingdata-in-Xhandle on its AgentNode - Every
subflowNode.data.selectedSubflowmatches a sub-flow filename you'll pass via--subflows - Tool javaImplBody uses
call.getArgumentsAsMap(), notgetArgument(...)
Debug mode tells you:
- parser diagnostics (malformed JSON / orphan handles)
- walker output (start node, main exec path, total visits)
- AST kinds (
Return,AgentCall,If,For,SubflowCall, ...) - emitted Java (first 40 lines per file)
Smoke mode tells you:
- the same diagnostics
- whether
mvn compilePASSES on the resulting Spring Boot project
Goal: "When the user POSTs a number to /complex, double it via a sub-flow, then return 'positive' if the result is > 0 else 'non-positive'."
A self-contained example exercising sub-flow nesting + control flow.
main.rocketflow:
{
"type": "main",
"name": "complexFlow",
"nodes": [
// Trigger: HTTP controller, returns String.
{ "id": "start", "type": "startNode", "position": {"x":0,"y":0},
"data": { "triggerType": "controller", "returnType": "String",
"handles": { "inputs": [], "outputs": [{"id":"exec-out-1", "type":"exec"}] }}},
// Inline literal: 5.
{ "id": "val", "type": "valueNode", "position": {"x":100,"y":200},
"data": { "valueType": "Int", "value": 5,
"handles": { "inputs": [], "outputs": [{"id":"data-out-1", "type":"Int"}] }}},
// Call outer.rocketflow (a sub-flow that doubles).
{ "id": "outerCall", "type": "subflowNode", "position": {"x":200,"y":0},
"data": { "selectedSubflow": "outer.rocketflow",
"subflowSignature": {
"inputs": [{"name":"n", "type":"Int"}],
"outputs": [{"name":"doubled", "type":"Int"}]
},
"handles": {
"inputs": [{"id":"exec-in-1","type":"exec"}, {"id":"data-in-n","type":"Int"}],
"outputs": [{"id":"exec-out-1","type":"exec"}, {"id":"data-out-doubled","type":"Int"}]
}
}},
// ... ifelse + 2 endNodes (then/else) — see file for full content.
],
"edges": [
{ "id":"e1", "source":"start", "target":"outerCall",
"sourceHandle":"exec-out-1", "targetHandle":"exec-in-1" },
// data wire: 5 → outerCall.n
{ "id":"d1", "source":"val", "target":"outerCall",
"sourceHandle":"data-out-1", "targetHandle":"data-in-n" }
// ...
]
}outer.rocketflow (sub-flow): takes n: Int, returns doubled: Int,
internally calls a deeper inner.rocketflow then multiplies by 2.
Test:
npx tsx tools/codegen-cli.ts smoke path/to/main.rocketflow \
--subflows=path/to/subflow-dir \
--out=tmp/test-out
# → mvn compile PASSEDThe codegen produces ComplexFlowController (POST endpoint) +
ComplexFlowService containing _sub_outer(...) and _sub_inner(...)
helpers — the transitive sub-flow resolver pulls in inner even though
only outer is referenced from the main flow.
-
Handle id missing prefix → parser silently treats it as
data→ exec edges land in the data graph and codegen emits nothing. Always prefix withexec-ordata-. -
data.handlesmissing on a node → AST builder can't resolve parameters → empty method body / silent drop. The schema inschema/rocketflow.schema.jsondocuments the per-type expectations; VS Code auto-completes against it (see.vscode/settings.json). -
Sub-flow not in registry → "Sub-flow 'X' is referenced ... but was not provided in CompileOptions.subflows". Pass
--subflows=DIRto the CLI (or include it incompileProjectToJava({ subflows })). The compiler now traverses sub-flows transitively, so loading every.rocketflowin the project dir is the safe default. -
<<<var::X>>>in agent prompt without a matchingdata-in-Xhandle on the AgentNode → the runtime gets no value for{{X}}→ empty string in the rendered prompt. Always pair the var with a handle. -
Tool body uses non-existent API (
call.getArgument("...")) → mvn compile fails. Usecall.getArgumentsAsMap()instead. -
Position x/y omitted → parser crashes (required by ReactFlow). Use a rough grid (column × 200, row × 150) — codegen ignores positions, they're UI-only.
-
EndNode without an exec input → orphan branch, never reached. Every endNode must have
exec-in-1(and adata-in-1if the flow returns a value). -
Sub-flow without InputNode AND OutputNode → diagnostic error. Sub-flows always have exactly one of each, even when parameter-less (Input with empty
inputs[], Output with emptyoutputs[]).
Two layers:
High-level (recommended for one-shot project compile):
import { compileProjectToJava } from './services/CodeGen';
const { files, diagnostics } = compileProjectToJava({
mainFlow, // Flow object (parsed JSON)
subflows: { outer, inner }, // optional, keyed by basename
agents: { // optional
summarizer: {
instruction_code: 'Summarize: <<<var::text>>>',
functions: ['echo']
}
},
tools: [echoTool], // optional
packageName: 'com.example',
});
// files is JavaFile[] — service + trigger + tool registry + per-tool classesLow-level (advanced — when you need fine-grained control):
compileFlowToJava(flow, options)→{ files, diagnostics }for the flow service + trigger only.generateToolsJava({ basePackage, tools })→ tool executor classes.parseFlow({ raw, name })→{ flow, diagnostics }.walk(flow)→ walker visit map.buildAst(flow, diagnostics?, options?)→ AST array.
All exported from src/services/CodeGen/index.ts.
Source-of-truth files (in this repo):
schema/rocketflow.schema.json— JSON Schema for.rocketflowfiles, documents every per-node-typedatafield. VS Code auto-completes against this via.vscode/settings.json.src/services/CodeGen/types.ts—Flow,FlowNode,FlowEdge,NodeData,AstNode.src/services/CodeGen/README.md— pipeline overview (parser → walker → astBuilder → emitters).src/components/reactflow/registry/— one entry per node type with the authoritativefactoryDefaults(canonicaldatashape).src/services/Validation/preCodegenChecks.ts— every validation rule and itsruleId(matches the labels surfaced by the Generate dialog).
Most generated Java files are fully overwritten on regen — anything
the user wrote inside *.flow services / controllers / tools is lost.
The exception is the tab-driven Java families (Entity / Routine / Event / Trigger), which support marker preservation:
//{Edit here:}
// User code preserved across regen.
//{End Edit}The merge logic lives in src/components/common/EntityTab/utils.ts (and
siblings). It reads the on-disk file, extracts the user-edit sections,
regenerates the file from template, and splices the user-edit content
back in.
Practical guidance: for flows, assume zero preservation. Custom logic
goes in a separate @Service bean called via a Variable node.
- 1.20.3:
requestAgent(..., Map<String,Object> promptVars)for{{Y}}Mustache substitution at runtime. - 1.22.0:
chatCompletionfamily deprecated in favour ofrequestAgent/requestAutonomous. - 1.23.0: native Grok / Azure-Grok / DeepSeek / Gemini providers +
CUSTOMdata-driven provider viaCustomProviderSpec. - 1.24.0: prompt caching enabled on Azure Anthropic.
{ "type": "agent", "id": "summarizer", // matches the file name (no .json) "name": "summarizer", "model": "gpt-4o-mini", "instances": "any", // or array of LLM_INSTANCES ids "instructions": "Summarize: {{text}}", // RUNTIME form (Mustache-rendered) "instruction_code": "Summarize: <<<var::text>>>", // AUTHORING form (with var markers) "temperature": 0.0, "responseTimeout": 60000, "functions": ["echo"], // optional: tool names if autonomous "autonomous": false, // true = tool-use loop enabled "maxIterations": 5 // only meaningful when autonomous }