AI assistant for buerli/ClassCAD applications. Adds a chat panel to your app that creates and modifies 3D geometry through natural language — connect any tool-calling LLM; all CAD operations execute locally in the browser. See it in action in buerligons, our open CAD modeler.
npm install @buerli.io/aiPeer dependencies: @buerli.io/classcad, @buerli.io/core, @react-three/fiber ≥8,
react ≥18, zustand ≥4.
The agent talks to any LLM through a small LLMProvider interface. Built-in providers:
| Provider | Use for |
|---|---|
createAnthropicProvider({ apiKey }) |
Claude — the Anthropic API or any compatible proxy |
createAutoProvider({ apiKey, endpoint }) |
OpenAI, Azure, or any OpenAI-compatible endpoint serving multiple models |
createOpenAIProvider({ endpoint, model }) |
A single Chat Completions endpoint — including local AI (Ollama, LM Studio, vLLM) |
your own LLMProvider |
anything else — one chat() method (see Custom integration) |
createAutoProvider is the most capable choice for OpenAI-style endpoints: it reads the
endpoint's /models to discover what's available, routes each model to the right API
surface (Responses vs Chat Completions), and learns per-model context windows and
thinking levels — which powers the panel's built-in model and thinking pickers.
(createResponsesProvider is also exported if you want to pin the Responses surface.)
createAnthropicProvider({ apiKey: ANTHROPIC_KEY })
createAutoProvider({ apiKey: OPENAI_KEY, endpoint: 'https://api.openai.com/v1', model: 'gpt-5.5' })
createOpenAIProvider({ apiKey: 'ollama', endpoint: 'http://localhost:11434/v1/chat/completions', model: 'qwen3' })Tool-use quality varies between models. Strong tool-calling models (GPT-5.x, Claude, Gemini) work best; small local models may not reliably follow the tool protocol.
import { createCadAgent, createAutoProvider, initAgentAsync } from '@buerli.io/ai'
import { useBuerli, BuerliGeometry } from '@buerli.io/react'
import { Canvas } from '@react-three/fiber'
await initAgentAsync() // once at startup — loads the bundled ClassCAD knowledge
const { AgentPanel, AgentCanvas } = createCadAgent({
provider: createAutoProvider({ apiKey: API_KEY, endpoint: API_ENDPOINT }),
// modelName, reasoningEffort — default model + thinking level
// maxTokens, contextLimit — fallbacks; per-model values are auto-discovered
// maxIterations: 25 — tool-loop cap
// systemPrompt / extraContext — see Custom prompts
})
function App() {
const drawingId = useBuerli((s) => s.drawing.active || '')
const [open, setOpen] = useState(true)
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<Canvas>
<AgentCanvas /> {/* invisible — lets the agent see the 3D view */}
<BuerliGeometry drawingId={drawingId} />
</Canvas>
<AgentPanel drawingId={drawingId} open={open} onClose={() => setOpen(false)} />
</div>
)
}<AgentPanel> props: drawingId (required), open, onClose,
position ('right' | 'left' | 'bottom'), className, theme
({ bg, text, accent, userBubble, assistantBubble, width }), and extraContext
(per-mount domain prompt — overrides the factory default when one agent serves
several screens).
What the panel gives you out of the box:
- Model picker, thinking picker, and context ring — capability-driven; each control appears only when the provider/model actually supports it.
- Attachments — images (vision) and CAD files (STEP/IGES/STL…) via the
+button. - Stop to abort a running turn; per-tool status chips with results and errors.
- Source panel (
</>in the header) — the session as a runnable, syntax-highlighted buerli script: runtime IDs threaded into variables, one-click copy.
The default system prompt makes the agent a general ClassCAD expert. Add your domain on
top with extraContext (recommended — keeps the base expertise), or replace the whole
prompt with systemPrompt:
createCadAgent({
provider,
extraContext: `## This app: parametric pipe runs
Segments are named Default, Pipe1, …; expressions: length, outerDiam, thickness.
Angles in radians (UI shows degrees). Always recalc after edits.`,
})Teach it your model's structure, naming, units, and the exact calls for common
operations — the more concrete, the fewer discovery turns the agent needs. When
replacing systemPrompt, you can compose with the exported DEFAULT_SYSTEM_PROMPT.
| Tool | Purpose |
|---|---|
call_api |
Any v1.<domain>.<method> ClassCAD call (also facade.* and drawing APIs) |
call_api_batch |
Many calls in one turn; later calls reference earlier results ("$0.id") |
tree / find / inspect |
Read the structure tree, search nodes, full node detail |
get_selection / set_selection |
Read or set the user's 3D selection |
list_methods / describe_method |
Discover and document the 254 API methods |
snapshot |
See the 3D viewport (PNG → vision) |
load_file |
Import a user-attached CAD file |
download |
Export STEP/STL/OFB as a download button in the chat |
delegate |
Hand a sub-task to a specialist sub-agent |
Everything executes in the browser against the buerli API — no extra server for CAD.
The ClassCAD knowledge (method registry + curated docs) ships via the @classcad/skill
dependency and loads with initAgentAsync(). (TOOL_SCHEMAS and executeTool are
exported for tests or custom executors.)
Never ship API keys in the browser. Point the provider at your backend and authenticate your users there:
// client
createAutoProvider({ apiKey: userSessionToken, endpoint: 'https://your-app.com/api/ai' })
// server: verify the user, attach the real provider key, forward the body verbatim.<AgentPanel> is a thin view over createAgentStore() — build your own chat UI on the
same store and keep the full agent (tools, batching, attachments, cancel):
import { createAgentStore, createAutoProvider, initAgentAsync } from '@buerli.io/ai'
import type { AgentConfig, UIMessage } from '@buerli.io/ai'
await initAgentAsync()
const useAgent = createAgentStore() // zustand — React hook AND vanilla store
const config: AgentConfig = {
provider: createAutoProvider({ apiKey: '…', endpoint: '…' }),
drawingId,
model: 'gpt-5.5', // optional per-message override
reasoningEffort: 'low', // optional
extraContext: MY_PROMPT, // optional
}
function MyPanel() {
const messages = useAgent((s) => s.messages) // UIMessage[]
const isRunning = useAgent((s) => s.isRunning)
const error = useAgent((s) => s.error)
const usage = useAgent((s) => s.usage) // { inputTokens, outputTokens }
const send = (text: string) => useAgent.getState().sendMessage(text, config)
// attachments: sendMessage(text, config, images?: ImageInput[], files?: FileAttachment[])
return (
<>
{messages.map((m: UIMessage, i) => {
if (m.type === 'assistant') return <Md key={i} text={m.text} />
if (m.type === 'thinking') return <Collapsed key={i} text={m.text} />
// m.type === 'tool': { name, label, status: 'running'|'done'|'error',
// detail?, image? (snapshot data-url), download? }
return <ToolChip key={i} {...m} />
})}
{error && <Error text={error} />}
<Input onSubmit={send} disabled={isRunning} />
{isRunning && <button onClick={() => useAgent.getState().stop()}>Stop</button>}
</>
)
}- Non-React: same store via
useAgent.getState()/useAgent.subscribe(). useAgent((s) => s.codeLog)(CodeEvent[]) holds the API-call log for a source view;reset()clears the conversation.
Snapshots without <AgentCanvas> — register a capturer once and the snapshot tool
works with any viewer:
import { setSnapshotCapturer, createCanvasCapturer } from '@buerli.io/ai'
setSnapshotCapturer(createCanvasCapturer(myCanvasElement)) // or a custom SnapshotCapturer → base64 PNGModel/thinking pickers — provider.getCapabilities() returns
{ models: ModelOption[] } (ids, context limits, reasoning levels); render your own
picker and pass the choice as config.model / config.reasoningEffort.
Fully headless — drive the raw event loop, no store, no React:
import { runAgentLoop } from '@buerli.io/ai'
for await (const ev of runAgentLoop('Create a 50mm box', [], config)) {
// ev.type: 'text' | 'thinking' | 'tool_start' | 'tool_end'
// | 'subagent_start' | 'subagent_end' | 'usage' | 'error' | 'done'
}Custom provider — one method; must support tool-use blocks. Optionally add
getCapabilities() to power the pickers:
const myProvider: LLMProvider = {
async chat({ system, messages, tools, max_tokens, model, reasoningEffort }) {
// call your LLM; return { content: ContentBlock[], stop_reason: 'end_turn' | 'tool_use' }
},
}Pure-Node ESM — initAgent({ skillBundle, methodRegistry }) is the synchronous init
variant; pass the JSONs yourself
(import bundle from '@classcad/skill/bundle.json' with { type: 'json' }).
If your team has a GitHub Copilot subscription, the package ships a small local proxy
(browsers can't call api.githubcopilot.com directly):
npx copilot-proxy auth # one-time GitHub device-flow login
npx copilot-proxy # run on http://localhost:8788Then createAutoProvider({ apiKey: 'copilot-proxy', endpoint: 'http://localhost:8788/v1' }).
The proxy stores a COPILOT_OAUTH_TOKEN in ./.env.local — that token is a server-side
secret: keep the file gitignored and never expose it to client code. This is a development
convenience, not a multi-user production gateway. (For Node contexts where you already
hold a Copilot session token, createCopilotProvider({ token }) calls the API directly.)
