TL;DR: EmbodiWeave is an agent that decides where a robot policy should retrain from, using a FalkorDB graph of every checkpoint, evaluation, failure and past decision. It talks to the graph over MCP, writes down why it chose before it trains anything, and then a fixed gate plus a parent comparison, both recorded in the same graph, decide whether the result ships. In the final run, a graph rule I added changed the agent's choice. The new child still regressed against its parent, and it was blocked from shipping. That second part is the feature.
๐ Live demo ยท ๐ฅ 2:18 video ยท ๐งช Train one in your browser ยท ๐ป Code ยท Built for Graph Hacks (WeMakeDevs ร FalkorDB), Track 01: Agents That Act on Connected Data
A robot that hits a pedestrian at step 51
The first thing on EmbodiWeave's landing page is a failure. A small simulated robot (a 2D unicycle with range-finder rays) drives toward a goal, a scripted pedestrian crosses its path, and at step 51 it hits them.
The obvious fix is "train it more". But train what, exactly? By then I had a pile of checkpoints:
- the original from-scratch policy,
- a longer-trained version of it,
- a version that rehearsed several task families during training,
- children of those, some better on one suite and worse on another,
- and failed attempts I didn't want to repeat.
Each one has evaluations on four validation suites, and every suite has its own collision limit:
| Suite | Episodes | Max collisions | Min success |
|---|---|---|---|
| Target (static + crossing obstacles) | 60 | 10% | 80% |
| Open arena | 40 | 5% | 90% |
| Static obstacles | 40 | 10% | 75% |
| Crossing pedestrians | 40 | 15% | 75% |
That's 180 validation episodes per checkpoint, and a checkpoint is only promotable if it passes all four.
So the real question isn't "should I train more". It's:
Which existing checkpoint is the best place to start from, given everything we've already tried, and how do we know the result is actually better?
That's a question about relationships: lineage, which evaluation ran on which exact artifact, which children regressed against which parents, which decisions led to which outcomes. That's the graph.
Why a graph, specifically
Here's what makes this domain sneaky:
- Two checkpoints with identical tensor shapes can have incompatible sensors. Same input dimensions, different meaning. Matching shapes isn't compatibility.
- One great episode doesn't stand in for a full suite. A replay of the robot reaching the goal is not evidence the policy passes.
- An evaluation belongs to an exact artifact. If the weights changed, the old evaluation doesn't count, even if the checkpoint ID is the same.
- Regressions are inherited. If a checkpoint's ancestor already produced a child that got worse, that's information about the whole lineage.
A table of results can't express "the ancestor three hops up produced a regressed child". A graph does it in one MATCH.
The model
(Policy)-[:DERIVED_FROM]->(Policy) // lineage
(Policy)-[:USES_CONTRACT]->(Contract) // semantic sensor/action contract
(Evaluation)-[:ASSESSED]->(Policy) // bound to artifact_hash
(Evaluation)-[:ON_SUITE]->(Suite) // bound to suite_fingerprint
(Failure)-[:OBSERVED_IN]->(Evaluation) // collisions, timeouts
(Decision)-[:CONSIDERED {rank}]->(Policy) // every option it weighed
(Decision)-[:SELECTED]->(Policy) // the origin it picked
(Decision)-[:LAUNCHED]->(Run)-[:PRODUCED]->(Policy)
(Promotion)-[:FOR]->(Policy) // the gate verdict
(Decision)-[:HAS_OUTCOME]->(DecisionOutcome)-[:ASSESSED]->(Policy)
(Policy)-[:COMPARED_AGAINST | REGRESSED_AGAINST]->(Policy)
Two properties do a lot of quiet work: every Evaluation carries the artifact_hash of the exact weights it measured and the suite_fingerprint of the exact suite it ran. If either doesn't match, the evaluation simply doesn't count.
The loop
โโโโโโโโโโโโโโโโโ FalkorDB โโโโโโโโโโโโโโโโโโโโโโโโ
โ (read-only Cypher, shortestPath, exclusions) โ
โผ โ
MCP agent / planner โโโบ guardrail โโโบ CPU PPO trainer โ
writes Decision (can veto) โ โ
BEFORE training โผ โ
saved checkpoint + normalization
โ โ
180 validation episodes โ
โ โ
fixed promo-1 gate + parent comparison
โ โ
append-only evidence โโโบ trusted ingest
Three rules hold the whole thing together:
- The Decision is written before training starts. Graph revision, Cypher, parameters, returned record IDs, every considered candidate and the selected origin. Nobody can rationalise a choice after seeing the result.
- Planning stops if the graph is unavailable. The immutable JSON records are a recovery log, not a silent fallback.
- The gate never moves. Thresholds, suite definitions and promotion code stayed unchanged across every attempt in this post.
The queries that run the agent
1. What's actually wrong with this checkpoint?
MATCH (p:Policy {id:$policy})<-[:ASSESSED]-(e:Evaluation)-[:ON_SUITE]->(s:Suite)
WHERE s.role = 'validation' AND e.complete_suite = true
AND e.artifact_hash = p.artifact_hash AND e.suite_fingerprint = s.fingerprint
OPTIONAL MATCH (f:Failure)-[:OBSERVED_IN]->(e)
RETURN s.id, e.id, e.success_rate, e.collision_rate, e.timeout_rate,
f.outcome, f.hit, f.count
For the original policy this surfaced pedestrian collisions and open-arena timeouts. Longer training fixed three suites but left open-arena success below its floor. That retained-task failure is why the next untried intervention was rehearsal: training across all four task families instead of just the target.
2. How far is the nearest ancestor that actually passed?
This one is a native graph algorithm, exposed as an MCP tool:
MATCH (p:Policy {id:$policy})-[:USES_CONTRACT]->(c:Contract)<-[:USES_CONTRACT]-(a:Policy)
WITH p,a,shortestPath((p)-[:DERIVED_FROM*1..8]->(a)) AS path
WHERE path IS NOT NULL AND length(path) <= $max_hops AND a.status = 'completed'
MATCH (a)<-[:ASSESSED]-(e:Evaluation)-[:ON_SUITE]->(s:Suite {id:$suite})
WHERE s.role = 'validation' AND e.complete_suite = true AND e.artifact_hash = a.artifact_hash
AND e.suite_fingerprint = s.fingerprint AND e.success_rate >= $min_success
AND e.collision_rate <= $max_collision
RETURN a.id AS ancestor_id, a.artifact_hash AS artifact_hash, length(path) AS hops,
[n IN nodes(path) | n.id] AS path, e.id AS evaluation_id,
e.success_rate AS success_rate, e.collision_rate AS collision_rate
ORDER BY hops, collision_rate, ancestor_id LIMIT 20
Note the first line: an ancestor only counts if it shares the same semantic contract. Then it's bounded, unweighted lineage distance: "how many training generations back is the last checkpoint that passed everything?"
3. Which lineages are carrying regressions?
A weighted incoming-degree tool sums the positive collision-rate deltas on COMPARED_AGAINST and REGRESSED_AGAINST edges. One regressed child that went from 2/60 โ 3/60 on target and 0/40 โ 1/40 on crossing contributes:
1/60 + 1/40 = 0.04167
And one detail I care about: unknown support is null, not zero. A lineage with no recorded regressions isn't "safe". It's "unexamined".
4. Bind the gate to the exact saved file
MATCH (p:Policy {id:$policy})
MATCH (e:Evaluation)-[:ASSESSED]->(p)
MATCH (e)-[:ON_SUITE]->(s:Suite)
WHERE s.role = 'validation' AND s.id IN $suites
AND e.artifact_hash = p.artifact_hash AND e.complete_suite = true
AND e.suite_fingerprint = s.fingerprint
RETURN p.id, p.artifact_hash, s.id, e.id, e.success_rate, e.collision_rate
The backend also checks the model and normalization hashes and reconciles the immutable episode records. Partial, custom, stale-hash or wrong-contract evidence cannot promote a checkpoint, and final-test outcomes never enter the planning graph.
5. A native FalkorDB algorithm as a tool
shortestPath is Cypher. I also wanted one of FalkorDB's own algorithm procedures behind an MCP tool, so I first checked what the pinned image actually ships rather than trusting the latest docs. On FalkorDB 6.0.2 (graph ver 60002), algo.SPpaths, algo.BFS, algo.pageRank, algo.WCC and a few others are registered. algo.allShortestPaths is not.
lineage_path(from_policy, to_policy) wraps it:
MATCH (source:Policy {id:$from_policy}), (target:Policy {id:$to_policy})
CALL algo.SPpaths({sourceNode:source, targetNode:target,
relTypes:['DERIVED_FROM'], weightProp:'weight', maxLen:8, pathCount:1})
YIELD path, pathWeight
RETURN [n IN nodes(path) | n.id] AS path, pathWeight
LIMIT 1
Both IDs are capped at 160 characters and resolved with a parameterized lookup first. An unknown ID returns unknown_policy and the algorithm never runs. A disconnected pair returns no_path_within_bound. Against the live graph, the v4 regression traces back as judge-6b48โฆ โ physics-โฆ25da9c โ robust-โฆ352fd7, pathWeight: 2. Every edge weighs 1, so that's lineage distance, not a safety score.
Part 1: the graph finds a checkpoint that passes
The first study (search-v1) used an authored, deterministic planner that ranks origins by failed-suite count, worst deficit, total deficit, then score. Every candidate got the same 180 validation episodes:
| Candidate | Target (success / collision) | Open | Static | Crossing | Gate |
|---|---|---|---|---|---|
| Limit | โค10% | โค5% | โค10% | โค15% | all four |
| Original scratch | 86.67% / 13.33% | 72.5% / 0% | 95% / 2.5% | 72.5% / 17.5% | โ 1/4 |
| Longer training | 91.67% / 8.33% | 82.5% / 0% | 92.5% / 5% | 85% / 10% | โ 3/4 |
| Rehearsal (graph-selected) | 95% / 5% | 100% / 0% | 97.5% / 2.5% | 85% / 15% | โ 4/4 |
Then came the honest part. The rehearsal lineage's one held-out target test failed: 13 collisions in 100 against a 10% limit. Passing validation isn't the same as passing.
So I ran a second, preregistered study (robustness-v2) with new confirmation seeds registered before training. FalkorDB pointed at the rehearsal parent, whose crossing validation had zero collision margin (exactly 15%). The trial kept cyclic rehearsal and doubled the training-only terminal collision penalty to โ20. It ran one seed (19), requested 600,000 steps and consumed 600,064, with no seed sweep.
The child passed 4/4 with the required clearance. Its artifact hash was frozen before the one fresh confirmation batch was opened:
| Fresh confirmation (100 per family) | Target | Open | Static | Crossing |
|---|---|---|---|---|
| Collision limit | 10% | 5% | 10% | 15% |
| Goals | 92 | 100 | 93 | 95 |
| Collisions | 8 | 0 | 4 | 5 |
| Timeouts | 0 | 0 | 3 | 0 |
Passed. robust-20261016140226-352fd7 is the shipped 2D policy.
Part 2: hand the decision to an LLM, over MCP
A deterministic planner is the safe baseline. Track 01 is about agents that act, so the next step was to let a model make the call: discover the tools over MCP, query the graph, choose an origin, and launch real training. All of this sat behind a guardrail that can veto a choice but never quietly swaps in its own.
The MCP server runs over stdio and Streamable HTTP. You can plug it straight into Claude Desktop or Cursor:
{"mcpServers":{"embodiweave":{"url":"https://embodiweave.52.202.25.181.sslip.io/mcp/"}}}
Public HTTP clients can read. Training needs separate consent on the webpage.
What followed was four registered attempts, and I'm showing all of them, because the sequence is the story.
v2: the agent picks, trains, and the graph says "no"
GPT-4.1 Mini made four MCP calls, read FalkorDB's shortestPath result, chose physics-20261016150004-25da9c, and launched 61,440 PPO steps. The child passed 4/4 suites across 180 episodes.
Then the parent comparison ran:
| Suite | Parent | v2 child |
|---|---|---|
| Target | 2/60 | 3/60 โฌ๏ธ |
| Crossing | 0/40 | 1/40 โฌ๏ธ |
It passed the gate and got worse than its parent, so EmbodiWeave didn't release it. The outcome went back into the graph: REGRESSED_AGAINST edges, plus an append-only DecisionOutcome linked to the original Decision with exact per-suite deltas. The loop closed.
v3: the agent ran out of room
The next preregistered attempt was meant to read those outcomes before choosing. It called both new graph tools and then stopped: tool results totalled 33,515 bytes, over the app's 32,000-byte request guard, before prompt overhead was even counted. That meant 0 PPO steps and 0 validation episodes. The receipt is kept byte-for-byte.
The fix was a proper context budget, not a bigger limit:
| Bound | Limit |
|---|---|
| Per tool summary | 2,000 bytes |
| Retained tool text | 12,000 bytes |
| History | 6 exchange groups |
| Whole request | 28,000 bytes |
Full records stay fetchable by ID, so nothing is lost, just summarised. The retry peaked at 17,926 bytes.
v3-retry: it read the warning and made the same choice
With context fixed, the agent made six MCP calls, read both outcome tools, and explicitly cited the v2 regression outcome in its pre-training Decision.
Then it chose the same origin anyway, and got the same regression: target 3/60 vs 2/60, crossing 1/40 vs 0/40. It passed the gate and was blocked by the parent comparison. Not released.
This was the most useful result of the week. Reading the evidence isn't the same as acting on it. The model could cite the outcome and still rank the same origin first, because nothing in its action space made the regression binding.
v4: make the graph enforce it
So I stopped asking the model to weigh the regression and made eligibility a graph rule. The new MCP tool rank_origins_excluding_regressed(target_policy, max_hops=8):
- verifies compatible contracts, checkpoint hashes and complete validation records,
- walks each origin's self-and-ancestor lineage up to 8 hops,
-
excludes any origin with a recorded regression on that lineage, either a
REGRESSED_AGAINSTedge or anot_released_regressionoutcome, - also excludes lineage it couldn't fully examine within the depth bound,
- ranks the survivors by failed-suite count, then collision clearance, then ID.
The core of it, running through GRAPH.RO_QUERY:
MATCH path=(origin)-[:DERIVED_FROM*0..8]->(ancestor:Policy)
WITH origin, failed_suites, collision_clearance, ancestor, length(path) AS hops
WHERE hops <= $max_hops
OPTIONAL MATCH (ancestor)-[reg:REGRESSED_AGAINST]-(other:Policy)
OPTIONAL MATCH (decision:Decision)-[:HAS_OUTCOME]->(outcome:DecisionOutcome)-[:ASSESSED]->(child:Policy)
WHERE outcome.release_verdict = $blocked_verdict
AND (child = ancestor OR (decision)-[:SELECTED]->(ancestor))
OPTIONAL MATCH (ancestor)-[:DERIVED_FROM]->(beyond:Policy)
WHERE hops = $max_hops
...
RETURN origin.id AS origin_id, failed_suites, collision_clearance, reasons,
unexamined_ancestors,
size(reasons) > 0 OR size(unexamined_ancestors) > 0 AS excluded
ORDER BY excluded, failed_suites, collision_clearance DESC, origin_id
Every exclusion comes back with its reason and the outcome ID that caused it. The guardrail re-runs the query right before training and can veto a stale proposal.
Result: the agent made seven MCP calls, cited the prior outcome, and the graph excluded physics-20261016150004-25da9c. The agent chose robust-20261016140226-352fd7, a different origin from v2 and v3-retry for the first time. It trained 61,440 steps and ran 180 unchanged validation episodes:
| Suite | Parent | v4 child | Limit | Gate |
|---|---|---|---|---|
| Target | 2/60 | 4/60 | 10% | Pass |
| Open | 0/40 | 0/40 | 5% | Pass |
| Static | 1/40 | 1/40 | 10% | Pass |
| Crossing | 0/40 | 1/40 | 15% | Pass |
Gate: 4/4. Parent comparison: not_released_regression. The confirmed 8/100 checkpoint stays deployed.
Does depth actually matter? Depth 1 vs depth 8, live
A guide on graph agents I read asks a blunt question: would the agent behave differently on a shallow lookup? So the live page now runs the same rank_origins_excluding_regressed query at depth 1 and depth 8, side by side.
Against the live graph today:
| Depth | Eligible | Excluded |
|---|---|---|
| 1 | 4 | 16 |
| 8 | 11 | 9 |
At depth 1, all 16 exclusions include lineage the query couldn't see yet, and the rule excludes that rather than calling it fine. Walking 8 hops clears 7 of them, because their deeper history has no recorded regression. It also surfaces regression paths longer than one hop for 5 origins, like the 3-node path above. No origin that was eligible at depth 1 gets excluded at depth 8. Each row shows the exclusion reason, the outcome ID and the actual policy path. The caption is computed from the returned rows, not hard-coded.
Checking the model's own words
v4's model wrote that its pick was the "safest" origin. Nothing in the graph supports that comparison. So before anything the model says reaches the page, it now goes through a deliberately dumb lexical check:
- any ID that isn't in the current tool results is rejected,
- comparison words (
safest,safer,safe,guaranteed,best) are rejected unless the same sentence carries a returned measurement, - improvement claims from an empty or fully excluded ranking are rejected.
A rejected sentence is replaced by a flat rendering of the graph rows, with a visible note. Here's the saved v4 rationale run through it:
{
"accepted": false,
"reasons": ["unsupported comparison"],
"text": "Origin robust-20261016140226-352fd7; failed suites 0.0; collision clearance 0.05; eligible in the returned ranking.",
"note": "model sentence rejected: unsupported comparison"
}
The original receipt is untouched. This is a conservative word filter, not proof that a claim is justified, but it means the model can't upgrade "eligible" to "safe" on its own.
Soโฆ did it work?
Yes, at exactly what it claims and nothing more:
- โ A graph rule changed eligibility, and that changed the agent's next decision. That's the Track 01 brief: an agent acting on connected data, with a decision you can trace back to specific edges.
- โ The same graph refused to ship a regression, three times, including once with an origin the model called "safest". That word isn't supported by the evidence, so the page now rejects it and shows the graph rows instead.
- โ It does not show the agent learned anything, or that its choices beat a baseline planner, or that the robot is safer. One seed, small studies, more training budget for later candidates. I'm not going to dress that up.
What I actually built is the harness that makes those claims checkable. Every decision is written before the outcome is known, and every outcome is written back where the next decision has to face it.
The physics reality check
The robot trains in a simple 2D simulator. To see whether that holds up, I took the unchanged 2D-trained weights and ran them in MuJoCo with torque limits, inertia, friction and real contacts:
In the first frozen transfer study (40 fresh seeds per suite, cells are goals / collisions / timeouts out of 40):
| Checkpoint | Open | Static | Crossing | Target |
|---|---|---|---|---|
Historical baseline P-static-b
|
40 / 0 / 0 | 16 / 12 / 12 | 30 / 10 / 0 | 7 / 24 / 9 |
| Original scratch | 35 / 0 / 5 | 37 / 1 / 2 | 30 / 9 / 1 | 34 / 4 / 2 |
| Graph-selected rehearsal | 40 / 0 / 0 | 37 / 3 / 0 | 32 / 8 / 0 | 37 / 3 / 0 |
A second, fresh study of the shipped robust-โฆ352fd7 checkpoint recorded collisions of 6/40 on target, 0/40 open, 1/40 static and 4/40 crossing.
The rehearsal lineage holds up far better than the old baseline (3/40 vs 24/40 on target), though it still collides in 8/40 crossing episodes. And the shipped checkpoint's 6/40 (15%) target collisions still fail the 10% limit in physics. FalkorDB stores these as their own Suite nodes (role='sim2sim', physics='mujoco'), so "which lineage survives a physics change?" is one query. A separate mixed-domain training study got closer (one trial was 1 episode short of the required clearance), but nothing has passed yet. No physical safety claim is made. Simulation success isn't hardware safety.
What I learned
- "The agent read the evidence" is not "the agent acted on it." v3-retry cited the regression and repeated the mistake. If a constraint matters, put it in the action space (a graph rule, a guardrail) and not just the prompt.
- Write the decision before you see the outcome. Storing the Cypher, parameters, candidates and choice before training made every result honest by construction.
-
Close the loop in the graph. Outcomes as
DecisionOutcomenodes andREGRESSED_AGAINSTedges turned past failures into something the next query has to traverse. - Context is a budget, so engineer it. The v3 failure wasn't the model's fault. Byte caps per tool, retained-text caps and fetch-by-ID fixed it without hiding anything.
-
Unknown isn't zero.
nullsupport, excluded unexamined lineage, and a held-out failure reported in the README: the system is most trustworthy where it admits what it doesn't know. - Preregister, then don't peek. Confirmation seeds were registered before training and opened once. That's the difference between "it passed" and "I found a run that passed".
Try it
- No account, no API key: open the evidence walkthrough and click Query this decision to see live FalkorDB rows and the recorded reasons.
-
Compare depths and trace lineage: the graph tools panel runs depth 1 vs 8, the native
algo.SPpathslineage tool and the v4 sentence check. All read-only, no model call. - Train one yourself: hit Start training on Train live. The graph picks the origin, the server trains, reloads the saved file, runs all 180 validation episodes and records the gate. Finishing training doesn't guarantee a pass.
-
Run it locally:
docker compose up --build, then openhttp://127.0.0.1:8000/live.html. It runs on CPU and needs no GPU and no model key for PPO. - Reproduce a result: every checkpoint bundle ships weights, normalization, contract, validation records, graph decisions, replays and per-file checksums.
Stack
FalkorDB (pinned by digest) ยท Python 3.13 ยท PyTorch CPU PPO ยท MCP Python SDK (stdio + Streamable HTTP) ยท GPT-4.1 Mini via OpenRouter for the agent runs ยท MuJoCo for sim-to-sim ยท FastAPI ยท live 2D/3D replay viewer ยท AWS Lightsail ยท GitHub Actions CI.
Honest limits
- Small studies with one fixed training seed each. Later candidates also had more training. No learning, planner advantage or safety improvement is established.
- The outcome citation in v3-retry and v4 was required by protocol and prompt, not something the model came up with.
- MuJoCo target collisions (6/40) still fail the reference limit.
- The sentence check is lexical. It catches invented IDs and unbacked comparisons, not every misleading claim.
- MCP is tested with the pinned SDK over both transports. The Claude Desktop and Cursor configs are documented but haven't been manually tested in those UIs.
- The public trainer is shared and quota-limited, and the free model provider can be busy. Recorded evidence always stays available.
AI disclosure
EmbodiWeave was my idea. Claude helped build the original simulator, graph tools, experiments and design. Codex audited and extended the implementation, ran the validation search, improved the browser workflow and prepared the evidence and docs. The MCP agent runs used GPT-4.1 Mini, and the older search studies are explicitly deterministic. Narration is Deepgram Aura-2 with Nova-3 captions. Robot motion is recorded simulator output. Every number in this post comes from saved PPO artifacts and measured episodes, and no result was invented to get a pass.
If you build agents that take real actions, I'd love to hear how you stop them from repeating a mistake they've already read about. โญ the repo if this was useful.

Top comments (0)