# Mutiny BIM: I modelled a scan-sized room in it, exported the IFC 4.3, and raced two editors on one wall

> Satyajit Ghana — Head of Engineering @ Inkers Technology
> canonical: https://ai.satyajit.si/articles/mutiny-bim
> date: 2026-11-12
> tags: bim, mcp, agents, cad, point-cloud, 3d

My [roundup of 3D and BIM tools for agents](/articles/agent-3d-bim-tools) read Mutiny BIM from its source and placed it on a capture-to-IFC pipeline. It explained the commit rule step by step, the trade-word ratchet in the kernel and the 250k performance budgets. I did not run any of it then. This piece is the run.

The question I care about is narrow. I turn 360 and fisheye walks into point clouds, and point clouds into building models. If a scan-to-BIM step hands Mutiny a room as elements, what does the kernel store, what reaches the IFC file a reviewer opens in another tool, and what happens when a person and an agent both touch the same wall? There was no capture behind this run. I made up one room with the dimensions and fields a fitting stage would hand over, modelled it the way a scan would describe it, landed it through the MCP tool server with an approval, exported it, read the export with IfcOpenShell, and raced editors against the commit server until something gave.

Something did. Wall moves are protected exactly as the docs say. Wall thickness is not, and by the code, neither are element parameters.

## What I ran, and on what

[Mutiny BIM](https://github.com/permitzip/mutinybim) is Apache-2.0, maintained by PermitZIP, who [announced the repository](https://x.com/PermitZIPhvac/status/2119870226120519687) on 9 November and [posted a demo video](https://x.com/PermitZIPhvac/status/2120659880650637442) two days later. I used commit `94513ba` (11 November), the same one the roundup read.

<RepoCard repo="permitzip/mutinybim" />

Everything ran in a throwaway `node:22-bookworm` container on a shared 16-core Linux server, capped at 3 CPUs and 4 GB, with its ports bound to localhost and reached from my Mac over an SSH tunnel. A headless Chromium on the Mac drove the editor for the screenshots and clips. IfcOpenShell 0.9.0 ran locally in a scratch virtualenv. When I finished, the container, its image and the work directory were removed.

The install is unremarkable in the good way. `npm ci` took 13.5 s and left 578 MB of `node_modules`. `npm run demo` built the browser editor in 5,489 ms (10.64 MB of JavaScript in 584 files, by its own log line) and served an in-memory model on port 3095. No database, no sign-in. The full stack with Postgres exists as a `docker compose` file, but it binds ports that were taken on that host, and the dev server runs the same commit code, so I stayed on the dev server.

I ran three of the test paths that matter here, `packages/bim-ifc`, `packages/bim-sync` and the kernel's top-level `__tests__`: 68 suites and 305 tests passed, one of each skipped, in 62.7 s on two workers. The repository has 849 test files; I did not run the rest.

## A wall is a record

Start from what the kernel stores, because everything else (the editor, the MCP tools, the IFC writer, the commit server) is a reader or writer of these records.

A model is `ModelState`: a revision number, a map of elements by id, the connections between them and a few settings. Here is one wall from my run, exactly as the kernel wrote it to `scan-room.model.json`:

```json
{
  "id": "wall-c4231333641e772e-1", "kind": "wall", "version": 1, "authoredRev": {},
  "start": { "x": 0, "y": 0 }, "end": { "x": 6240, "y": 0 },
  "thickness": 230, "height": 3000, "wallType": "Wall",
  "placement": { "kind": "line", "levelId": "level-57969c4ac4423c11",
                 "lateral": "center", "vertical": "bottom", "startOffsetMm": 0, "endOffsetMm": 0 },
  "classId": "arch.wall", "classSource": "recognised",
  "partition": { "level": "Scan L1", "zone": "Z1", "discipline": "architecture" },
  "schemaVersion": 2
}
```

Three of those fields do the work in what follows.

`version` counts how many committed changes have touched this element. `authoredRev` maps a field name to the revision in which a person or agent last wrote it on purpose. After my races, the walls I moved carried `{"start": 23, "end": 23}`, and the ones whose thickness I changed carried nothing for thickness at all. Hold that thought; it decides the race results.

`partition` places the element in a level, a zone and a discipline, so a client can load only the areas it needs. `classId` is how a trade add-on recognises it: `arch.wall` comes from the architecture pack, not from the kernel.

**Units.** Lengths are stored in millimetres as plain numbers, and every parameter names a quantity kind. `packages/bim-units` defines ten base dimensions: the seven SI ones plus plane angle, counted items and currency, which "physics calls dimensionless but a model must not mix". On top of those sit 53 quantity kinds and 151 unit codes. The point of kinds is in the source comment: drainage fixture units and supply fixture units are both dimensionless, and inches of water and psi are both pressure, "but a model never adds supply fixture units to drainage fixture units; kinds make that a type error and a refused formula". For anyone who has merged a survey in metres with a design in feet and inches, that is the right instinct.

It also leaks. I asked for a supply duct in metric (600 cfm, 200 mm deep) and the mechanical pack's sizing read answered with a 406.4 by 200 mm duct at 686 fpm: 406.4 mm is 16 inches, picked from an imperial size list. The preview described it as "+13 ft of 16"x7⅞" duct". The arithmetic holds: 600 cfm through 16 by 7.87 inches (0.875 ft²) is 685.8 fpm, the velocity the read reported. The kernel is unit-clean; the trade catalogues are American.

**Types and parameters.** An element can name a type, and a value on the element overrides the type's. The roundup's pack explorer shows that resolution with three records side by side, so I will not repeat it.

**Networks.** Ducts, pipes and conduit are straight `run_segment` records joined by `run_fitting` records, and each end holds a link to what it meets. My duct's first segment ends with `"links": {"end": {"elementId": "run-…-f1", "port": "a"}}`, and the elbow's port `a` links back to that segment's `end`. Flow is a derived value a network adapter walks and totals; my run had no air terminal, so the segment says `"flow": 0`. Derived values recompute on every commit and never conflict.

**Spaces.** Nobody draws a room outline. The script gives one point inside, and the kernel traces the enclosure from the walls. My space came back with its outline on the walls' inner faces, `(115, 115)` to `(6125, 4705)`, and the four walls that bound it. Its area, 6.01 m by 4.59 m, is 27.5859 m², which is the `NetFloorArea` the IFC file carries to the fourth decimal. That trace-from-a-seed behaviour is exactly what a scan pipeline wants: give it walls and a point per room and it builds the spaces.

**Phases and groups.** Every element is in a group (General, New work, Demolition) and is created in a phase, optionally demolished in a later one. A phase status is one of new, existing, demolished or temporary. There is also a lock with the state `existing`: the kernel's summary reads "Locked … as existing conditions", and a locked element refuses edits. For scan-to-BIM this is the most useful field in the kernel. A scanned wall is existing construction, and the people designing against it should not be able to drag it by accident.

**Sheets.** Views, sheets, viewports, tags and schedules are model records too, so they sync, conflict and undo like walls. They come out as vector PDF through `packages/bim-sheets`. They do not go into IFC: the exporter's `NOT_EXPORTED_KINDS` lists `view`, `sheet`, `viewport`, `tag`, `dimension` and a few more.

**Commits.** A change is an intent command, a small JSON object such as `move_wall` with a `wallId` and a `delta`. A script's output is one `batch_edits` of up to 500 steps, one revision and one undo. The server re-runs every command on its own head and decides. That is `planCommit()` in `packages/bim-kernel/src/changeset.ts`, and the races below are about what it compares.

## The add-on contract, from a scan pipeline's side

The kernel's stance is that trades are add-ons. A pack is a manifest plus contributions to versioned extension points; the contract is at version 1.13.0, and `KNOWN_POINTS` in `packages/bim-pack/src/points.ts` lists 92 of them. The ones a scan-to-BIM pack would touch are few:

- `kernel.class`, for a class such as `scan.as_built_wall` with its IFC mapping.
- `kernel.parameter_set`, for a deviation-from-cloud parameter, with a unit kind (length) and an `ifc: { pset, property }` home so the exporter writes it with no exporter change.
- `lint.rule`, for "this wall sits more than 20 mm off the cloud". Element rules run only on what an edit touched, and a rule lists the other kinds it reads so it re-runs when they change.
- `kernel.ifc_import_rule`, to map classes coming in from an IFC that another tool produced.

That is a design I would write, not something in the repository; no pack reads a point cloud today. The contract allows it without kernel edits, and the loading rules are sensible for a pipeline that hands models between teams: a model pins the exact versions of its packs, a host missing one reports it by name and keeps the records, and a host installs a data add-on only after a person approves that exact version, while code add-ons are refused by default. What I could not check is the claim of no kernel edits for a new trade: the demo server loads only the first-party packs, so a pack of mine would run only through the test harness.

## A scan-shaped room, written as a script

A scan pipeline does not click in an editor. It emits geometry: wall lines with thicknesses, a floor outline, openings at measured positions. In Mutiny that output is a script. This is the one I ran, for a 6.24 by 4.82 m room with 230 mm walls:

```ts
import { defineScript, units, building, routes } from "@permitzip/bim-script";

const mm = units.mm;
const at = (x: number, y: number) => ({ x: mm(x), y: mm(y) });

export default defineScript({
  kind: "route",
  reads: { levels: true },
  parameters: {},
  run(ctx) {
    const level = building.level(ctx, { name: "Scan L1", elevation: mm(0) });
    const corners = [at(0, 0), at(6240, 0), at(6240, 4820), at(0, 4820)];
    const walls = building.walls(ctx, { level, points: corners, closed: true, thickness: mm(230), height: mm(3000) });
    building.floor(ctx, { level, points: corners, thickness: mm(150) });
    building.door(ctx, { wall: walls.element(1), along: mm(900), width: mm(900), mark: "D-01" });
    building.window(ctx, { wall: walls.element(3), along: mm(2000), width: mm(1500), height: mm(1200) });
    building.spaceInWalls(ctx, { level, inside: at(3000, 2400), number: "S-01", name: "Scanned room" });
    const advice = ctx.read("mechanical.duct_size", { cfm: 600, shape: "rect", height_mm: 200, system_kind: "supply_air" });
    const { width, height } = advice.suggested.size;
    routes.along(ctx, { level, domain: "duct", size: routes.rect(mm(width), mm(height)),
      system: { id: "sa-scan", kind: "supply_air", name: "Supply air" }, height: mm(2700),
      points: [at(500, 2400), at(3000, 2400), at(3000, 4000)] });
  },
});
```

(Trimmed: the file I ran also takes an origin offset, types the sizing read, and writes a label and a report.)

It worked on the first try, which I did not expect from a repository that went public three days earlier. Through `npm run example`, which runs a script the way an agent does against a fresh model, it made 7 edit steps and 13 records: 4 walls, 2 openings, 2 duct segments, an elbow, a slab, a space, a level and the supply-air system, applied in one approval in 2.0 s. The preview's quantities check out against the geometry: "+73 ft of walls" is the 22.12 m centreline perimeter, "+324 ft² of floors and slabs" is 6.24 by 4.82 m, "+297 ft² of spaces" is the 27.5859 m² net area, and "+13 ft" of duct is the 2.5 m and 1.6 m legs.

Defaults filled the rest. I gave the door no height and it got 2,134 mm, which is seven feet. I gave the window no sill and it sits at 900 mm. A scan pipeline should pass every measured dimension explicitly, or the model will carry American defaults where the cloud said otherwise.

<Figure src="https://media.thesatyajit.com/ai/articles/mutiny-bim/fig1.png?v=9c0a876731b8891d" alt="Mutiny BIM's plan view in a browser: the demo's rooftop unit and duct trunk at the top, and below it the scripted room with four grey walls, a door marked D-01 at the bottom left, a window in the top wall, and a blue supply duct running in and turning up through a radius elbow." caption="My scripted room on the dev server's plan view, after the agent's change landed, below the demo model's own trunk duct. Screenshot from my run, headless Chromium." />

## The agent loop, measured

The MCP server is the part of Mutiny meant for agents, and its design is easy to state: the agent writes a script, the server previews it, a person approves, and only what was previewed lands. I ran it as `mutinybim-mcp --http --model-server ws://127.0.0.1:3095/sync --model f-a-demo`, so that whatever the agent landed appeared live in the browser editor, and wrote a small client with the official MCP TypeScript SDK that played the agent's part.

The tool list is small on purpose. The core server lists 10 tools: the nine the docs describe plus `bim_enable_pack`, which turns on a tool family on request. Its tool list serialises to 17,758 bytes of JSON. Started with `--packs all`, the same server lists 168 tools in 332,540 bytes. An agent that starts with the core and learns the scripting kit from `bim_script_reference` carries about a nineteenth of the tool schema in its context.

One run of my client against the live model:

| call | time | what came back |
|---|---|---|
| `bim_script_reference` | 704 ms first, 5 ms after | workflow, the kit's functions, four examples |
| `bim_model_summary` | 26 ms | revision 1, 13 elements, RTU-1 at 650 cfm |
| `bim_check_output` | 48 ms | no findings, a `script_id` |
| `bim_run_script` | 657 ms | `previewed`: 13 creates, the quantities, an `output_hash` |
| `bim_apply_output` | 610 ms refused, 637 to 802 ms saved | asks for approval, then `saved` as revision 2 |
| `bim_query_elements`, `bim_validation_issues` | 7 to 8 ms | all 26 records with their facts (my `kind` filter was ignored); 0 issues |

The approval goes through MCP elicitation: the server asks the client to show a person a form. The message my client received was "MutinyBIM wants to land "Scanned room S-01 with a 406.4x200 mm supply duct at 686 fpm" in model f-a-demo as one undo step: 13 new, 0 changed, 0 deleted", with a required boolean `approve`.

My first client answered with an accept and an empty form. The server answered `not_approved`, "Elicitation response content does not match requested schema", and the model stayed at revision 1. That was my bug, but it is a fair test of the gate: a client that does not say yes does not land anything. With `approve: true` it saved as revision 2, and the editor showed the room arrive with a "Local agent saved revision 3" toast on the second room I landed the same way.

<Video src="https://ai.satyajit.si/articles/mutiny-bim/clip3" poster="https://media.thesatyajit.com/ai/articles/mutiny-bim/clip3-poster.jpg?v=5df7d628766578c1" alt="Mutiny BIM's plan view with the demo trunk duct at the top and one scripted room below. Partway through, a second identical room appears to the right and a toast reads Local agent saved revision 3." caption="A second room, landed by my MCP client after approval, appearing in an open browser session in real time. Screen recording from my run, headless Chromium." />

`bim_apply_output` takes the preview's `apply_with.args` unchanged, including the `output_hash` and the `base_revision`. Landing re-runs the script and lands only output that hashes to what was previewed; the code answers `stale_preview` when the model moved in between. I did not trigger that path. Scripts run in a fresh Node process with the permission model on, an empty environment, 1 GB and 30 s caps; the docs say plainly that Node cannot deny a process the network, so this runner is for your own scripts on your own machine.

## Two editors, one wall

Here is the claim to test. The architecture doc says: "No last-write-wins on intent. If two people set the same authored field differently, the later one gets a conflict and decides." The commit server re-runs each command on the current head and, for each field the command records as authored, refuses it if another session wrote that field since the command's base revision to a different value.

To race it I used the sync client the docs show for programs, `SyncClient` from `packages/bim-sync`, twice in one Node process: two sockets, two actors, each its own session. Each race connects both, waits for both to see the same head, then submits one command from each side in the same tick, so both requests carry the same `baseRevision`. The first to reach the sequencer commits; the second is decided against a head that has moved. The outcomes came back in 9 to 50 ms, but that is loopback inside one container and says nothing about network latency.

<RaceLedger />

Five races behave as documented. Two people moving the same wall get "Priya changed wall-…-4 start while you were editing" for whoever arrives second. An agent, with an actor id of the form `agent:<user>:<run>`, gets the same refusal in the same words. Priya in two tabs is refused by Priya, because each tab is its own session. A delete of a wall someone just edited is refused with "check it before deleting". A move and a thickness change on the same wall both commit, because they are different fields.

The move with the same offset surprised me until I read why. Both sides sent `move_wall` with `delta: { x: 50 }`. The second is re-run on a head where the wall has already moved 50, so it writes the wall at 100, which differs from what Priya wrote, and the same-value exemption never applies. Intent commands that are deltas cannot match each other. That is defensible; an absolute "set start to (x, y)" would have committed.

The thickness race is the finding. Priya set a wall to 300 mm and Avery set it to 250 mm, on the same base. Both committed, as revisions 15 and 16. Afterwards the wall was 250 mm, its `version` had gone up twice, and its `authoredRev` had no entry for thickness. Neither of them was told anything.

The code says why. In `packages/bim-kernel/src/elements/commands.ts`, the element-core commands (`set_element_class`, `set_element_type`, `set_element_values`, `set_element_placement` and `set_element_thickness`) share one exit:

```ts
return finish(reader, working, touched, [], () => [], { summary: [summary] });
```

The fourth argument is the list of authored writes, and it is empty. `planCommit()` only compares fields that are in that list, so for these five commands there is nothing to conflict on, and the policy degrades to last write wins. `move_wall`, by contrast, pushes `start` and `end` (and the ends of joined walls it drags along). The part-parameter command `set_parameters` records `values.<name>` and is protected. What I measured is thickness. `set_element_values` shares that exit line, so I expect the same result, but I did not race it; `set_parameters` I only read.

For a scan-to-BIM handover this matters more than it would for a drafter. The work an agent does after a scan is exactly element-core work: set a type, set a class, fill a parameter with `set_element_values`, correct a thickness the fit got wrong. Those are the edits where two agents, or an agent and a person, can overwrite each other without a word. The delete rule still protects the element as a whole, because it looks at which elements changed rather than which fields were authored, so the failure is a silent overwrite, not a lost wall.

It looks like an oversight in one switch statement rather than a design decision, and the fix is small: push `{ elementId, field }` per written field before `finish`, the way `move_wall` does. It is the first thing I would send upstream. The roundup's commit simulator assumes the field being written is recorded. For thickness, the running code does not record it; I did not race height.

## What the IFC 4.3 file contains

Export is a pure function: `exportIfc()` in `packages/bim-ifc` takes the model, a project and a timestamp and returns STEP text. It never reads a clock, it visits elements in id order, and every GlobalId is derived from a stable id, so "the same model and options give the same bytes". I called `exportIfc()` directly from a small script on the room model.

The room came out as 15,964 bytes in 34 ms: `FILE_SCHEMA(('IFC4X3_ADD2'))`, view definition ReferenceView, 289 entity instances of 45 types, lengths in millimetres. The exporter's report counted 10 building elements (4 `IfcWall`, 1 `IfcSlab`, 1 `IfcDoor`, 1 `IfcWindow`, 2 `IfcDuctSegment`, 1 `IfcDuctFitting`), 2 `IfcOpeningElement` with their voids and fills, 1 `IfcSpace` with 4 `IfcRelSpaceBoundary`, 1 `IfcDistributionSystem`, 6 `IfcDistributionPort`, 12 property sets, 1 quantity set and 1 type (`IfcWallType`). It reported no warnings and nothing skipped.

IfcOpenShell 0.9.0 opened it and its validator, with EXPRESS rules on, reported nothing. I exported the repository's four example models the same way: the small office (41,017 bytes, 29 elements), rooftop unit (21,177 bytes, 12), plumbing riser (78,933 bytes, 48) and electrical room (30,806 bytes, 17). Three of those validated clean too. The electrical room has one error: a property written as `IFCCOUNTMEASURE(30.)`, a real, where the IFC4X3_ADD2 schema as IfcOpenShell reads it types `IfcCountMeasure` as an integer. It is a one-character bug in the writer and most viewers will shrug at it; a strict checker will not.

The tree below is my file, generated from it rather than typed.

<IfcExplorer />

Valid is not the same as useful. Reading entity by entity against the kernel record each came from:

**Walls are extruded boxes along their centreline, with nothing else.** Thickness survives only as the profile's `YDim`. There is no `IfcMaterialLayerSet` (the file has zero materials), no `Pset_WallCommon` (so no `IsExternal`, `LoadBearing` or `FireRating`), no base quantities. The only property set on a wall is `MutinyBIM_Organization`: group, phase created, phase demolished and a `Status` of `NEW`. That `Status` is the right idea in the wrong place; IFC's common property sets have a standard `Status` property that tools look for.

**Corners are not joined.** The exporter writes each wall as a strip from its start to its end (`geo.strip(Math.hypot(dx, dy), el.thickness, el.height)`) and writes no `IfcRelConnectsPathElements`. On my closed loop of 230 mm walls, the south wall runs from x 0 to 6,240 mm and the west wall from y 0 to 4,820 mm, both centred on their lines. So at each outer corner a 115 by 115 mm square is empty, and at each inner corner the same square belongs to two walls. The editor's 3D view draws the corners closed, and `move_wall` drags joined ends along, so the kernel knows about joins. The IFC does not carry them. Over a 3 m height that is about 0.04 m³ of missing wall per corner, and a clash check against a scan will light up every corner.

**The space has no geometry.** `IfcSpace` 'S-01' has a reference, a long name, `NetFloorArea` 27.5859 m² in `Qto_SpaceBaseQuantities`, and four physical boundaries with no connection geometry. It has no body and no footprint. A viewer can list it but not draw it, and anything downstream that computes volumes or checks room heights from IFC has nothing to work with.

**MEP is topology, not data.** Each duct segment is an extruded 406.4 by 200 mm rectangle with two `IfcDistributionPort` records, assigned to the supply-air `IfcDistributionSystem`. The size is only in the profile; there is no property for it, no `PredefinedType`, and no flow. The elbow has `PredefinedType` BEND and its two ports, and no body at all.

**Openings are done properly.** The door and window each void their host wall through an `IfcOpeningElement` and fill it, with `OverallWidth` and `OverallHeight` (900 and 2,134 for the door). The spatial tree is the standard one: project, site, building, storey, with every element contained in the storey.

None of that is a reason to call the exporter bad. Version `0.1.0` of `bim-ifc` writes a geometrically honest, schema-valid file with stable ids, and the gaps are additive. It is a reason not to call the export a handover. For a reviewer in another tool, today's file is massing with openings, a space list and a duct graph.

## The post's video, and the same kind of build through MCP

PermitZIP's 11 November post says an assistant "wrote a script that built the sheets, schedules, rooms, spaces, RTU, VAV boxes, electrical equipment, circuits and single line diagram, and then it ran the script". The 105 s video attached to it is a walkthrough of the finished model: mechanical and power plans, an electrical one-line, a switchboard schedule, then the building in 3D. It does not show the script being written or run.

<Video src="https://ai.satyajit.si/articles/mutiny-bim/clip1" poster="https://media.thesatyajit.com/ai/articles/mutiny-bim/clip1-poster.jpg?v=22167da951765264" alt="Mutiny BIM's sheet view in dark mode, flipping through generated sheets: a mechanical floor plan with duct runs, an electrical single-line diagram, and a switchboard schedule table." caption="Sheets in the model the assistant's script built: mechanical plans, a single-line diagram and a switchboard schedule. A walkthrough of the result, not the script running. Video from @PermitZIPhvac's post on X." />

<Video src="https://ai.satyajit.si/articles/mutiny-bim/clip2" poster="https://media.thesatyajit.com/ai/articles/mutiny-bim/clip2-poster.jpg?v=8ac126f78c0b38b5" alt="Mutiny BIM's 3D view orbiting and zooming into a two-storey building with cut-away floors showing ducts, rooms and equipment." caption="The same model in the 3D view. Video from @PermitZIPhvac's post on X." />

I did not have that script. What I could reproduce is the same pattern at a smaller scale: the repository's own `rooftop-unit` and `electrical-room` examples, shifted to free space and landed on the live model through my MCP client, one approval each. The first sizes and places a 10-ton rooftop unit, routes supply and return ducts at 1,043 fpm, tags them and puts a mechanical plan and an equipment schedule on sheets. The second places a panelboard and 11 receptacles in two rooms, circuits them ("11 receptacles (meets NEC 210.52), 2 circuits on 1 unit. LP-1 1980 VA connected", in the script's own label) and puts a power plan and a panel schedule on sheets. They landed as revisions 24 and 25, each preview taking about 0.7 s and each apply about 0.8 s. There were no VAV boxes and no single-line diagram in my run, because the examples do not make them.

<Figure src="https://media.thesatyajit.com/ai/articles/mutiny-bim/fig2.png?v=266657152d508a73" alt="Mutiny BIM's 3D view of the live demo model: two small scripted rooms with ducts and openings, a larger two-room building with a rooftop unit and magenta return and blue supply ducts, and a two-room electrical layout in the background." caption="The live model after my four landings: the two scan-shaped rooms (right), the rooftop-unit example (front) and the electrical-room example (back). Screenshot from my run." />

<Figure src="https://media.thesatyajit.com/ai/articles/mutiny-bim/fig3.png?v=bc9c06dea60797e5" alt="Mutiny BIM's sheet view showing sheet M-101, RTU level mechanical plan, with a two-room plan and the rooftop unit's ducts placed in a viewport, a title block, and a sidebar listing sheets E-201, E-602, M-101 and M-601." caption="Sheet M-101, made by the rooftop-unit script, with the other three sheets the two scripts made in the sidebar. Screenshot from my run." />

So the claim in the post holds in kind. An assistant that writes one script against this kit can produce plans, schedules and sheets with sized equipment, and the server lands it as one reviewable change. Whether that script came out of Grok in one go, I cannot check from a video.

## Where it fits a scan-to-BIM pipeline

Put the five steps up again: capture, reconstruct, model, make it BIM, ship. Mutiny takes no point cloud, mesh or splat; its underlays are PDF, image, DXF and DWG, and it links IFC models through web-ifc. So it does not help with the hard step, turning a registered cloud into wall lines. That stays with a fitting stage, whether a learned layout model like [SpatialLM](/articles/spatiallm), which writes a room's walls and openings as text from a point cloud, or a program that writes the building as data, like [debim](/articles/debim).

What it can be is the place that output lands. A fitting stage emits a script like mine, with every measured dimension passed explicitly. `bim_check_output` catches what the kit would refuse. `bim_run_script` previews it, and a person looks at the preview against the scan before saying yes. The elements land in an `existing` phase, locked as existing conditions, and design work proceeds against them in the same model. Spaces come from the walls. MEP routing and sizing run on top with refusals in words. Sheets and schedules are generated by scripts the same way. Several people and agents work at once, and the commit server stops the conflicting moves.

What stands in the way today, all of it visible in my run:

1. **Thickness is last-write-wins, and by the code so are element parameters.** The edits an agent makes on a scan-derived model (types, classes, parameters, thicknesses) are the ones that record no authored field. Until those commands record authored fields, two agents filling parameters on the same walls will overwrite each other in silence.
2. **The IFC is not a handover.** No materials, no common property sets, no joined corners, no space geometry. If the deliverable is an IFC for a consultant's Revit or a checker, something has to enrich it after export.
3. **Defaults are imperial.** A door with no height is 2,134 mm and duct sizes come from an inch list. A scan pipeline must pass everything.

None of these is deep. The commit model, the approval loop and the add-on contract, which are the hard parts to get right, are right.

## Where I land

I would use Mutiny BIM now as the shared model behind a scan-to-BIM agent, not as its deliverable. The script-preview-approve loop through MCP is the cleanest agent interface I have seen on a BIM kernel: one script, one preview with quantities, one approval, one undo, and a gate that refuses anything not explicitly approved. Refusals for conflicting geometry edits arrive by name, for people and agents alike. That part is worth building on.

I would not trust it yet with concurrent parameter work, and I would not hand its IFC to anyone as a model. The first is a one-line class of fix I expect upstream to take; the second is a version-0.1.0 exporter with a long, additive to-do list. If you are evaluating it for capture work, run my race with `set_element_values` on your own walls before you put two agents on one model.

**How I checked.** Ran: the install, the dev server and editor, `npm run example` on my script and the four repository examples, the export of all five models with my own call to `exportIfc()`, IfcOpenShell's validator on each file, my MCP client (four landings on the live model, one deliberately unapproved), the seven races with two `SyncClient` sessions, and three test paths. Read, not run: the `set_element_values` and `set_parameters` code paths, locking elements as existing conditions, the exporter's determinism, the add-on loading rules, the script runner's sandbox, the `stale_preview` path and the Postgres model server. The numbers in the 250k budgets are covered in the [roundup](/articles/agent-3d-bim-tools); I did not run the performance gate on a shared machine.
