← All posts

Your Evaluator Is a Node. Your Loop Is an Edge.

An LLM that judges another LLM, a senior model that drives the conversation, and a self-refining loop — none of it needed a runtime feature. It's four nodes and a backward edge.

We keep making the same three claims, and people keep treating each one as a feature we'd have to go build.

An observer that watches an agent and decides whether it's done. An LLM-as-judge that scores another model's work. A self-refining loop — the exact phrase from the 2024 definition of a flow, iterative, multi-agent, self-refining. Three things that, in most stacks, are three separate pieces of machinery: an eval harness bolted on the side, a judge framework, an orchestration loop with its own control flow.

Here they are the same primitive you already have. The observer is a node. The judge is that node's system prompt and its two output ports. The loop is a backward edge and a counter. Nothing in the runtime knows the word "evaluation."

This is the flow. It's called Eval Loop, and the whole export is at the bottom of this post so you can import it and run it.


The shape

start
  ↓
Seed Demo Product        JS   · lays down a whole demo context ($)
  ↓
User Analyser            LLM  ←──────────────────────────┐  the worker
  ↓                                                       │  (in practice: an agent)
Senior Evaluator         LLM  · reads the whole convo     │  the observer / judge
  ├─ answered ─→ Finalize Result   JS  · done             │
  └─ continue ─→ Append Teacher Q  JS  · writes next Q ────┤
                       ↓                                   │
                 Loop Counter       JS  · count + 1 ───────┘

Two LLM nodes. One is the worker being evaluated — in this demo it analyses products and writes a recommendation, but in practice this is any node, up to and including a fully bound agent with its own tools. The other is the observer that reads everything the worker has said and decides, each cycle, whether the work is good enough or needs another turn.

The rest is plumbing you can read: a seed, a counter, and one JS node that turns the observer's decision into the next question. There is no "loop node," no "evaluation step" in a palette. There is a backward edge.


The seed — so you can just hit Run

The first real node lays down the entire starting context, so the flow is runnable without you creating a Context document first:

let products = [
  { "id": "P100", "name": "MacBook Air M4",              "price": 1299,
    "description": "13-inch laptop with Apple M4 chip, 16GB RAM and 512GB SSD." },
  { "id": "P101", "name": "Dell XPS 13",                 "price": 1199,
    "description": "13-inch ultrabook with Intel Core Ultra and OLED display." },
  { "id": "P102", "name": "Lenovo ThinkPad X1 Carbon",   "price": 1499,
    "description": "Business laptop with Intel Core Ultra and enterprise security." }
]

let project = {
  "goal": "Create high quality buying recommendations for products",
  "user_message": "Analyze the products and provide recommendations"
}

input = { loop: { count: 0 }, project, products, user_request_analyse: {}, evaluator: {} }
input

A JS node reads and writes a variable called input — its scoped slice of context — and the value of the last expression is the node's output. No return. This node has no key, so the object it names on that last line becomes the root context every downstream node reads: the product catalogue, the project goal, an empty user_request_analyse for the worker, an empty evaluator for the judge, and a loop.count starting at 0.

That loop.count is the spine of the whole thing. Hold onto it.


The worker — the node being evaluated

The User Analyser LLM node (key: user_request_analyse) is seeded with two messages:

System: you are helpful assistant
User:   {{$.project.user_message}}

        products :
        {{$.products}}

Ordinary stuff — a {{$.path}} template resolves the project's user message and the seeded catalogue into the prompt, the model answers, and its turn lands under $.user_request_analyse.messages. On the first pass that slot fills with [system, user, assistant].

The important thing about this node is what it is in the topology: it's the thing under test. Swap it for an agent with tools, a retrieval chain, a whole sub-flow — the evaluator downstream doesn't care. It reads a message history and judges it. What produced that history is somebody else's node.


The observer — a judge is a node with two ports

Senior Evaluator (key: evaluator) is the claim, made concrete. It's an LLM node whose system prompt is a reviewer's brief — read the conversation, estimate coverage of the project's goal, target 90%, don't loop forever — and whose user message hands it exactly what a judge needs:

User:  Project :
       {{$.project.goal}}

       Message History :
       {{$.user_request_analyse.messages}}

It reads the goal and the worker's entire conversation. That's the observer part — nothing about it is privileged runtime access; it's a template pointing at another node's output.

Then the judge part. The node binds two functions, and in FloMorphic a bound function is an output port:

FunctionPortMeaning
continue(question)continueCoverage isn't there yet — here's one clarifying question to push the work forward
answered(summary)answeredGood enough — here's why

The model doesn't return prose we then parse for a verdict. It calls a function, and the name of the function it calls is the branch the flow takes. continue routes to the loop; answered routes to the exit. The evaluation and the routing are the same act. There is no separate "if the judge said yes…" node reading the judge's text — the judge picks the edge.

And here's the part worth slowing down on: the system prompt names those exact functions. Read it and you'll see continue and answered spelled out in the instructions — "you MUST call continue", "prefer concluding with answered". So the same name lives in three places at once:

  1. the bound function on the node — continue(question) (design time),
  2. the edge's port{ "from": "senior-evaluator", "to": "append-teacher-question", "port": "continue" } (design time, static),
  3. the system prompt telling the model when to call it (guides the runtime choice).

That shared name is the seam where a statically-drawn edge meets a dynamic runtime decision. The routes are fixed on the canvas before anything runs — there are exactly two edges out of this node, and no prompt can invent a third. What's dynamic is only which of those fixed edges fires, and the prompt is where you make the drawn topology legible to the model in its own language, so its bounded judgment lands on a route you already drew. This is flow engineering in one node: the model's autonomy is real but corralled — it decides, but only among edges a human put there, and the prompt is the contract that tells it what those edges mean. Change the prompt and you change when it routes each way; change the edges and you change where those routes can go. The two are wired together by a name.

And notice how the system prompt uses the counter:

The current evaluation loop count is provided by {{$.loop.count}}. If {{$.loop.count}} is 0, this is the first evaluation cycle. You MUST call continue… After 5 or more messages, prefer concluding with answered unless a critical requirement is still completely missing.

The judge's behaviour is a function of where in the loop it is. First cycle: always probe. Later cycles: converge. That's a policy you can read and edit in a drawer, not a control structure compiled into a framework. Which brings us to the counter — and to the flag this whole post exists to explain.


clear_history — the same node kind, opposite memory

Here is the subtlety that used to force people into an awkward workaround, and the reason we added a checkbox.

An LLM node's messages are seeded once. The system and user messages you configure — with their {{...}} variables — are resolved against the context at the moment the slot is first populated, and from then on that node continues an existing conversation rather than re-seeding. On a straight-line flow you never notice. Inside a loop, it's the whole ballgame — and the two LLM nodes here want the exact opposite of each other.

The worker must keep its history. User Analyser is a genuine multi-turn conversation. Each cycle it should see everything said so far plus the new question, and add one more turn. Re-seeding it would wipe the conversation we're trying to build. So it has no flag — the runtime finds an existing messages slot on re-entry and continues it, exactly as wanted.

The judge must throw its history away. Senior Evaluator's user message embeds {{$.loop.count}} and {{$.user_request_analyse.messages}} — both of which change every cycle. But those variables were resolved when the slot was first seeded, at count: 0 against the first, short conversation. Left alone, the judge would re-read the same frozen snapshot forever: same count, same stale history, never seeing the work it's supposed to be re-evaluating. It would also start accreting its own past verdicts as conversation, which is not what a fresh assessment is.

What you want is for the judge to re-seed every pass: empty the slot, re-resolve the templates against the now-current context, evaluate the conversation as it stands right now. Before, the only way to get that was to put a JS node in front of it whose whole job was to clear the messages slot so the runtime would re-seed — a node that existed purely to defeat the caching. Now it's a field in the LLM settings:

"body": {
  "messages": [ /* system + user */ ],
  "clear_history": true
}

clear_history: true empties the node's message slot on entry, so its seeded messages re-resolve every cycle. The judge always reads the current loop.count and the current worker conversation. The extra node disappears.

So the two nodes sit side by side, same kind, and the only difference between "remember everything" and "start clean every time" is one checkbox:

User Analyser     (no flag)          → history GROWS  — a real multi-turn agent
Senior Evaluator  clear_history:true  → re-seeds fresh — a stateless judge

That's the flag. It's small, and it removes an entire node whose only purpose was to work around the default.


The loop — an edge and a counter

When the judge calls continue, two JS nodes turn that decision into the next iteration.

Append Teacher Question (key: messages, scope: $.user_request_analyse) reads the evaluator's function call and writes its question into the worker's conversation:

let history = input.messages || []
let eval_func = _get("$.evaluator")
let eval = JSON.parse(eval_func.messages[eval_func.messages.length - 1].tool_calls[0]?.arguments)
let question = eval?.question || 'To move closer to the goal, refine one point: which option is the strongest recommendation, and what evidence makes it better than the others?'
let messages = [...history, { role: 'user', content: question }]
messages

That || fallback matters more than it looks. If the judge ever routes continue without a well-formed question, the loop still needs a turn that advances the goal — not an empty message and not a dead cycle. So the fallback is itself a goal-directed follow-up: it asks the worker to sharpen its recommendation and back it with evidence, keeping the conversation decomposing toward the project goal rather than stalling. The loop should never spend a cycle asking nothing.

Because its scope is $.user_request_analyse, input is only the worker's slice — input.messages is that growing conversation, and nothing else is reachable through input. But the question it needs lives under $.evaluator, which is outside this node's scope. That's exactly what _get is for:

_get("$.path") reads from the full context root, regardless of the node's scope. A scoped node gets its slice as input — that's the local working set it reads and writes. When it needs a value that isn't in that slice, _get("$.somewhere.else") reaches back to the whole context and fetches it. input is your scope; _get is the escape hatch to everything outside it.

So _get("$.evaluator") reaches over to the judge's output — which input can't see from here — pulls the arguments of the function it called, and lifts out the question. Then it appends that question as a new user turn onto its own scoped slice. This is the "top model driving the conversation": the senior evaluator isn't just scoring — the question it emits becomes the next thing the worker is asked. The judge is steering the student.

Loop Counter (key: count, scope: $.loop) does the humblest and most important job in the flow:

let count = input.count + 1
count

Scoped to $.loop, it reads input.count and writes count + 1 back to $.loop.count. That's the value the judge's system prompt reads to know which cycle it's in.

And then the edge that makes it a loop:

Loop Counter ──→ User Analyser

A backward edge to the worker. That's it. There is no loop construct, no iterator, no while. A run re-enters User Analyser — which now finds a longer messages history (its old turns plus the teacher's new question) and continues the conversation — produces a new turn, and flows back into the judge, whose clear_history makes it re-read the whole updated picture at the new count. The cycle turns until the judge calls answered instead of continue.

This is the thing worth sitting with. An agent is a loop — and in FloMorphic a loop is a durable context object a process iterates over, drawn as an edge that goes back. We didn't add loop support. Loop support is what an edge and a persisted context already are.


The reasoning is on the outside

There's a pattern here worth naming, because it's the whole reason to build refinement this way instead of asking one model to "think harder."

A single model tasked with "analyse these products thoroughly" does its multi-step reasoning inside its own head — it decides, invisibly, which facets matter (price, performance, build quality, security, resale, warranty), interrogates each one in a hidden chain of thought, and hands you a finished paragraph. You get the conclusion. You cannot see which facets it considered, which it skipped, or where it convinced itself.

This flow turns that inside out. The senior node's prompt asks it to raise exactly one clarification question per cycle — and across cycles those questions fan out over the facets a thorough analysis actually has. Cycle 1: "which of these has the strongest long-term support?" Cycle 2: "how does resale value compare?" Cycle 3: "which fits a security-sensitive enterprise?" Each question is a visible turn on the context, each is judged before the next is asked, and each pushes the worker to cover a facet it hadn't.

The reasoning didn't get smaller — it got externalized. What would have been one model's opaque internal deliberation is now a sequence of nodes you can read, replay, gate, and edit. You can see the exact question that moved the work forward, change the judge's brief to weight a facet you care about, or cap the depth. The chain of thought is no longer a property of a single inference you have to trust; it's the topology of the loop.

That's the same move flow engineering makes everywhere — pull control and planning off the probabilistic model onto a layer you can see. Here it's applied to the reasoning about quality: a senior model that probes one facet at a time, on the record, instead of one model that reasons across all of them behind a curtain.


The exit

The answered port goes to Finalize Result, which reads the judge's summary and packages the outcome:

let evalNode = input.evaluator
let last = evalNode.messages[evalNode.messages.length - 1]
let summary = JSON.parse(last.tool_calls[0]?.arguments || '{}')

let result = {
  status: 'answered',
  requirements: input.user_request_analyse,
  evaluation: summary
}
result

Notice what changed from the teacher node: no _get here. Finalize Result has no scope, so input is the whole context — the evaluator is in scope as input.evaluator, and the worker as input.user_request_analyse. It reads both directly. The teacher node needed _get for the exact opposite reason: it was scoped down to one branch, so the evaluator was out of reach through input. Same two pieces of data, two access paths — decided entirely by each node's scope. That's the rule in one comparison: read your slice through input, reach anything outside it with _get.

The worker's final conversation and the judge's closing rationale, side by side, on the context — the record of what was decided and why, which is the whole reason to build it this way rather than as an opaque loop somewhere.


Run it, and watch the claim happen

Import the export below, point the two LLM nodes at a provider profile (the keys never travel in the flow — they live in a Node Settings profile referenced by id), and hit Run.

The judge probes on cycle 0 because its prompt says it must. The teacher's question lands in the worker's history. The counter ticks. The worker answers again, now with more to go on. The judge — re-seeded, reading the current count and the grown conversation — decides again. A few cycles in, coverage is there and it calls answered. Every turn of that is a traced event on the same diagram you drew, and the final context holds both the conversation and the verdict.

An observer that watches an agent and rules on it. An LLM judging an LLM. A conversation a senior model steers. A self-refining loop. Four things people expect to be four subsystems — and on the canvas they're four nodes and an edge, because that's what flow engineering bounds: not the model's reasoning, but where its reasoning is allowed to lead.


The full export

{
  "flomorphic": { "kind": "workflow", "version": 1 },
  "title": "Eval Loop",
  "nodes": [
    { "ref": "start", "kind": "startNode", "title": "Start" },

    { "ref": "seed-demo-product", "kind": "js", "title": "Seed Demo Product",
      "data": { "logic_rule":
        "let products = [ { \"id\":\"P100\", \"name\":\"MacBook Air M4\", \"price\":1299, \"description\":\"13-inch laptop with Apple M4 chip, 16GB RAM and 512GB SSD.\" }, { \"id\":\"P101\", \"name\":\"Dell XPS 13\", \"price\":1199, \"description\":\"13-inch ultrabook with Intel Core Ultra and OLED display.\" }, { \"id\":\"P102\", \"name\":\"Lenovo ThinkPad X1 Carbon\", \"price\":1499, \"description\":\"Business laptop with Intel Core Ultra and enterprise security.\" } ]\nlet project = { \"goal\":\"Create high quality buying recommendations for products\", \"user_message\":\"Analyze the products and provide recommendations\" }\ninput = { loop:{count:0}, project, products, user_request_analyse:{}, evaluator:{} }\ninput" } },

    { "ref": "user-analyser", "kind": "llm", "title": "User Analyser",
      "key": "user_request_analyse",
      "data": {
        "settingsId": "nset_ms0totsvaaj3yigk",
        "body": { "messages": [
          { "role": "system", "content": "you are helpful assistant" },
          { "role": "user",   "content": "{{$.project.user_message}}\n\nproducts :\n{{$.products}}" }
        ] } } },

    { "ref": "senior-evaluator", "kind": "llm", "title": "Senior Evaluator",
      "key": "evaluator",
      "data": {
        "settingsId": "nset_ms0totsvaaj3yigk",
        "body": {
          "messages": [
            { "role": "system", "content": "You are a senior reviewer and evaluator. Review the entire conversation history and decide whether the project's goal has been adequately addressed. Estimate coverage 0-100%; the target is 90%+. The current loop count is {{$.loop.count}}. If {{$.loop.count}} is 0, this is the first cycle and you MUST call continue, even if the answer looks complete. When calling continue, ask exactly ONE concise clarification question. If the conversation already has 5+ messages and the answer substantially addresses the goal, call answered. Do not probe forever — refinement, not perfection. When calling answered, give a short summary of why the goal is sufficiently met." },
            { "role": "user",   "content": "Project :\n{{$.project.goal}}\n\nMessage History :\n{{$.user_request_analyse.messages}}" }
          ],
          "clear_history": true
        },
        "functions": [
          { "name": "continue", "title": "Continue Loop",
            "description": "Coverage is below target and more clarification is needed.",
            "parameters": { "type": "object", "required": ["question"],
              "properties": { "question": { "type": "string" } } } },
          { "name": "answered", "title": "Answer Complete",
            "description": "Coverage is sufficient and the requirements are complete.",
            "parameters": { "type": "object",
              "properties": { "summary": { "type": "string" } } } }
        ] } },

    { "ref": "append-teacher-question", "kind": "js", "title": "Append Teacher Question",
      "key": "messages", "scope": "$.user_request_analyse",
      "data": { "logic_rule":
        "let history = input.messages || []\nlet eval_func = _get(\"$.evaluator\")\nlet eval = JSON.parse(eval_func.messages[eval_func.messages.length-1].tool_calls[0]?.arguments)\nlet question = eval?.question || 'To move closer to the goal, refine one point: which option is the strongest recommendation, and what evidence makes it better than the others?'\nlet messages = [...history, { role: 'user', content: question }]\nmessages" } },

    { "ref": "loop-counter", "kind": "js", "title": "Loop Counter",
      "key": "count", "scope": "$.loop",
      "data": { "logic_rule": "let count = input.count + 1\ncount" } },

    { "ref": "finalize-result", "kind": "js", "title": "Finalize Result",
      "data": { "logic_rule":
        "let evalNode = input.evaluator\nlet last = evalNode.messages[evalNode.messages.length-1]\nlet summary = JSON.parse(last.tool_calls[0]?.arguments || '{}')\nlet result = { status: 'answered', requirements: input.user_request_analyse, evaluation: summary }\nresult" } }
  ],
  "edges": [
    { "from": "start",                   "to": "seed-demo-product" },
    { "from": "seed-demo-product",       "to": "user-analyser" },
    { "from": "user-analyser",           "to": "senior-evaluator" },
    { "from": "senior-evaluator",        "to": "append-teacher-question", "port": "continue" },
    { "from": "senior-evaluator",        "to": "finalize-result",         "port": "answered" },
    { "from": "append-teacher-question", "to": "loop-counter" },
    { "from": "loop-counter",            "to": "user-analyser" }
  ]
}

Repo: github.com/FloMorphic/getting-started

Concepts and docs: inflowenger.com/flomorphic

Related reading: why flow engineering is an architecture, not a role, the JS node's evaluation model this flow leans on, and how a context object survives across a loop the way it survives across a pause.