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.
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.
| Question | Current 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
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
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.
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.
| Layer | Technology | Key Files |
|---|---|---|
| MCP server | Python · FastMCP · httpx | mcp/nova3d_mcp/server.py · client.py · auth.py · loopback.py |
| Flutter app — state | Dart · Riverpod | chat_provider.dart · cad_provider.dart · billing_provider.dart |
| Flutter app — data | Dart · Dio | cad_service.dart · chat_service.dart · conversation_repository.dart |
| Flutter app — 3D viewer | JavaScript · Three.js | app/web/nova3d/ (scene.js · model.js · uvMaps.js · sculpt.js) |
| Blender plugin — UI | Python · bpy | operators/generate.py · ui/panel.py |
| Blender plugin — worker | Python · threading | services/generation.py · api/client.py · services/uv_maps.py |
| Backend (closed-source) | GraphFlow v2 | hosted at nova3d.xyz/api |
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
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.
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.
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
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
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 class | Behavior | Examples |
|---|---|---|
| Recoverable | Retry silently, continue polling | 404 (not registered yet), 502/503/504, timeout |
| Budget exhausted | Raise immediately, no retry | budget_exhausted workflow state |
| Auth / hard error | Raise immediately, surface to caller | 401, 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.
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
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 label shown |
|---|---|
| caption_llm | Understanding the reference image… |
| code_generation_llm | Writing the Blender scene… |
| run_blender | Building and exporting the 3D model… |
| repair_llm | Repairing the Blender script… |
| validation_llm | Reviewing the generated model… |
| final_validated_correction | Finalizing the corrected model… |
| fail_generation | Generation failed. |
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
| Tool | Workflow endpoint | Key extra inputs | Terminal node |
|---|---|---|---|
| regenerate_part | /run/state/regenerate_3d_part | part_type (name to replace) | regenerate_3d_part |
| add_part | /run/state/add_3d_part | description only | add_3d_part |
| articulate_model | /run/state/articulate_3d_model | model_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.
The code artifact is an opaque dict containing the Blender Python source plus two MCP-side metadata keys the server embeds:
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.
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
Edit tools only append a single assistant message (no user turn) and skip the snapshot PATCH — they just extend the existing conversation thread.
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
| Event kind | Payload | Handler action |
|---|---|---|
| status | str | Update wm.nova3d_status, redraw |
| workflow | workflow_id str | Store in wm.nova3d_workflow_id |
| done | {glb_path, code_text, …} | Import GLB, load code text datablock (main thread bpy) |
| uv_status | str | Update wm.nova3d_uv_status |
| uv_done | {sets: [{atlas_count}]} | Show atlas count in UI |
| error | str | self.report(ERROR), finish modal |
| finished | None | Remove timer, refresh credits, return FINISHED |
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
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.
| Tool | Backend workflow | Key inputs | Key outputs | Mutates 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 |
The model parameter in generation tools selects the LLM tier. The server resolves provider credentials — the client never sends API keys.
| model param | Provider | LLM |
|---|---|---|
| gemini (default) | Gemini 3.1 Pro | |
| claude-sonnet | Anthropic | Claude Sonnet 4.6 |
| claude-opus | Anthropic | Claude Opus 4.8 |
| gpt-5.5 | OpenAI (via OpenRouter) | GPT-5.5 |
Nova3D architecture report · generated 2026-06-29