# The Revit bridge, and the two languages either side of it Context for anyone asked to read the live model. Read this before writing a script that talks to Revit. ## What the bridge is Revit runs a pyRevit routes server. `tools/rvt.py` posts a snippet of Python to it, that snippet executes **inside Revit's own process**, and whatever it leaves in a variable named `result` comes back as JSON. PYTHONIOENCODING=utf-8 tools/rvt.py exec tools/rvt.py status # which document is open tools/rvt.py exec -c "..." # one-liner `python` is not on PATH. `` is the Python 3.12 install under the current user's `AppData\Local\Programs`; `CLAUDE.md` spells out the full path, and so does whoever hands you a task. `PYTHONIOENCODING=utf-8` is not optional. Ticket text in this job contains `U+200E`, an invisible mark, and printing a result that carries it raises `UnicodeEncodeError` on a cp1252 console — the bridge succeeded and the client crashed on the way out. ## Two languages, and they are not the same one **Inside Revit: IronPython 2.7.** No f-strings, no `pathlib`, no `dataclasses`, no `subprocess` worth using. `unicode` and `basestring` exist; `vibe.compat` exists for the differences. Write code you would have written in 2015. **Outside: CPython 3.12.** `panellift/` and `tools/` are this side. The two halves never import each other; they communicate through a JSON job file. ## Rules that are not negotiable - **Read only, unless you have been told otherwise in this specific task.** No `Transaction`, no `doc.Delete`, no parameter writes. If the script you are about to write would modify the document, do not run it. Measured 2026-08-30: **no button and no check writes to the model.** Ticket QC, Panel Lift, Extract Design and Self Check all read the document and write only to disk. `helpers.transaction()` exists and nothing calls it; `startup.py` wraps bridge execs in a transaction, which is why a snippet run through the bridge *can* write. The tool does not modify models because nobody has made it, not because it cannot. That is a property worth keeping deliberately rather than by accident. A read-only tool's worst failure is a wrong report, which the reader can disbelieve. Once it writes, its worst failure is wrong geometry in a model somebody builds from, discovered days later. **The first feature with a real reason to write is reinforcing-suggestion**, and the engineer's call on 08-30 was to build it read-only first: find the closest donor and show it, leave the copying to a person. The reason is not timidity. Picking the right donor is the only hard part -- copying is mechanical -- and if both ship together, a bad result cannot be attributed: eighty-four elements in the wrong place look the same whether the donor was wrong or the transform was. - **Never use pyRevit's Reload.** It leaves the routes handler pointing at a dead scripting engine and the next request kills Revit outright — an unhandled exception on a non-main thread, which Revit cannot catch. Restart Revit instead. `rvt.py` refuses to talk to a bridge that has survived a reload, which is the only protection there is. - **Your script freezes Revit for exactly as long as it runs.** The bridge executes on Revit's API thread. There is no background about it: while your snippet is running, the person at the keyboard has a frozen application. Assume that cost before you write the loop, batch the work into small pieces, and ask before running anything long against a model somebody is using. The expensive move is enumerating a view's contents. `FilteredElementCollector(doc, view.Id)` on a view nobody has opened **forces Revit to generate that view**, which is what "generating graphics" in the status bar means. Walking every placed view of twelve sheets froze a cloud model for five minutes. If all you need is which views are on a sheet, `GetAllPlacedViews()` gives you their names without touching their contents — reading the contents is the part that costs. - **The second person to run something measures the cache, not the code.** Most of what a view or a schedule costs is paid once per session, by whoever asks for it first; everyone after that gets it for a hundredth of the price. Measured here on one design: the first ticket read took 47.2 s, the next two took 0.04 s and 0.05 s, and the reading half was all of it -- the checking half was 0.19 s. A bigger design cost minutes on its first ticket and seconds on every one after. So a benchmark that runs second proves nothing, and "I cannot reproduce your slow run" usually means "you warmed it up for me". Before believing a timing, ask whether anything touched the same views earlier in the session. Only a restart restores the cold state, and a restart is the user's to make -- ask, do not engineer a way around it. This matters most for anything a person clicks. Their first click of the day pays the warm-up; every benchmark you ran pays nothing. - **A client timeout is not a rollback.** The default timeout is 300 s. A request that times out on this side may still have executed and committed on the other. Re-probe the state before doing anything else, always. - **A bridge that does not answer is working, until the CPU says otherwise.** The default explanation for silence is that your snippet is still running, not that anything broke. The evidence is not on the bridge side, it is on the process: Revit's CPU time still climbing -> it is working. Wait. CPU flat and silent for a while -> now consider other explanations. Measured: a batch of 57 pieces was declared "a dead exec holding the API queue, only a restart will clear it". Nothing was dead. The same process went on to burn another hour of CPU and finished on its own, and the person who was asked to restart had simply slept instead. A restart would have thrown away every warm view and solved geometry in the session -- the most expensive thing this model has -- to fix a fault that did not exist. **Never ask someone to restart Revit before checking whether its CPU is climbing.** And when a diagnosis arrives with the symptom, verify the diagnosis, not just the symptom. - **A timeout does not stop the work.** The 300 s limit belongs to the client. Revit keeps executing your snippet to the end, still frozen. Re-sending the same script after a timeout starts a second run while the first is still going, and freezes the model again for its whole duration. Wait, then probe. - **Check that the response is yours, and make that possible to check.** Result files can carry another request's payload into your output file. It has happened twice in one afternoon, and not from timeouts: **there is one routes server on one port, and anyone working on this model shares it.** Two people debugging at once trade results. This is the worst failure mode on the bridge, because a foreign payload is well-formed JSON that answers a question you did not ask, and the wrong conclusion it produces looks exactly like a bug in your own code. So put a signature key in every result and check it before reading anything else: result = {'MYNAME_WHAT_THIS_IS': True, ...} If the key is missing, the answer belongs to somebody else. Send it again; do not squint at it for clues. ## Units Revit's internal unit is **decimal feet**. `XYZ()` takes feet. Every length, every bounding box, every location point is feet. Feeding an inch value into an `XYZ` is a 12x error, and the most expensive incident on this project was exactly that: copies landed 6,500 ft away and produced a week of false symptoms that all looked like API problems. Areas are ft2, volumes ft3, and `unit_weight_pcf` is per cubic foot. Only lengths are ambiguous; be explicit in every variable name you write. ## The library, in `WillTools.extension/lib/vibe/` - `knowledge.load(doc)` — the merged configuration: shipped defaults, then the plant file, then the job file, then the project config. Later layers win, and **lists replace rather than merge**. - `ticketqc` — reading a ticket. `collect_context(doc)` returns `(summaries, sheets)`; `assemblies_by_design(doc, know=know)` groups pieces; `read_ticket(doc, asm, sheets, know=know)` gathers one ticket; `name_of(el)` because `.Name` throws in IronPython on some elements. - `ticketchecks.run_all` — the computed checks. - `liftexport` — measuring a piece for the handling calculation. - `designspec.list_specs(doc)` — reviewed design specs by design number. A worked read-only example that gets insert stations right: `_scratch/claude_wp199_verify.py`. ## Two shapes that have caused wrong answers **Element ids.** `ticketqc.eid(el)` returns an int. `doc.GetElement` wants an `ElementId`, so `doc.GetElement(ElementId(int(the_int)))`. Passing the int raises `TypeError: expected Reference, got int`. **Insert positions.** `read_ticket` gives each lifter a `local` tuple — `(X, Y, Z)` in the panel's own frame. There is no `x` or `y` key; asking for one returns `None` and looks exactly like missing data. `local[0]` is measured from the panel **centre**: station_ft = ticket['length'] / 2.0 + lifter['local'][0] Checked: WP200's four P136 sit at `local[0] = ±7.208` on a 22.917 ft piece, giving 4.25 ft from each end, and its ticket prints `4'-3"`. **A bar mark is a configuration, not an object.** `SR612` says what the bar IS -- #6, 60 ksi, a 17A bend, A/B/C to these dimensions -- and says nothing about which one. The same mark turning up on eight different tickets is eight different bars built to one recipe, and that is the normal, wanted case. The reason is not a modelling convention, it is how the shop works. **Every distinct mark is a SKU**: the bending machine has to be set up for it, by hand, once. So a plant works to keep the number of marks down and bend each one in a big batch. Reusing a mark across many pieces is good practice, not a mistake to be reported. What follows for anything that names a bar: **the mark and the element id are both required, and neither substitutes for the other.** The id identifies the object; the mark is what a person recognises -- shown "24227001 overlaps 17599760" an engineer asks what those are, shown "SR701 x P106" he answers in one sentence. A report that drops the mark is unreadable; a report that drops the id cannot be acted on, because the mark does not point at anything in particular. **Nested families.** An element whose `SuperComponent` is not None belongs to another member. It still appears in `GetMemberIds()`, so counting members without filtering double-counts: `SR5602` sits inside the `P76` embed and `RL_OVAL_PATCH_4T` inside `QLPD4T5WG`. ## Scratch Put scripts in `_scratch/` with a prefix of your own. It is gitignored. Do not write the job name, the client's name, a model filename or an absolute user path into anything you create. ## What a running session picks up, and what it does not Measured 2026-08-30, and more precise than the rule this file carried before. brand-new modules import fine already-imported ones stay on the code they were loaded with YAML knowledge re-read on every run Ten modules created that day all imported through the bridge with no restart. In the same session `liftexport.weight_volumes` -- added to a module that had already been imported -- was still absent, and knowledge roles added to the plant YAML minutes earlier took effect immediately. So the rule is not "a code change needs a restart". It is **a change to a module this session has already imported needs a restart.** New work is verifiable straight away; edits to existing modules are not. The trap: importing a module through the bridge to look at it CACHES it. A module read early and edited later is then stuck for the rest of the session, and the bridge will keep reporting the old behaviour convincingly. If a fix seems not to have taken, check whether the session imported that module before the edit -- that is more likely than the fix being wrong. Never solve this with pyRevit -> Reload. It kills Revit. Restarting Revit is the owner's action, never an agent's.