Nova3D — Architecture & Control Flow


Code-native 3D generation. The pipeline writes Blender Python, executes it server-side, and returns a structured GLB with named, separately addressable parts — not a fused mesh blob. This document maps the full control flow across all three clients.

Open-Source Boundary, Billing, and BYOK

The repo open-sources the clients and agent harness. The hosted GraphFlow v2 backend that turns prompts into Blender Python, runs Blender, repairs/validates the output, and stores artifacts is not open-source in this repo.

QuestionCurrent answer from the codebase
What is open-source? The MCP server, Flutter/web client, Three.js viewer, Blender plugin, auth/session glue, workflow submission, polling, result parsing, and conversation/history linking.
What is closed-source? The hosted Nova3D GraphFlow v2 backend at nova3d.xyz/api: prompt assembly, LLM code-generation orchestration, server-side Blender execution, validation/repair loops, and artifact production.
What request crosses the boundary? Initial generation posts an authenticated JSON body to POST /run/state/sketch_to_3d_v2?request_id=state-... with prompt, code_llm_profile, code_llm_tier, optional image_artifact, and requested return_nodes. The client then polls GET /status/{workflow_id} and reads GET /result/{workflow_id}.
Is the hosted backend free? No. MCP initial generation is gated by Nova3D sign-in, readiness, and account credits. The web client currently lists paid initial-generation tiers such as Gemini (12 credits), Claude Sonnet (15), Claude Opus (25), and GPT-5.5 (28).
Does Nova3D use my LLM API key? For MCP initial generation, no: it routes to Nova3D-managed paid tiers and does not send user provider keys. In the web app, BYOK provider keys are used for edit operations such as regenerate, add part, and articulate; those requests can send the provider key to the Nova3D backend. In both cases, the LLM writes Blender Python, not raw mesh triangles.

Contents

System Overview

3clients
5MCP tools
4backend workflows
3spoll interval

Nova3D has a closed-source backend (GraphFlow v2) and three open-source clients. All three clients share the same REST API contract: submit a workflow, poll for status, fetch the result. The backend is model-agnostic: MCP initial generation routes to Nova3D-managed paid tiers, while selected web/edit flows can use user-provided provider keys.

flowchart TD
  User(("User"))
  FlutterApp["Flutter Web App\napp.nova3d.xyz"]
  MCPServer["MCP Server\nnova3d-mcp"]
  BlenderPlugin["Blender Plugin"]
  Backend["Nova3D Backend\nGraphFlow v2\nnova3d.xyz/api"]
  BlenderEngine["Server-side Blender\nexecutes generated Python"]
  LLM["LLM Provider\nGemini · Claude · GPT"]
  GLBStore["Artifact Storage\nGLB + code + joints"]

  User -->|"chat prompt"| FlutterApp
  User -->|"tool call"| MCPServer
  User -->|"panel UI"| BlenderPlugin

  FlutterApp -->|"REST /run/state"| Backend
  MCPServer -->|"REST /run/state"| Backend
  BlenderPlugin -->|"REST /run/state"| Backend

  Backend --> LLM
  LLM -->|"Blender Python script"| Backend
  Backend --> BlenderEngine
  BlenderEngine -->|"GLB file"| GLBStore
  GLBStore -->|"glb_url"| Backend
  Backend -->|"result"| FlutterApp
  Backend -->|"result"| MCPServer
  Backend -->|"result"| BlenderPlugin
          
drag to pan · scroll to zoom
Fig 1. High-level system topology. All clients share the same backend API; the backend owns the LLM calls and Blender execution.

The asset's source of truth is the Blender Python construction program. The GLB is its compiled output. This is why every generated object is born inspectable, addressable, editable, and animatable.


Repo Structure

app/

Flutter/Dart web client. Chat UI, 3D viewer (Three.js in a web worker), CAD service HTTP layer, Riverpod state management, conversation local cache + remote sync.

mcp/

Python MCP server (FastMCP). Exposes generate_3d, regenerate_part, add_part, articulate_model, get_generation_status as MCP tools. Handles browser OAuth loopback auth. Runs as uvx nova3d-mcp.

blender-plugin/

Blender Python add-on. Non-blocking modal operator + background thread. Generates, downloads, and imports GLB directly into Blender scene. Manages project folders + UV atlases.

LayerTechnologyKey Files
MCP serverPython · FastMCP · httpxmcp/nova3d_mcp/server.py · client.py · auth.py · loopback.py
Flutter app — stateDart · Riverpodchat_provider.dart · cad_provider.dart · billing_provider.dart
Flutter app — dataDart · Diocad_service.dart · chat_service.dart · conversation_repository.dart
Flutter app — 3D viewerJavaScript · Three.jsapp/web/nova3d/ (scene.js · model.js · uvMaps.js · sculpt.js)
Blender plugin — UIPython · bpyoperators/generate.py · ui/panel.py
Blender plugin — workerPython · threadingservices/generation.py · api/client.py · services/uv_maps.py
Backend (closed-source)GraphFlow v2hosted at nova3d.xyz/api

MCP Authentication Flow

The preferred auth path is a browser OAuth loopback. No user-visible tokens are needed. The MCP server stores the session credential in ~/.local/state/nova3d/mcp-session.json (chmod 600). A manual NOVA3D_TOKEN env var is supported as a fallback for headless environments.

sequenceDiagram
  participant Agent as MCP Agent
  participant Server as MCP Server
  participant Browser as Browser
  participant API as Nova3D API

  Agent->>Server: nova3d_login tool call
  Server->>Server: bind TCP :0 (random port)
  Server->>Server: generate CSRF state token
  Server->>Browser: webbrowser.open(/mcp/connect?state=&port=)
  Browser->>API: user completes sign-in
  API->>Browser: redirect to localhost:{port}?code=&state=
  Browser->>Server: HTTP GET localhost:{port}?code=&state=
  Server->>Server: validate state token
  Server->>API: POST /mcp/session/exchange {code}
  API->>Server: {token, expires_at}
  Server->>Server: save to mcp-session.json (chmod 600)
  Server->>API: GET /mcp/status
  API->>Server: {authenticated, credits, generation_ready}
  Server->>Agent: return readiness payload
              
drag to pan · scroll to zoom
Fig 2a. Browser loopback auth. The MCP server acts as its own OAuth redirect handler on a randomly bound port.

The nova3d_login tool first waits 1 second for the browser to complete (common if the browser is already logged in). If it times out, it returns a login_pending_confirmation payload; the agent then calls nova3d_status which checks the background task result. This is a deferred-result pattern to avoid blocking the MCP call.

claude mcp add nova3d -e NOVA3D_TOKEN=n3d_your-key -- uvx nova3d-mcp

Token resolution order: (1) SessionStore file · (2) NOVA3D_TOKEN env var. If neither is present, tools return a structured error directing the agent to run nova3d_login.

Auth State Machine

stateDiagram-v2
  [*] --> Unauthenticated
  Unauthenticated --> PendingLogin : nova3d_login called
  PendingLogin --> Authenticated : browser callback received + code exchanged
  PendingLogin --> Unauthenticated : timeout or state mismatch
  Authenticated --> ReadyToGenerate : credits funded
  Authenticated --> NeedsCredits : credits empty
  NeedsCredits --> ReadyToGenerate : user purchases credits
  ReadyToGenerate --> Unauthenticated : token expired / nova3d_logout
          
drag to pan · scroll to zoom
Fig 2b. MCP auth state transitions. Every generation tool gate-checks ReadyToGenerate before proceeding.

Generate 3D — Main Tool Loop

The generate_3d tool is the entry point for all new assets. It orchestrates: readiness gate → conversation creation → workflow submission → polling loop → result parsing → history persistence. The code_artifact returned is the key passed to all subsequent edit tools.

flowchart TD
  Call["generate_3d(prompt, model, image?)"]
  Gate{"credits ready?\nGET /mcp/status"}
  Fail1["return {failed: true}"]
  Conv["POST /conversations\nget conversation_id"]
  Submit["POST /run/state/sketch_to_3d_v2\n{prompt, code_llm_tier, return_nodes, conversation_id}"]
  Poll{"GET /status/{workflow_id}\nevery 3s"}
  Progress["report_progress(node)"]
  Budget{"budget_exhausted?"}
  FetchResult["GET /result/{workflow_id}"]
  Parse["GenerationResult.from_api()\nextract glb_url · parts · code_artifact"]
  History["persist conversation history\nPATCH snapshot + POST messages + POST workflow-links"]
  EmbedMeta["embed _nova3d_conversation_id\ninto code_artifact"]
  Return["return {\n  glb_url, parts, code_artifact,\n  conversation_url, workflow_id\n}"]
  Fail2["return {failed: true, error_message}"]

  Call --> Gate
  Gate -->|"no"| Fail1
  Gate -->|"yes"| Conv
  Conv --> Submit
  Submit --> Poll
  Poll -->|"running"| Progress
  Progress --> Poll
  Poll -->|"budget_exhausted"| Budget
  Budget --> Fail2
  Poll -->|"terminal"| FetchResult
  FetchResult --> Parse
  Parse -->|"failed"| Fail2
  Parse -->|"success"| EmbedMeta
  EmbedMeta --> History
  History --> Return
          
drag to pan · scroll to zoom
Fig 3. The generate_3d tool call loop. The polling loop (every 3s) is the core blocking pattern shared by all workflow tools.

Polling Loop Detail

The _poll_and_collect method in client.py is the universal polling engine used by all four workflow methods. It handles three distinct error classes:

Error classBehaviorExamples
RecoverableRetry silently, continue polling404 (not registered yet), 502/503/504, timeout
Budget exhaustedRaise immediately, no retrybudget_exhausted workflow state
Auth / hard errorRaise immediately, surface to caller401, invalid token, non-recoverable

After the status poll reaches terminal state, a second retry loop fetches the result — because even a terminal workflow may return a transient 404 before the result record is written.


Backend Pipeline — Server-Side Node Graph

The backend executes a named node graph (GraphFlow v2). The MCP client specifies return_nodes — the terminal node names it wants results from. Status polling tracks current_node and last_exit_node from the node visit sequence.

flowchart TD
  Start(["workflow start"])

  subgraph Image["Image path (optional)"]
    caption_prompt["caption_prompt"]
    caption_llm["caption_llm\nunderstand image"]
  end

  generation_prompt["generation_prompt\nbuild LLM prompt"]
  code_generation_llm["code_generation_llm\nLLM writes Blender Python"]
  run_blender["run_blender\nexecute script → GLB"]
  repair_llm["repair_llm\nLLM fixes broken script"]

  subgraph Validation["Validation pass"]
    capture_validation_screenshots["capture screenshots"]
    validation_prompt["validation_prompt"]
    validation_llm["validation_llm\nreview model"]
    validation_correction_blender["apply corrections"]
  end

  T1(["final_validated_correction ✓"])
  T2(["final_latest_valid ✓"])
  T3(["fail_generation ✗"])

  Start -->|"has image"| caption_prompt
  caption_prompt --> caption_llm
  caption_llm --> generation_prompt
  Start -->|"text only"| generation_prompt
  generation_prompt --> code_generation_llm
  code_generation_llm --> run_blender
  run_blender -->|"script error"| repair_llm
  repair_llm --> run_blender
  run_blender -->|"all retries fail"| T3
  run_blender -->|"GLB produced"| capture_validation_screenshots
  capture_validation_screenshots --> validation_prompt
  validation_prompt --> validation_llm
  validation_llm -->|"pass"| T1
  validation_llm -->|"minor fix"| validation_correction_blender
  validation_correction_blender --> T1
  validation_llm -->|"best effort"| T2
          
drag to pan · scroll to zoom
Fig 4. Backend node graph for sketch_to_3d_v2. The repair loop (run_blender → repair_llm → run_blender) handles LLM-generated Blender scripts that fail to execute. The validation pass captures screenshots and reviews them with a second LLM call.

The code_artifact in the result is the Blender Python script that produced the GLB. Part names are extracted from it via regex (obj.name = "part_name") when the backend doesn't return an explicit parts list. This script is the state carried between all subsequent edit operations.

Node Progress Labels (visible in MCP progress reports)

NodeProgress label shown
caption_llmUnderstanding the reference image…
code_generation_llmWriting the Blender scene…
run_blenderBuilding and exporting the 3D model…
repair_llmRepairing the Blender script…
validation_llmReviewing the generated model…
final_validated_correctionFinalizing the corrected model…
fail_generationGeneration failed.

Edit Tool Loops

The three edit tools — regenerate_part, add_part, articulate_model — all follow the same pattern. The code_artifact is both the input (carries current scene state) and the output (updated for the next call). This chaining is what links edits together without re-running the full generation.

flowchart TD
  EditCall["edit tool call\n(code_artifact, description)"]
  ExtractConv["extract _nova3d_conversation_id\nfrom code_artifact"]
  Submit["POST /run/state/{workflow}\n{code_artifact, description, llm, provider}"]
  Poll{"GET /status/{workflow_id}\nevery 3s"}
  FetchResult["GET /result/{workflow_id}"]
  UpdateArtifact["embed conversation_id\nback into new code_artifact"]
  PersistEdit["POST /conversations/{id}/messages\nPOST /conversations/{id}/workflow-links"]
  Return["return {\n  glb_url, parts,\n  updated_code_artifact,\n  conversation_url\n}"]

  EditCall --> ExtractConv
  ExtractConv --> Submit
  Submit --> Poll
  Poll -->|"running"| Poll
  Poll -->|"terminal"| FetchResult
  FetchResult --> UpdateArtifact
  UpdateArtifact --> PersistEdit
  PersistEdit --> Return
          
drag to pan · scroll to zoom
Fig 5. Shared control flow for all three edit tools. The code_artifact travels as a stateful opaque blob; the server interprets it as the current scene program.

Workflow differences between edit tools

ToolWorkflow endpointKey extra inputsTerminal node
regenerate_part/run/state/regenerate_3d_partpart_type (name to replace)regenerate_3d_part
add_part/run/state/add_3d_partdescription onlyadd_3d_part
articulate_model/run/state/articulate_3d_modelmodel_url or model_artifact, selected_meshes?articulate_3d_model

The articulate_model tool also accepts model_url or model_artifact (the GLB reference), because the articulation backend reads the existing mesh geometry to determine valid joint pivot positions rather than re-generating from scratch.

Code Artifact Chaining

The code artifact is an opaque dict containing the Blender Python source plus two MCP-side metadata keys the server embeds:

{ // backend-provided fields (Blender Python script, metadata) "content": "import bpy\n...", "format": "blender_python", ... // MCP-embedded fields (client-side only) "_nova3d_conversation_id": "conv_abc123", "_nova3d_prompt": "a washing machine with drum and door" }

The conversation ID is what links all edits to the same session in the web app. The prompt is carried forward so edit history messages can reference the original generation context.


Conversation History & Linking

Every generation and edit is mirrored into a Nova3D conversation so it appears in the web app at app.nova3d.xyz/chat/{conversation_id}. History persistence is best-effort — a failure never blocks the generation result from being returned.

flowchart LR
  Gen["generate_3d\nsucceeds"]
  Conv["POST /conversations\n{title, source: mcp}"]
  Snapshot["PATCH /conversations/{id}\n{conversation_metadata: {nova3d_chat_snapshot}}"]
  AppendUser["POST /conversations/{id}/messages\nuser message (prompt text)"]
  AppendAsst["POST /conversations/{id}/messages\nassistant message (model_url, code_artifact, joints)"]
  LinkWF["POST /conversations/{id}/workflow-links\n{workflow_id, message_id, relation_type}"]
  WebApp["web app /chat/{id}\nshows full generation history"]

  Gen --> Conv
  Conv --> Snapshot
  Snapshot --> AppendUser
  AppendUser --> AppendAsst
  AppendAsst --> LinkWF
  LinkWF --> WebApp
          
drag to pan · scroll to zoom
Fig 6. Conversation history persistence. The snapshot PATCH writes the full Flutter-app-compatible chat JSON so the web app hydrates correctly. Individual messages are also appended so the remote API can paginate them.

Edit tools only append a single assistant message (no user turn) and skip the snapshot PATCH — they just extend the existing conversation thread.


Blender Plugin Flow

The Blender plugin is a separate client with the same backend API contract but a different threading model. Blender's Python must never block the main thread, so the plugin uses a worker thread + event queue + modal timer pattern. Only Blender-API calls (bpy) happen on the main thread.

flowchart TD
  Panel["Blender 3D View Panel\nnova3d_prompt, model selector"]
  Operator["NOVA3D_OT_generate\n(main thread invoke)"]
  EncodeImages["encode reference images\nbpy → base64 data URLs"]
  Params["GenerationParams\n(immutable, assembled on main thread)"]
  Thread["GenerationJob.start()\ndaemon thread"]
  Timer["wm.event_timer_add(0.25s)\nstart modal"]

  subgraph Background ["Background thread (GenerationJob)"]
    Readiness["GET /readiness\ncheck service up"]
    Credits["GET /balance\ncheck credit budget"]
    Session["POST /conversations\ncreate session"]
    Submit["POST /run/state/sketch_to_3d_v2\nstart workflow"]
    PollBG{"GET /status/{id}\nevery poll interval"}
    FetchBG["GET /result/{id}"]
    Download["download GLB + code.py\nto project folder"]
    WriteMeta["write meta.json\n(durable project record)"]
    QueueDone["queue.put('done', {glb_path, code_text})"]
    UVMaps["generate UV maps\n(separate workflow loop)"]
    QueueFinished["queue.put('finished')"]
  end

  subgraph MainThread ["Main thread (modal tick every 0.25s)"]
    DrainQueue["drain queue\n(queue.get_nowait)"]
    ImportGLB["bpy: import GLB\ncreate text datablock"]
    RefreshUI["tag_redraw\nupdate status line"]
    Teardown["remove timer\nwm.nova3d_running = False"]
  end

  Panel -->|"nova3d.generate"| Operator
  Operator --> EncodeImages
  EncodeImages --> Params
  Params --> Thread
  Params --> Timer
  Thread --> Readiness
  Readiness --> Credits
  Credits --> Session
  Session --> Submit
  Submit --> PollBG
  PollBG -->|"queue.put('status')"| DrainQueue
  PollBG --> PollBG
  PollBG -->|"terminal"| FetchBG
  FetchBG --> Download
  Download --> WriteMeta
  WriteMeta --> QueueDone
  QueueDone -->|"'done' event"| ImportGLB
  ImportGLB --> UVMaps
  UVMaps --> QueueFinished
  QueueFinished --> Teardown
  Timer --> DrainQueue
  DrainQueue --> RefreshUI
          
drag to pan · scroll to zoom
Fig 7. Blender plugin control flow. The main thread only handles bpy calls; everything else runs on the daemon thread and communicates via a thread-safe queue.

Event Protocol

Event kindPayloadHandler action
statusstrUpdate wm.nova3d_status, redraw
workflowworkflow_id strStore in wm.nova3d_workflow_id
done{glb_path, code_text, …}Import GLB, load code text datablock (main thread bpy)
uv_statusstrUpdate wm.nova3d_uv_status
uv_done{sets: [{atlas_count}]}Show atlas count in UI
errorstrself.report(ERROR), finish modal
finishedNoneRemove timer, refresh credits, return FINISHED

Flutter App Flow

The Flutter app uses Riverpod for state. The key loop is in chat_provider.dart's MessagesNotifier, which manages an optimistic UI: it shows a streaming placeholder immediately, then polls the backend, and finally patches the message with the real result.

flowchart TD
  ChatInput["User types prompt\nor attaches image"]
  GenerationDraft["GenerationDraftsNotifier\nstore request keyed by conversationId"]
  Navigate["navigate to /chat/{conversationId}"]
  Seed["MessagesNotifier.seed()\noptimistic placeholder message"]
  SendGen["sendGeneration(request)"]
  CreditCheck["ensurePaidCreditBudget\nGET estimate + GET wallet"]
  StartWF["CadService.startGeneration()\nPOST /run/state/sketch_to_3d_v2"]
  RunWorkflow["runWorkflow(workflowId)\npolls GET /status every few seconds"]
  Upsert["_upsert(message)\nupdate state + persist local cache"]
  Sync["persistMessagesSnapshot\nPATCH /conversations/{id}"]
  FinalMsg["final message with modelUrl\nstops isStreaming"]
  Viewer["3D viewer renders GLB\nuvMaps.js applies UV atlases"]

  ChatInput --> GenerationDraft
  GenerationDraft --> Navigate
  Navigate --> Seed
  Seed --> SendGen
  SendGen --> CreditCheck
  CreditCheck -->|"insufficient"| FinalMsg
  CreditCheck -->|"ok"| StartWF
  StartWF --> RunWorkflow
  RunWorkflow -->|"status update"| Upsert
  Upsert --> Sync
  RunWorkflow -->|"completed"| FinalMsg
  FinalMsg --> Viewer
          
drag to pan · scroll to zoom
Fig 8. Flutter app generation flow. The optimistic seed → streaming placeholder → final message pattern is what makes the chat UI feel instant even though generation takes 30–120 seconds.

On page reload, _resumeActiveGenerations scans for messages that have a workflowId but no modelUrl — these were in-flight when the page closed. It re-attaches to the workflow via polling, recovering the result without requiring the user to restart.


MCP Tool Reference

ToolBackend workflowKey inputsKey outputsMutates code_artifact?
nova3d_setup none instructions text no
nova3d_login none (loopback OAuth) authenticated status + credits no
nova3d_status GET /mcp/status authenticated, generation_ready, credits, next_action no
nova3d_logout none (local only) cleared_local_session no
generate_3d sketch_to_3d_v2 prompt, model, image_base64? glb_url, parts, code_artifact, conversation_url, workflow_id creates new
regenerate_part regenerate_3d_part code_artifact, part_type, description, model glb_url, parts, updated code_artifact, conversation_url yes
add_part add_3d_part code_artifact, description, model glb_url, parts, updated code_artifact, conversation_url yes
articulate_model articulate_3d_model code_artifact, articulation_request, model_url or model_artifact, model glb_url, joints, joint_count, updated code_artifact, conversation_url yes
get_generation_status GET /status/{workflow_id} workflow_id state, is_terminal, progress_label, current_node no

Model Routing

The model parameter in generation tools selects the LLM tier. The server resolves provider credentials — the client never sends API keys.

model paramProviderLLM
gemini (default)GoogleGemini 3.1 Pro
claude-sonnetAnthropicClaude Sonnet 4.6
claude-opusAnthropicClaude Opus 4.8
gpt-5.5OpenAI (via OpenRouter)GPT-5.5
WorkflowState enum values
pending → running → completed | budget_exhausted | failed | terminated | unknown "succeeded" / "success" → maps to completed "cancelled" / "timed_out" → maps to terminated Terminal states: completed, budget_exhausted, failed, terminated

Nova3D architecture report · generated 2026-06-29