Agentic Arch bridge agent
Connects the FreeCAD and Blender already installed on an architect's machine to
Agentic Arch running in the cloud, so the AI can build real geometry there
instead of generating a picture of it.
Architect's machine Cloud (Cloud Run)
┌────────────────────────────┐ ┌──────────────────────────┐
│ FreeCAD GUI ── XMLRPC :9875│ │ NestJS + Vertex (Gemini) │
│ Blender GUI ── TCP :9876│ │ BridgeGateway (wss) │
│ ▲ │ │ device registry │
│ │ │ wss │ job dispatch │
│ archxflow-bridge run ──────────┼────────────►│ chat tool loop │
│ (outbound only) │ ticketed │ │
└────────────────────────────┘ └──────────────────────────┘
The agent dials out. Nothing listens on the architect's machine, so there is
no port to forward, no firewall rule, and no inbound surface — which is also why
this works from a laptop on hotel wifi.
Install and pair
Published as @archxflow/bridge. It needs Node 22 or newer and has no
runtime dependencies of its own.
npm install -g @archxflow/bridge
In the web app, generate a pairing code, then on the machine with FreeCAD:
archxflow-bridge pair ABCD-EFGH --url https://your-cloud
archxflow-bridge run
Before it is published, build it from this repository instead — the commands
are the same with node dist/index.js in place of archxflow-bridge:
cd agent
npm install
npm run build
pair swaps the code for a long-lived device token and stores it in the
platform's own per-user config directory with 0600 permissions. The cloud
keeps only a SHA-256 of it, so a leaked server database cannot be replayed.
Check what the agent can see at any time:
archxflow-bridge status
Approval — what runs without asking, and what does not
The cloud model can write arbitrary Python into FreeCAD and Blender. That is the
point of the bridge, and it is also remote code execution on someone's
workstation, so the two categories are treated differently:
freecad.build, freecad.sheets, freecad.presentation, blender.render, all the *.status/screenshot/scene reads | Run unattended. The Python is the platform's own, bundled with this package. The cloud picks which script runs, never what is in it. |
freecad.exec, blender.exec | Prompt. The source is printed in full and someone on the machine answers y / a (allow this verb for the rest of the run) / N. |
────────────────────────────────────────────────────────────────────────
Agentic Arch wants to run Python inside FreeCAD
Why: measure the clear width of the stair well
────────────────────────────────────────────────────────────────────────
│ import FreeCAD
│ doc = FreeCAD.ActiveDocument
│ print(doc.getObject('Stairwell').Shape.BoundBox)
────────────────────────────────────────────────────────────────────────
[y] run once [a] always allow this verb [N] decline :
--bypass turns the prompt off:
archxflow-bridge run --bypass
Do that only where you trust the whole pipeline into the model. A prompt
injection in an uploaded bye-law PDF or a survey drawing reaches the same tool
the architect does, and with --bypass there is nothing between it and this
machine. The agent announces the mode at startup and reports it to the cloud, so
the web app can show which machines are unattended.
With no TTY attached (a service, a container) and no --bypass, code jobs are
declined rather than silently run.
GUI or headless
Each driver prefers a running GUI and falls back to a headless process:
freecad.build | yes | FreeCADCmd |
freecad.sheets | required | — TechDraw builds view geometry on the Qt event loop |
freecad.presentation | required | — viewport captures need a window |
blender.render | yes | blender --background |
A job that needs a GUI fails with that reason rather than quietly producing an
empty sheet.
Where files go
Jobs run in the agent's work directory, laid out the way build.py expects
(it derives the project root from its own path):
<config dir>/work/cad/freecad/*.py ← bundled from the repo at build time
<config dir>/work/cad/out/ ← model.json in, everything else out
Whatever a job produces is uploaded to the cloud and attached to the job, up to
64 MB per file and 256 MB per job. Anything larger is left on disk and reported.
Configuration
agent.json in the config directory, or flags, or environment:
--url, AGENTIC_ARCH_URL | Cloud base URL |
AGENTIC_ARCH_TOKEN | Device token, for a container that cannot pair interactively |
--bypass | Run code without prompting |
--workDir | Where builds happen |
--freecadCmd, FREECAD_CMD | FreeCADCmd path, if it is somewhere unusual |
--blenderCmd, BLENDER_CMD | Blender path |
Config directory: %APPDATA%\AgenticArch · ~/Library/Application Support/AgenticArch ·
$XDG_CONFIG_HOME/agentic-arch.
Revoking a machine
Revoke in the web app (DELETE /api/bridge/devices/:id). The record is
tombstoned and any live socket is closed immediately, so a stolen laptop stops
being able to run jobs the moment the button is pressed, not at the end of some
token lifetime. archxflow-bridge unpair only clears the local copy — it does not
tell the cloud to stop trusting it.
Protocol
src/protocol.ts is a verbatim copy of backend/src/bridge/bridge.protocol.ts
and is refreshed by npm run build. It is type-only and dependency-free so the
same file compiles on both sides.
The agent has no runtime dependencies — Node 22's native WebSocket,
fetch and FormData, a hand-rolled XML-RPC client for FreeCAD (about forty
lines, because the addon accepts six methods), and a raw TCP client for
Blender's unframed JSON.
Pairing from the web app
Settings → Machines in the studio (/settings/machines) does the cloud half
of this: it mints the pairing code, shows the three commands with the code and
your cloud URL already filled in, and lists every paired machine with what is
running on it. The panel closes itself the moment a machine pairs against the
code, so there is no "did that work?" step.
The same page is where a machine is revoked. Revoking closes its live socket
immediately — archxflow-bridge unpair only clears the local copy of the token and
does not tell the cloud to stop trusting it.
Nothing is held back
Beyond the named build verbs, the bridge passes the whole surface of both
applications through, so the cloud can do anything the architect's own MCP
client can:
agent.capabilities | What both apps expose right now, with a read-only flag per method |
freecad.rpc | Any FreeCAD RPC method — create_object, edit_object, delete_object, insert_part_from_library, run_fem_analysis, execute_code, and the rest |
blender.command | Any Blender addon command, including the optional Polyhaven, Sketchfab, PolyPizza and Hyper3D/Hunyuan handlers when that machine has them enabled |
Blender's list is discovered, not hardcoded: agent.capabilities asks the
addon itself, so handlers that are switched off do not appear and a new addon
release does not need an agent release.
FreeCAD arguments are encoded as real XML-RPC values, so structs and arrays
round-trip — create_object(doc, {Name, Type, Properties: {...}}) works, which
is what makes the passthrough genuinely complete rather than string-only.
Approval is per-call, not per-verb
Exposing everything does not mean asking for nothing. Because one verb now
covers both list_documents and execute_code, the gate looks at the payload:
Reads — get_*, search_*, poll_*, list_documents, ping | run unattended |
Everything else — creating or editing objects, deleting, FEM runs, asset downloads and generation, execute_code | prompts, showing the method name and its arguments |
"Always allow" is scoped to the method, not the verb — approving
freecad.rpc:get_objects for the session does not silently approve
freecad.rpc:execute_code. --bypass still skips the lot.
Who may call the bridge
Every bridge route is guarded, and the guard defaults to the strictest class —
a route added later without a decorator refuses rather than inheriting an open
door.
| agent | POST /bridge/session, POST /bridge/artifacts/:jobId | The device token. An agent has no operator credential and never needs one. |
| pairing | POST /bridge/pair | None — the code is the credential: single use, ten minutes, ~2.8e11 combinations. Rate-limited to 10 failures per address per 10 minutes. |
| operator | everything else — pairing-codes, devices, jobs, artifact download | See below. |
The WebSocket upgrade is not a route: it is authenticated by the single-use
ticket, which is itself minted only from a valid device token.
Configuring the operator class
There are no user accounts yet, so this cannot check who — only that the
caller got past something. It fails closed: an unconfigured production
deployment refuses, rather than serving POST /bridge/pairing-codes to the open
internet. Pick one:
BRIDGE_ADMIN_TOKEN=… | Callers send it as Authorization: Bearer …. |
BRIDGE_TRUSTED_PROXY=true | Accept a request carrying an IAP identity header. Only set this when the service cannot be reached except through IAP — the header is trivially forged by anyone who can hit the container directly, so this is opt-in rather than assumed. |
BRIDGE_ALLOW_UNAUTHENTICATED=true | No check at all. Logs a warning at startup. For a private network you control. |
In development (NODE_ENV is not production) loopback is allowed, so the
local two-server setup keeps working with nothing configured. That exemption is
never granted in production.
A note on IAP and the agent
If IAP fronts the whole service, it rejects the agent's Authorization: Bearer aab_… at the edge, before any of this runs — the agent would never connect.
Reaching a deployment like that needs the agent paths exposed outside IAP (a
second service, or an IAP path exclusion), which is infrastructure rather than
code.
What ships in the package
dist/ the compiled agent — one file, no dependencies
scripts/ build.py, sheets.py, presentation.py, copied from cad/freecad at build time
scripts/ is not optional. jobs.ts loads it from ../scripts at runtime, so a
package built without it installs cleanly and then fails every build job with
"build.py is not bundled with this agent". Both directories are listed in
files; check with npm pack --dry-run before publishing.
Neither directory is committed — npm run build regenerates both, and prepack
runs it so npm publish cannot ship a stale one.