The idea: What if you could describe a 3D model in plain English and get actual CAD geometry out - not a mesh approximation, but real parametric B-rep solids you can export as STEP files for manufacturing?
Why This Exists
I've been designing parts in OpenSCAD and CadQuery for 3D printing. Both are programmatic - you write code instead of clicking in a GUI - which makes them a natural fit for LLM code generation. But the workflow is clunky: prompt an LLM in a chat window, copy the code, run it, look at the result, paste it back, ask for changes. Too much friction.
I wanted a tight loop: describe what you want, see it rendered, ask for changes, repeat. All in the terminal. So I pointed Claude Code at an empty directory and told it what to build.
What Claude Code Built
Over two sessions across two days (16 and 18 January), it produced CadQuery Agent - a terminal UI application that:
- Takes a natural language design request ("a phone stand with a 15-degree angle and cable cutout")
- Sends it to an LLM to generate CadQuery Python code
- Executes the code in a sandboxed subprocess
- Renders 4 orthographic views (front, right, top, isometric) using PyVista
- Displays the code and screenshots side-by-side in a Textual TUI
- Lets you request modifications - the LLM sees the current code, the rendered images, and your change request
- Auto-evaluates the result against all accumulated requirements using a vision model
About 5,000 lines of Python across 30 files. The initial implementation dropped in 2 hours. The remaining time was spent on evaluation, prompt tuning, UX polish, and tests.
The Key Design Decision: Stateless Context
The most important architectural choice was making each LLM request self-contained. No conversation history is passed between iterations. Instead, each request includes:
- The original design request
- The current CadQuery code
- Screenshots of the current model (4 views)
- The modification request
This prevents context degradation - where the model gradually loses track of requirements as the conversation grows. Every iteration gets a clean, complete picture of the current state. The LLM doesn't need to remember what happened three turns ago because it can see the code and the rendered output right now.
How the Pieces Fit Together
Sandboxed execution. LLM-generated code runs in a subprocess with AST validation. Only cadquery, math, and numpy imports are allowed. Dangerous builtins (eval, exec, open, __import__) are blocked. There's a timeout - if code takes more than 10 seconds, the LLM is automatically asked to optimise it.
Multi-view rendering. PyVista renders the model using OCC direct tessellation (not STL conversion) with topological edge extraction. This gives clean, accurate edges in the orthographic views - important because the vision model needs to be able to see what's actually there.
Auto-evaluation. After each iteration, a vision model examines the 4 rendered views and checks every requirement individually. It uses a structured format:
CHECKING REQUIREMENTS:
- REQUIREMENT 1 (plate with post): I see a rectangular plate with a cylinder -> MET
- REQUIREMENT 2 (ball on top): Cylinder has flat top, NO sphere visible -> NOT MET
VERDICT: FAIL
If evaluation fails, the system automatically sends the feedback back to the code generation LLM for a fix attempt. If the fix also fails, you're asked whether to continue trying or give up.
Provider agnostic. Supports Anthropic, OpenAI, Ollama, and any OpenAI-compatible API. I've been running it with Devstral Small via the Mistral API - a surprisingly capable small model for this task. Configuration lives in ~/.config/cadquery-agent/config.toml.
The Prompt Engineering
The system prompt is dense with CadQuery-specific guidance: selector syntax (>Z, |X, #Z), common patterns (holes, fillets, boolean operations, assemblies), and explicit pitfalls to avoid. When code errors occur, the error message is analysed to generate targeted hints - fillet radius too large, empty selector, boolean operation failure - which are injected into the fix prompt. This was refined over several iterations with Claude Code as I tested models that failed in predictable ways.
One subtle but important distinction in the prompt: when to use .union() (single fused body) vs cq.Assembly() (truly separate parts). Without this, LLMs tend to create assemblies for everything, which breaks rendering and export.
What I Actually Did
My contribution was the idea and direction. I described the architecture I wanted, pointed out when things weren't working (focus handling, dialog positioning, keyboard shortcuts not firing), and steered prompt improvements based on testing with real models. Claude Code wrote all the code - the TUI layout, the LLM client abstraction, the sandboxed executor, the renderer, the evaluation system, the test suite.
The commit history tells the story: 37 commits over two days, almost all Co-Authored-By: Claude. The progression from "initial implementation" to "add evaluation system" to "add stochastic retry mechanism for LLM integration tests" happened in a single afternoon.
What I Learned
Small models work - some of them. Devstral Small generates valid CadQuery code for most designs. GLM-4.6V (via Z.AI) was disappointing for its size - it struggled with CadQuery's API despite being a large model. The evaluation loop catches failures, so you don't need a frontier model for every request - just one that's good enough most of the time, with automatic retries.
Vision evaluation is the hard part. Getting a vision model to reliably check "is there a sphere on top of the cylinder" sounds easy. It's not. The evaluation prompt went through several rewrites to force per-requirement checking instead of a vague "looks good" pass. Even now, it occasionally misses things.
Testing LLM output needs a different approach. You can't write a deterministic test for code generation - the same prompt gives different output each run, and most APIs don't let you set a seed. So I added a @llm_stochastic decorator that runs each test multiple times and passes if enough attempts succeed (e.g. 2 out of 3). It reports all failure details if the threshold isn't met. This made the test suite actually useful instead of flaky.
Stateless beats stateful for iterative design. I initially considered maintaining a full conversation history. The stateless approach is simpler, more reliable, and costs fewer tokens. The model doesn't need context about failed attempts - it just needs to see what exists now and what should change.
Built with Claude Code. Source at poita66/CadQueryAgent.