Contents

OpenTopos — Architecture & Control Flow


Overview

OpenTopos turns a natural-language prompt into a fully-articulated 3D object — geometry code, URDF joints, textures, rendered views — without any manual Blender interaction.

The harness works through a plan-driven DAG. A plan.json declares tasks with explicit dependency edges. An orchestrator (Runner) executes them: LLM agents for creative work, deterministic Python tools for Blender renders, exports, and rubric evaluation. When the judge scores too low, a fix loop synthesizes targeted repair agents from judge feedback and re-runs only the affected tasks.

Code is truth — procedural Python is authoritative; all meshes, URDF, GLB, and renders are derivative artifacts, freely deletable and reproducible.

7architecture layers
3task kinds
3agent backends
~$2per object run
~5 minwall time
0.65+judge pass threshold

Inputs

  • Natural-language prompt
  • Optional reference images
  • Config (model, Blender path)

Agents Write

  • design.json — part contract
  • src/parts/*.py — geometry
  • src/build.py — assembly
  • src/joints.yaml — articulation

Tools Produce

  • artifacts/object.glb
  • artifacts/object.urdf
  • artifacts/view_*.png (8 views)
  • run_report.json

Layer Stack

Seven layers; each depends only on the ones below. New backends, tools, rubrics, and domain workflows extend leaf layers without touching the core orchestrator.

flowchart TD
  L7["CLI"]
  L6["Domain Workflows"]
  L5["Orchestrator"]
  L4["Critic"]
  L3["Knowledge / Skills"]
  L2["Tools"]
  L1["Agent Backends"]
  L0["Substrate"]

  L7 --> L6 --> L5
  L5 --> L4
  L5 --> L3
  L5 --> L2
  L5 --> L1
  L1 --> L0
  L2 --> L0
          
drag to pan · scroll to zoom
Fig 1. Seven-layer stack. L5 (Orchestrator) is closed for modification; all extension points are at the leaves.
LayerNameRoleExtend by
L7CLItopos make / run / doctor / cost / skillNew CLI subcommand
L6Domain Workflowsplan.json template + rubric per object typeNew domain dir + plan template
L5OrchestratorDAG execution, fix-loop, subgraph expansionClosed — core
L4CriticRubric evaluation via vision LLMNew Critic backend class
L3Knowledge / SkillsSKILL.md bundles injected as agent soft-hintsNew topos_*/SKILL.md file
L2ToolsDeterministic Python functions via @toolNew @tool function
L1Agent BackendsLLM CLI subprocess wrappersNew AgentBackend subclass
L0SubstrateWorkspace, Blender subprocess, configConfig overrides

topos make End-to-End Flow

The CLI bootstraps a workspace, generates a plan, and hands off to the Runner. topos run <slug> skips everything before Runner.

flowchart LR
  USER["user prompt\n+ images"] --> MAKE["cli.py\nmake()"]
  MAKE -->|"create"| WS["workspace\noutputs/slug/"]
  MAKE -->|"generate"| PLAN["plan.json"]
  PLAN --> RUN["Runner.run()"]
  RUN -->|"agent tasks"| SRC["src/\nPython source"]
  RUN -->|"tool tasks"| ART["artifacts/\nglb · urdf · png"]
  RUN --> REPORT["run_report.json"]
          
drag to pan · scroll to zoom
Fig 2. topos make top-level flow. The Runner is the only component that executes tasks; the CLI just wires the inputs.

Articulated plan — fixed task sequence

The generated plan.json for an articulated object uses this sequence. The subgraph fan-out is resolved at runtime after the design agent writes design.json.

01_agent_design → src/design.json (part contract) 02_subgraph_parts → expands at runtime into N per-part children: 02__10_agent_part_* → src/parts/PartName.py (parallel) 02__30_tool_judge_part_* → per-part visual judge (parallel) 02__40_tool_verify_parts → Blender import check 03_agent_build → src/build.py (assembly, blocks on subgraph) 04_agent_joints → src/joints.yaml (parallel to subgraph) 05_tool_render_multiview → artifacts/view_*.png (8 Blender views) 06_tool_export_glb → artifacts/object.glb 07_tool_export_urdf → artifacts/object.urdf 08_tool_judge → rubric evaluation (drives fix loop)

DAG Runner — Main Execution Loop

The Runner topo-sorts the task graph, dispatches ready tasks in parallel, and checks early-stop conditions after each complete iteration.

flowchart TD
  LOAD["load plan.json\ntopo-sort tasks"]
  READY["find ready tasks\n(deps all done)"]
  RUN["execute in parallel\nThreadPoolExecutor"]
  RECORD["record TaskResult\n(success, cost, output)"]
  SKIP["cascade-skip\ndownstreams"]
  MORE{"more tasks?"}
  CHECK{"early-stop?"}
  FIX["build fix agents\nfrom judge feedback"]
  REPORT["RunReport"]

  LOAD --> READY --> RUN --> RECORD
  RECORD -->|"task failed"| SKIP --> MORE
  RECORD -->|"task ok"| MORE
  MORE -->|"yes"| READY
  MORE -->|"no"| CHECK
  CHECK -->|"judge pass"| REPORT
  CHECK -->|"max iters"| REPORT
  CHECK -->|"regression"| REPORT
  CHECK -->|"saturation"| REPORT
  CHECK -->|"continue"| FIX --> READY
          
drag to pan · scroll to zoom
Fig 3. DAG runner main loop. Original AgentTasks are not re-run on fix iterations — only fix agents and stochastic tools re-execute.

iter_policy — early-stop rules

TriggerConditionDefault
judge_passAll judges report passedactive
max_global_itersHit iteration budget ceiling3
regressionAssembly score dropped vs previous iteralways on
saturationNo judge improved ≥ min_improvement0.05

Deterministic tools (export, verify) are skipped on fix iters when inputs haven't changed. Stochastic tools (judge, texture gen) always re-run.


Three Task Types

Every DAG node is one of three kinds. The Runner dispatches each kind differently.

AgentTask — "kind": "agent"

Spawns an LLM agent (claude/gemini/codex CLI) as a subprocess. The agent receives a goal prompt, skills, and write access to src/. It reads and writes files autonomously using its built-in file tools.

  • goal: inline text, file path, or Jinja2 template (topos:articulated/designer.md.j2)
  • skills: SKILL.md bundles listed as optional reads — agent picks what's relevant
  • allowed_tools: gates which file operations the agent may call
  • timeout_s: soft watchdog (idle grace) + hard ceiling
{ "id": "01_agent_design", "kind": "agent", "goal_template": "topos:articulated/designer.md.j2", "goal_params": { "prompt": "wooden drawer cabinet" }, "skills": ["topos_design_articulated"], "allowed_tools": ["Read", "Edit", "Write", "Glob"], "timeout_s": 600, "expected_outputs": ["src/design.json"] }

ToolTask — "kind": "tool"

Calls a registered Python function from the @tool registry. No LLM — pure deterministic or stochastic computation. Blender renders, exports, and rubric evaluation all run as ToolTasks.

  • tool: name registered via @tool decorator
  • deterministic=True: skipped on fix iters if inputs unchanged (export, verify)
  • deterministic=False: always re-runs (judge, texture gen)
{ "id": "08_tool_judge", "kind": "tool", "tool": "judge", "args": { "rubric": "articulated_object_v2", "image_pattern": "artifacts/object_render/view_*.png" }, "deps": ["05_tool_render_multiview"] }

SubgraphTask — "kind": "subgraph"

Runtime fan-out. After deps complete, reads a file and calls an expander strategy that emits child tasks with namespaced IDs (02__10_agent_part_frame). The subgraph completes when all children succeed. Part count is not known until design.json is read.

{ "id": "02_subgraph_parts", "kind": "subgraph", "expand_from": "src/design.json", "expansion_kind": "articulated_parts", "deps": ["01_agent_design"] }

SubgraphTask — Runtime Fan-out

The design agent decides how many parts exist. The subgraph expander reads that decision and materializes per-part agents dynamically. Independent part agents run in parallel and never communicate — they share only design.json.

flowchart TD
  DESIGN["01 design agent\nsrc/design.json"]
  SG["02 subgraph\nexpand from design.json"]

  P1["part agent — Frame\nsrc/parts/Frame.py"]
  P2["part agent — DrawerFront\nsrc/parts/DrawerFront.py"]
  P3["part agent — Handle\nsrc/parts/Handle.py"]

  J1["judge — Frame"]
  J2["judge — DrawerFront"]
  J3["judge — Handle"]

  VER["verify_parts\nBlender import check"]
  BUILD["03 build agent\nsrc/build.py"]
  JOINTS["04 joints agent\nsrc/joints.yaml"]

  DESIGN --> SG
  SG --> P1 & P2 & P3
  P1 --> J1
  P2 --> J2
  P3 --> J3
  J1 & J2 & J3 --> VER
  VER --> BUILD
  DESIGN -.->|"parallel"| JOINTS
          
drag to pan · scroll to zoom
Fig 4. Subgraph fan-out for a 3-part cabinet. Per-part agents run in parallel; build blocks until the entire subgraph (all children) succeeds. Per-part judges that pass are frozen across fix iterations.

design.json — coordination contract

The design agent writes this file. All subsequent agents read it; none write to it. The bbox field is enforced by build.py at a 5 mm tolerance — independent agents stay aligned without inter-agent communication.

{ "name": "Drawer Cabinet", "parts": [ { "name": "Frame", "description": "Main cabinet body — outer shell with shelf supports", "geometry_notes": "600 × 400 × 800 mm, 18 mm wall thickness", "bbox": { "x": 0.6, "y": 0.4, "z": 0.8 } }, { "name": "DrawerFront", "description": "Sliding drawer face panel", "bbox": { "x": 0.55, "y": 0.02, "z": 0.18 } } ], "assembly_notes": "Frame is root. DrawerFront slides along Z axis." }

Fix Loop — Iterative Repair

When the assembly judge scores below threshold, the fix loop synthesizes targeted repair agents from judge feedback. Only failing parts and downstream tasks re-run — not the whole pipeline.

flowchart LR
  JUDGE["judge\n(assembly)"]
  PASS{"passed?"}
  DONE["stop\njudge_pass ✓"]

  FIX["fix agent\nfeed judge feedback\nas goal prompt"]
  BUILD["build agent\nre-stitch assembly"]
  RENDER["render\n8 new views"]

  HALT{"regression or\nsaturation or\nmax iters?"}
  STOP2["halt"]

  JUDGE --> PASS
  PASS -->|"yes"| DONE
  PASS -->|"no"| FIX
  FIX --> BUILD --> RENDER --> JUDGE
  JUDGE --> HALT
  HALT -->|"yes"| STOP2
  HALT -->|"no"| PASS
          
drag to pan · scroll to zoom
Fig 5. Fix loop. The fix agent receives concrete per-criterion feedback — not a vague "make it better." Passing parts are sticky and frozen.

Fix prompt — concrete, not vague

The fix agent receives a rendered Jinja2 prompt (fix_loop.md.j2) with the judge's exact feedback and actionable steps:

## Judge feedback (iter 0, score 0.68 / threshold 0.70) geometry_accuracy (0.72): "Handles are 1 mm — too thin to see. Use ≥ 8 mm." material_realism (0.64): "Wood is flat uniform color, no grain visible." ## Suggested fixes: 1. Handle.py — change cylinder radius 0.001 → 0.008 2. DrawerFront.py — add wood-grain noise texture Work the judge's list. Read design.json once to orient. Edit ONLY the files the judge named. Preserve what is passing.

Agent Backend — ClaudeCLIBackend

AgentTasks run as CLI subprocesses. The backend manages subprocess lifecycle, stream-json parsing, watchdog timeouts, and retry logic. The agent never has API access — it only sees src/ via file tools.

flowchart LR
  TASK["AgentTask\ndispatched by Runner"]
  PROMPT["build prompt\nsystem base\n+ goal\n+ skills"]
  SPAWN["spawn subprocess\nclaude -p prompt\n--output-format stream-json\n--add-dir src/"]
  PARSE["parse stream-json\nassistant · tool_use\ntool_result · result"]
  WD{"idle >\ngrace_s?"}
  KILL["kill\ntimeout"]
  DONE["exit 0\ncompleted"]
  RETRY["retry?\nquota → backoff\ntimeout → once"]
  RESULT["AgentRunResult\nsuccess · cost · files_modified"]

  TASK --> PROMPT --> SPAWN --> PARSE
  PARSE --> WD
  WD -->|"no"| PARSE
  WD -->|"yes"| KILL --> RETRY --> SPAWN
  PARSE -->|"result event"| DONE --> RESULT
          
drag to pan · scroll to zoom
Fig 6. ClaudeCLIBackend subprocess lifecycle. rate_limit_event is excluded from the watchdog — quota waiting does not reset the idle clock.

Prompt layering — three sources

1 — System base

prompts/system/coding_agent_base.md

Framework rules for every agent: write only in src/, no from topos... imports, bbox contract rules.

2 — Task goal

From plan.json goal / goal_file / goal_template

Task-specific instruction. Designer gets designer.md.j2; fix agents get fix_loop.md.j2 with judge feedback injected.

3 — Skills (soft)

topos/skills/topos_*/SKILL.md

Capability bundles. Agent reads only those whose when_to_use matches the current task.

Blender — always stateless

Tools invoke Blender as a subprocess. Agents never call Blender directly — they only write Python source files that Blender later executes.

blender --background --python src/build.py -- [tool-args] # script starts with empty scene # writes outputs to artifacts/ (detected by mtime snapshot) # exits 0 on success, non-zero on error # results reproduce identically without a hot process pool

Judge / Critic Cycle

The judge ToolTask loads a rubric YAML, picks a pluggable Critic backend, and scores rendered images. The score and per-criterion feedback drive the fix loop.

flowchart LR
  TOOL["judge tool\nimage_pattern + rubric"]
  RUBRIC["load rubric YAML\ncriteria · weights\npass_threshold"]
  IMAGES["collect renders\nview_000…007.png"]
  CRITIC["critic backend\nclaude_vision\ngemini · openai"]
  SCORE["score per criterion\n0–1 + feedback text\n+ suggested_fixes"]
  AGG["weighted average\ncheck min_floors"]
  RESULT["CriticResult\npassed · overall_score\nsuggested_fixes"]

  TOOL --> RUBRIC & IMAGES
  RUBRIC & IMAGES --> CRITIC --> SCORE --> AGG --> RESULT
          
drag to pan · scroll to zoom
Fig 7. Judge evaluation pipeline. Critic backend is swappable per-rubric. Default (claude_vision) reads both rendered images and source code.

Rubric structure

id: articulated_object_v2 judge_backend: claude_vision pass_threshold: 0.70 # weighted avg must exceed this criteria: - id: geometry_accuracy prompt: "Does the geometry match the intended design?" weight: 1.5 min_floor: 0.50 # hard gate — avg won't save it below 0.50 - id: material_realism prompt: "Do textures look physically plausible?" weight: 1.0 - id: articulation_correctness prompt: "Do joints appear correctly modeled?" weight: 1.2 min_floor: 0.40

Data Flow — Files Between Stages

Every agent and tool communicates only through files. No direct agent-to-agent calls. The fix loop closes the cycle by feeding scores back to source files.

flowchart LR
  PROMPT["user prompt\n+ references"]
  DESIGN["src/design.json"]
  PARTS["src/parts/*.py"]
  BUILD["src/build.py"]
  JOINTS["src/joints.yaml"]
  RENDERS["artifacts/view_*.png"]
  GLB["artifacts/object.glb"]
  URDF["artifacts/object.urdf"]
  SCORES["judge scores\n+ suggested_fixes"]

  PROMPT -->|"01 design agent"| DESIGN
  DESIGN -->|"02 part agents"| PARTS
  DESIGN & PARTS -->|"03 build agent"| BUILD
  DESIGN -->|"04 joints agent"| JOINTS
  BUILD -->|"05 render"| RENDERS
  BUILD -->|"06 export"| GLB
  BUILD & JOINTS -->|"07 export"| URDF
  RENDERS -->|"08 judge"| SCORES
  SCORES -->|"fix loop"| PARTS
  SCORES -->|"fix loop"| BUILD
          
drag to pan · scroll to zoom
Fig 8. File-level data flow. All inter-agent coordination happens through files — no sockets, no shared memory. The fix loop closes the cycle.

Workspace Layout

Every run writes into one slug-named directory under outputs/. No dated subdirs, no extra top-level folders.

outputs/wooden_drawer_cabinet/ ├── plan.json ← original plan (immutable) ├── plan.expanded.json ← post-subgraph snapshot (debug) ├── run_report.json ← verdict: costs, scores, history │ ├── src/ ← agents write here (authoritative source) │ ├── design.json ← 01_agent_design │ ├── build.py ← 03_agent_build │ ├── joints.yaml ← 04_agent_joints │ └── parts/ │ ├── Frame.py ← 02__10_agent_part_frame │ ├── DrawerFront.py ← 02__11_agent_part_drawer_front │ └── Handle.py ← 02__12_agent_part_handle │ ├── artifacts/ ← tools write here (deletable) │ ├── object.glb │ ├── object.urdf │ └── object_render/ │ ├── view_000.png ← 8 views from render_multiview │ └── … view_007.png │ ├── prompts/ │ ├── intent.md ← from topos make prompt │ └── references/*.png ← reference images │ └── trajectories/ ← full per-task audit trail (gitignored) ├── 01_agent_design_iter0/ │ ├── prompt.txt │ ├── transcript.jsonl ← raw stream-json events │ └── result.json └── 08_tool_judge_iter1/ └── output.json ← per-criterion scores + feedback

Source Directory Reference

Path ▲ Role Extend by
topos/cli.pyEntry point — topos make/run/doctor/cost/skillNew CLI subcommand
topos/orchestrator/runner.pyDAG execution, fix-loop dispatch, early-stopClosed — core
topos/orchestrator/plan_schema.pyplan.json Pydantic validation + topo-sortClosed — core
topos/orchestrator/plan_generator.pyGenerates articulated plan.json from promptAdd domain generator
topos/orchestrator/expand.pySubgraph expander strategies (ADR-0008)Register new expander
topos/orchestrator/fix_loop.pyBuilds FIX agent tasks from judge feedbackClosed — core
topos/backends/claude_cli.pyClaudeCLI subprocess, stream-json, watchdog, retryNew AgentBackend subclass
topos/tools/registry.py@tool decorator + ToolSpec lookupAdd @tool function
topos/tools/judge.pyJudge ToolTask: loads rubric, invokes CriticNew rubric YAML
topos/tools/_blender_subprocess.pyStateless blender --background runnerClosed — substrate
topos/agents/visual_critic/Critic protocol + backends (claude, gemini, openai)New Critic subclass
topos/prompts/system/Base system prompts — all agentsEdit with care
topos/prompts/articulated/Domain Jinja2 goal templatesAdd domain prompt dir
topos/skills/topos_*/SKILL.md capability bundles — agent soft-hintsNew SKILL.md dir
topos/rubrics/Rubric YAML (criteria, weights, thresholds)Add rubric YAML
examples/Example workspaces with plan.json + per-example promptsAdd example dir

Click any column header to sort.

ADR Index

ADRDecision
0001Code-as-truth — Python source authoritative; 3D artifacts are derivative
0002Stateless Blender — blender --background per build; hot pool is cache only
0003Claude CLI backend — agents run as subprocesses, not API calls
0004Recipe injection — domain knowledge via SKILL.md, not hard-coded prompts
0005Modeling vs rendering — agents write geometry code; tools invoke Blender
0006design.json bbox contract — independent agents coordinate via shared spec file
0007Three-layer prompts — system / domain / per-example layering
0008Subgraph runtime expansion — part count resolved from design.json at runtime