!!! warning "Status: experimental"
The agent runtime is under active development on the `feature/agent-runtime` branch.
It is off by default. Phase 0 (the in-Revit host) is validated on Revit 2024 and 2025; the CLI
and MCP server (Phase 1) are in progress. See [Phases](#phases).
The agent runtime lets any MCP-capable coding agent (Claude Code, Codex, Cursor, VS Code, OpenCode, ...) read and change a live Revit model by sending Python programs, not by calling hundreds of per-API tools. The Revit API is the toolset; MCP is only the control protocol. The human stays in control: by default, the model changes a run makes are approved inside Revit.
Everything below is also available in Revit under pyRevit → Settings → Agent Runtime (MCP). That section enables the host, sets the policy and default engine, shows whether the host is listening, and has one button per MCP client to register the server. The agent runtime is independent of the Routes server and doesn't need it enabled.
-
Enable the agent host. Run this with the
pyrevit.exeof the clone you want the agent to use, then reload pyRevit or restart Revit.pyrevit configs agent enable -
Register the MCP server with your client. The registration command does not change whether the host is enabled.
pyrevit mcp install claude # Claude Code, user scope pyrevit mcp install codex # OpenAI Codex pyrevit mcp install cursor # Cursor (~/.cursor/mcp.json) pyrevit mcp install vscode # VS Code (user mcp.json) pyrevit mcp install opencode # OpenCode (~/.config/opencode/opencode.json[c])
Add
--projectto register for the current directory instead (Claude Code:.mcp.json, Cursor:.cursor/mcp.json, VS Code:.vscode/mcp.json, OpenCode:opencode.json[c]). Codex only supports user-level servers. A JSON config file that contains comments is never rewritten; the command prints the entry to add by hand. -
Start Revit (or reload pyRevit), and open a model.
-
Check the connection:
pyrevit agent status
-
Ask your agent about the model, for example "How many doors on Level 2 have no Mark?" or "Set the Comments of the selected walls to 'Check fire rating'". Changes show an approval prompt in Revit, with the changed elements selected and temporarily isolated in the active view. Keep commits them as one undo entry named
Agent: <title>; Discard rolls them back.
To remove the server: pyrevit mcp uninstall <client>. pyrevit mcp uninstall --all removes
the user-level entry from every client, and the pyRevit uninstaller runs it with --owned, which
keeps entries that point at another pyRevit install. It only cleans the profile of the account
running it, never other users' profiles, so on a shared machine each user who registered the
server removes their own entry. When that command runs elevated, it doesn't start the claude
or codex command line tools, so those two entries stay; run pyrevit mcp uninstall claude
(or codex) from a normal prompt. Project-level entries
(--project) are never removed automatically. To turn the host off: pyrevit configs agent disable.
!!! warning "Model data leaves the machine and is recorded locally"
Whatever the agent reads (element names, parameter values, results) is sent to
the agent's model provider. Check this against your project's confidentiality rules
before enabling the agent runtime. The runtime also stores each run's script, request,
response, large result, and captured images in plaintext under `%APPDATA%\pyRevit\agent`
for up to 14 days.
| Setting | Command | Values |
|---|---|---|
[agent] enabled |
pyrevit configs agent (enable | disable) |
Starts the in-Revit host on the next pyRevit load. Default false. |
[agent] policy |
pyrevit configs agent policy (readonly | ask | auto) |
readonly: queries and dry runs only; it does not sandbox Python or prevent file, network, or process side effects. ask (default): each modify run needs approval in Revit. auto: modify runs are committed without the prompt; each is still one undo entry, guarded and recorded. |
[agent] engine |
pyrevit configs agent engine (ironpython | cpython) |
Engine for runs that don't name one. Default ironpython (the attached IronPython). |
Run any of these commands without a value to print the current setting.
The document captured at the start of a run has an amber pyRevit accent tab with black text throughout execution and the Keep/Discard prompt.
The tooltip reads Agent: <run title> during execution and Agent: <run title> (awaiting approval) during approval.
The highlight identifies the run document, not a document inspected by an earlier get_context request.
All tabs for the same native document identity are highlighted, even when document titles match.
The first successful paint remains visible for at least 1.5 seconds; consecutive runs on that document share the highlight without an intermediate restore.
Tab Coloring remains underneath the temporary overlay and resumes when it ends.
Settings > Agent Runtime (MCP) > Highlight the document tab during agent runs controls [agent] tab_highlight, which defaults to true and applies to the next run without reloading pyRevit.
The configuration accessor is PyRevitConfigs.GetAgentTabHighlight() / SetAgentTabHighlight(bool); no flat Python userconfig alias is provided.
Three consecutive visual failures disable highlighting for the Revit process and produce one log entry without changing a run's outcome.
| Tool | Changes the model | Purpose |
|---|---|---|
get_skill |
no | Task guidance in markdown (see Skills); the server's instructions tell agents which skill to read first |
list_revit_instances |
no | Running Revit sessions with the agent host |
get_context |
no | Revit and pyRevit versions, agent policy, scripting (engine and Python version scripts run on), document, open_documents, active view, selection, levels |
inspect_elements |
no | Class, category, type, level, location, bounding box and parameters of up to 50 elements |
lookup_revit_api |
no | Signatures of a Revit API type or member, reflected from the running Revit. Also its namespace and Python import line, and a creation list: static factories and the doc.Create.New… methods that return the type. A missing member returns found: false with the closest names, including matching values of other enums |
show_elements |
no | Select, zoom to, or temporarily isolate / hide elements (by id or category) in the active view, or reset the temporary mode. No approval prompt. If Revit can't zoom, the response has zoomed: false and a zoom_failed error instead of a dialog blocking Revit. |
capture_view |
no | PNG of a view for visual checks. export renders any view through Revit; viewport renders an open view through Revit cropped to what its window shows now (zoom, pan, temporary isolate; no selection highlight), in a rolled-back transaction; screen captures the active view window as the user sees it (selection, temporary isolate), and fails with view_obscured when another application's window covers any part of it; view 3d renders a temporary 3D view of model categories only, framed by a section box around the model (or elements) and seen from direction, which is rolled back. eye and target (feet) switch it to a perspective camera without a section box; section (origin, normal, optional depth) renders a temporary section or elevation for a horizontal normal, or a plan cut at origin height for [0, 0, -1], framed on elements or the model. detail_level (coarse, medium, fine) and display_style (wireframe, hidden_line, shaded, shaded_edges) apply to the temporary view and, in export and viewport modes, to a named view for that capture only, inside the same rolled-back group (a view template that controls them is detached there). Saved under %APPDATA%\pyRevit\agent\captures. |
run_query |
never | Run a read-only script; model changes are always rolled back, but the script itself has full access to the machine, so the tool isn't marked read-only for MCP clients. workspace puts a folder of the agent's own modules on sys.path, re-imported fresh every run. timeout_s (default 300) stops a script that runs too long |
run_modify |
after approval | Run a changing script against the active document; it is refused when no document is active. dry_run=true previews the change set and rolls back |
get_run |
no | A recorded run: response, script, and pages of a large result |
lookup_pyrevit_api |
no | Signature, docstring and import line of a pyrevitlib or rpw function, a module listing, or a search by words. Works without Revit |
list_automation |
no | The marked pyrevitlib and rpw operations (see Automation operations), with invocable and the inputs each takes. Works without Revit |
run_automation |
never | Run an invocable operation by id with a JSON input object. Runs as a query, so changes are rolled back; it accepts no Python source |
Every request that runs on Revit's main thread closes the dialogs Revit opens during it and
reports them in dialogs, so a dialog can't leave the call hanging.
Only agent-written code needs approval. show_elements is a fixed host operation that
changes presentation, never model elements. Temporary hide/isolate runs in its own small
transaction because the Revit API requires one, and that state isn't saved with the
model. The agent is told to use it rather than writing run_modify scripts to show
things.
run_query and run_modify return a compact result for the agent: status, then
error (when present), decision, result, output, and only the non-empty change
set, failures, dialogs and blocked operations. The full record stays available through
get_run. AttributeError and TypeError failures carry a hint that names the
lookup_revit_api call to make, for example 'Collector' does not exist in Autodesk.Revit.DB or check the overloads of 'FilteredElementCollector.OfCategory'.
If the host cannot persist a result or run record, the response keeps the final model decision
and adds a warning. The corresponding get_run record can be incomplete or unavailable.
Every tool that talks to Revit accepts an optional revit argument (a year such as
"2024", or a process id) for when several Revit sessions run. pyrevit mcp --revit=<year>
sets a default.
pyrevitlib and rpw functions marked with @automation.operation (from pyrevit_automation)
are listed by list_automation with a stable id, mode, effects, context and transaction.
run_automation can call an operation when it is hand-wired in
dev/pyRevitLabs/pyRevitCLI/PyRevitAutomationOperations.cs, or when its marker declares
inputs on a module-level query or pure function that opens no transaction:
@automation.operation(
"pyrevit.elements.name",
PlainEnglish="Read an element name, including a view title on a sheet when requested.",
effects=("model.read",),
inputs=("element: element", "title_on_sheet: boolean?"),
)
def get_name(element, title_on_sheet=False):Each input is "<name>: <type>", with ? for an optional one, and is passed as the keyword
argument of the same name. Types are string (non-blank, at most 256 characters), number,
integer, boolean, element (an element id, resolved in the active document) and scalar
(a string, number or boolean). inputs=() declares an operation that takes no inputs. A
function with a doc parameter gets the active document. List results keep their first 200
items and add total and truncated.
The CLI validates the inputs, then sends a fixed script that calls
pyrevit.agent._operations.invoke; the operation's module and function travel in the run's
inputs, never in the script. invoke checks the live marker and the inputs again inside
Revit. dev/scripts/test_standalone_automation_marker.py fails when declared inputs don't match the
function they mark.
!!! tip "Long approvals"
A `run_modify` call waits until the user answers the prompt in Revit. The server
sends progress notifications every 10 seconds while it waits. If your client still
times out, raise its MCP tool timeout (Claude Code: the `MCP_TOOL_TIMEOUT`
environment variable, in milliseconds).
The guidance agents read lives in markdown, not in the CLI, so it can change without a rebuild:
pyrevitlib/pyrevit/agent/skills/INSTRUCTIONS.mdis the short entry text the MCP server sends oninitialize.{skills}is replaced with the list of skills.- Each skill is a folder with a
SKILL.mdthat starts withnameanddescriptionfront matter. PR 1 shipsrevit-scripting, the core contract for raw Revit API scripts. PR 2 adds domain guidance and library integration.
Agents read a skill with get_skill(name), and other markdown files in its folder with
get_skill(name, file). list_skills reports each skill's source and SHA-256 digest before
the agent reads it. The server loads only shipped skills by default. Enable user skills with
pyrevit configs agent skills enable to load folders from %APPDATA%\pyRevit\agent\skills.
Those folders are trusted-account guidance, not organizational policy: a same-name user skill
is ignored, and user guidance cannot replace shipped safety instructions or host enforcement.
Names, descriptions, files and paths are bounded and validated; reparse-point paths are ignored.
inspect_elements returns an element link with a destination, document reference and element
ids. Pass that object to navigate_revit_link to select, zoom to, isolate or temporarily hide
the same elements. The Revit host compares its active document with the link immediately before
presenting elements. If the document has closed or changed, it returns stale_link; activate the
intended document and inspect the elements again. Navigation only uses the fixed presentation
operation and never executes a command or changes model elements.
Scripts are Python, executed inside Revit by the regular pyRevit engines. The runner
injects doc, uidoc, app, uiapp, DB (Autodesk.Revit.DB), UI
(Autodesk.Revit.UI) and inputs (the inputs argument as a dict). Assign result
to return data. It is serialized to JSON:
ElementIdbecomes an integer.Elementbecomes{id, category, name, class}.XYZbecomes[x, y, z].- .NET numbers become numbers, and collections become lists.
Printed output is captured and returned too.
walls = DB.FilteredElementCollector(doc).OfClass(DB.Wall).WhereElementIsNotElementType()
result = {"count": walls.GetElementCount()}In run_modify, the script opens its own transactions:
t = DB.Transaction(doc, "Set comments")
t.Start()
for element_id in uidoc.Selection.GetElementIds():
doc.GetElement(element_id).get_Parameter(
DB.BuiltInParameter.ALL_MODEL_INSTANCE_COMMENTS
).Set(inputs["text"])
t.Commit()Rules the host enforces:
- A query that changes the model fails with
query_modified_modeland is rolled back. run_modifyrequires an active document and is refused before the script runs when none is active. The other tools (capture_view,inspect_elements,show_elements) also require an active document.- Background documents a run created or opened (no view in Revit, not linked, not open when
the run started) are closed without saving when the run ends, whatever its outcome, and
listed in
closed_documents. The host blocks their save and save-as operations while a run is active.get_context.open_documentslists every open document and marks the background ones. - While a run is active, the host cancels Revit operations exposed through cancellable save,
save-as, synchronize-with-central, file-export and view-export events. The response lists a
cancelled operation in
blocked. - A run in any mode that changes another open document fails with
other_document_modifiedand is rolled back.changes.other_documentslists what changed. A project the script opens or a family from a file during the run counts as one that was already open: changing it fails the run, it is rolled back, and it can't be saved or saved as, so the file on disk stays unchanged. A family fromEditFamilyor a new document the script creates has no file yet; it is reported there but doesn't fail the run, and isn't rolled back. - A script error rolls back everything, and the traceback points at the script's own lines.
Decorated functions, context-manager yields and rewritten exception handlers retain
their original script line numbers, including on IronPython.
When a Revit call throws, the message carries the underlying .NET exception and its
inner exceptions (
[.NET: Autodesk.Revit.Exceptions.… <- …]), not only the generic "A managed exception was thrown". - When Revit rolls back one of the script's transactions because of an error-level
failure, the run fails with
revit_failure, even if the script itself didn't raise. - A transaction left open fails the run with
transaction_left_open. - A script that runs past
timeout_s(default 300 seconds, a finite number above 0, at most 3600) is stopped and fails withtimeout. The runner checks the deadline between Python lines, so it stops loops. A single blocking call, such as a long Revit API call ortime.sleep, can't be interrupted; the run fails withtimeoutonce it returns. - Python drops a trace function after it raises once, so the runner compiles the script with
every bare
exceptandexcept BaseExceptionnarrowed toexcept Exception. A script can't swallow its own timeout, and can't catchKeyboardInterruptorSystemExitthat way. Suppression through an alias orcontextlib.suppress(BaseException)still bypasses this; on CPython a watchdog thread is the backstop. - Revit dialogs are closed automatically and reported in
dialogs. Warnings are removed and reported infailures, and errors roll back the failing transaction. - Documents can't be saved or synchronized during a run. Closing is blocked for documents open when the run started. The script may close documents it creates or opens during the run.
The MCP server's instructions teach agents these conventions, plus units (internal
feet) and ElementId.Value on Revit 2024+.
Agents must not guess the language level. get_context.scripting reports it:
"scripting": {
"default_engine": "ironpython",
"default_python": "3.4.2",
"default_syntax": "Python 3.4 syntax plus f-strings, ...",
"engines": {
"ironpython": {"available": true, "implementation": "IronPython", "python": "3.4.2", "syntax": "..."},
"cpython": {"available": true, "implementation": "CPython", "python": "3.12.3", "syntax": "..."}
}
}The IronPython entry follows the attached engine (IronPython 2.7.12 or 3.4.2). Scripts
run on default_engine (the [agent] engine setting) unless a run passes engine. Every
run response carries an engine field with the interpreter's own sys.version, which
confirms what actually executed.
available means a run on that engine can start. CPython loads its engine DLL from the
clone attached to the Revit version or, in a session pyRevit isn't attached to, from the
clone the session was loaded from. When that clone has no CPython engine, CPython is
available: false with an unavailable_reason, and a run that asks for it fails with
engine_unavailable before any code runs. get_context.pyrevit
reports attached, and clone and the IronPython python are null when the session
doesn't know them, instead of Unknown or 0.
Syntax measured in Revit on IronPython 3.4.2:
| Syntax | IronPython 3.4.2 |
|---|---|
f-strings, variable annotations, keyword-only arguments, */** unpacking in literals |
supported |
walrus :=, async/await, 1_000 numeric literals, positional-only /, match |
SyntaxError |
IronPython 2.7.12 is Python 2.7 syntax. CPython 3.12 supports everything, but reaches the Revit API through pythonnet, and pyrevitlib features built for IronPython may be limited.
pyrevit agent status [--json] running Revit sessions with the agent host
pyrevit agent context [--revit=<year>] get_context of a session
pyrevit agent run <script_file> [--mode=query|dry_run|modify] [--engine=<e>]
[--title=<t>] [--inputs=<json>] [--revit=<year>]
[--timeout=<seconds>] [--workspace=<folder>]
pyrevit agent runs [--limit=<n>] recent runs
pyrevit agent show <run_id|req-id> request, script and response of a run, by run id or request id
pyrevit mcp [--revit=<year>] the MCP server (stdio); started by MCP clients
pyrevit mcp (install | uninstall) (claude | codex | cursor | vscode | opencode) [--project]
pyrevit mcp uninstall --all [--owned] every client's user-level entry; --owned keeps
entries that point at another pyRevit install
--timeout defaults to 300 and is capped at 3600. --workspace must be an existing folder.
The agent and mcp commands ship with the pyRevit CLI of the same build. An older pyrevit
on PATH doesn't know them and prints the global usage; upgrade the CLI or call the one from
the build that provides the agent runtime.
Every run is recorded under %APPDATA%\pyRevit\agent\runs\<timestamp>-<run_id>\:
script.py: the submitted sourcerequest.json: title, mode, engine and inputsresponse.json: status, decision, changes, failures, dialogs, output and resultresult.json: present when the result was too large to return inline
Run records and capture_view images older than 14 days are deleted when the host starts.
A change to a skill under pyrevitlib/pyrevit/agent/skills or to a pyrevitlib helper it describes
is best checked by running its snippets through the MCP server in a Revit that has your clone
attached (pyrevit attach dev default --installed, see the developer guide). The steps in between
are easy to get wrong:
-
Rebuild only for C# changes. C# files under
dev/and the build pipeline underbuild/need a rebuild. Python underpyrevitlibordev/scriptsand the skill markdown don't. -
Close Revit, stop the MCP servers, then rebuild. Revit loads pyRevit from the clone's
bin\and keeps its DLLs open, and each client session runsbin\pyrevit.exe mcpfrom there too. A build started while either runs fails with "The process cannot access the file ... because it is being used by another process", for example onpyRevitLabs.Language.dll(Revit) orColorful.Console.dll(a server). Servers of other client sessions count too.pwsh dev/scripts/Stop-McpServers.ps1 cd build && dotnet run -c Debug -- ci
-WhatIflists the servers without stopping them. Clients start a new server on the next tool call, so don't call an MCP tool between the script and the build; afterwards the new server starts by itself, or the client needs a reconnect. -
Restart Revit after a build. Accept the "Security - Unsigned Add-In" dialog with Load Once every time; the MCP host doesn't answer before that. Python-only changes need no rebuild, but they need a restart or reload to take effect: IronPython can
reload()a changed module, while CPython can't redefine classes that implement .NET interfaces (reloadingpyrevit.revit.db.createraisesDuplicate type name within an assembly). Restarting Revit loads the edits on both engines. -
Repeat full test runs on the other IronPython. The attached IronPython is one setting per clone:
ironpythonruns on 2.7.12 afterpyrevit attach <clone> default --installedand on 3.4.2 afterpyrevit attach <clone> 342 --installed, whilecpythonis unaffected. The switch rewrites the.addinmanifest, so the running Revit keeps its engine until it is restarted (and the Load Once dialog appears again).get_contextand every run'senginefield show the version in use. Run the full tests once per IronPython. Attachdefaultagain, restart Revit and accept Load Once before continuing. -
Run the snippets in scratch documents.
run_queryrolls back changes to the active document, so code that edits a family or project opens its own withapp.NewFamilyDocument(template)orapp.NewProjectDocument(DB.UnitSystem.Metric)and closes it withClose(False)infinally. Roll back an open transaction on that document first:Closeraises while one is open, and the error hides the original failure. Put back any user setting the snippet touched, such asapp.SharedParametersFilename.
The in-Revit agent tests cover the shipped skills and the pyrevitlib side of the automation operations on both engines.
Claude Code / Codex / Cursor / VS Code / OpenCode
│ MCP over stdio
▼
┌──────────────────────────────┐ pyRevitCLI (net8, out of process)
│ pyrevit mcp │ MCP protocol, tool schemas, instructions,
│ │ instance discovery, run records
└──────────────┬───────────────┘
│ named pipe \\.\pipe\pyrevit-agent-<pid> (current user only)
▼
┌──────────────────────────────┐ inside Revit (PyRevit.Runtime, Agent/)
│ AgentHost │
│ AgentDispatcher ─► ExternalEvent
│ AgentRunGuard: TransactionGroup, DocumentChanged diff,
│ save/sync/close blocking, dialog + failure capture
│ approval + isolate preview, AgentScriptRunner, run records,
│ AgentInspector, AgentApiLookup
└──────────────┬───────────────┘
▼
ScriptExecutor ─► IronPython / CPython ─► Revit API + pyrevitlib
Why two processes:
- Dependency isolation: MCP handling and its dependencies stay out of
revit.exe. Assembly clashes with other add-ins are a real risk on .NET Framework 4.8. - Multi-instance routing: the CLI finds every running host and routes each call to one.
- Stability: the agent keeps its connection to
pyrevit mcpacross Revit restarts. - Client support: every MCP client supports stdio.
Why a named pipe: no TCP port, no firewall prompt, and nothing on the network. The pipe's access list allows only the current Windows user and denies network logons, and that is the authentication. The trust boundary is the Windows account: any process running as that user can connect to the host and submit scripts.
The CLI also checks that the process serving the pipe is the Revit recorded in the instance file, and refuses to send a request otherwise, so another local process can't take over a pipe name while the host is down.
Remote use is through Remote Desktop only. An RDP session is an interactive logon, so
pyrevit mcp started inside it reaches Revit. SSH is refused on purpose: an SSH logon
carries the Network SID, so connecting fails with UnauthorizedAccessException, even
under the same account.
Discovery: each host writes %APPDATA%\pyRevit\agent\instances\<pid>.json with the pipe
name, the Revit version and the process id, and removes it when Revit exits.
Discovery excludes registrations and attempts to delete their files only when the recorded process is missing, its readable name is not Revit (case-insensitive), or its readable start time exceeds the registration timestamp by more than two seconds.
A matching process name and start time establish liveness without requiring an available pipe.
If process metadata or the registration timestamp cannot establish liveness, discovery checks for the recorded named pipe without connecting, up to three times at 300 ms intervals.
Any successful pipe check includes the registration in discovery.
If all pipe checks fail, discovery excludes the registration from the current result but retains its file for subsequent discovery.
The host recreates its pipe between connections and waits 500 ms after a pipe error; a temporarily absent pipe does not establish that the host has stopped.
Excluded registrations do not appear in ambiguous_revit messages.
| Piece | Where | Used for |
|---|---|---|
| Queued ExternalEvent dispatcher | ShellExternalEventDispatcher in dev/pyRevitLabs.PyRevit.Shell/ShellLauncher.cs |
Pattern for the agent request queue (task-based wait, no spin) |
| Script execution | ScriptExecutor, ScriptRuntimeConfigs.Variables |
Running agent scripts on the IronPython / CPython engines |
| Config accessors | PyRevitConfigs |
[agent] settings, shared by the host and the CLI |
| Structured command results | __result__ / script.get_results() |
Returning command output to the agent (Phase 3) |
| Command lookup | sessionmgr.execute_command() |
Running a command by unique id (Phase 3) |
| Bundle metadata | ParsedBundle (pyRevitExtensionParser) |
Opt-in agent: block (Phase 3) |
| Batch runs | pyrevit run --models= |
Multi-model agent jobs (Phase 5) |
Deliberately not reused:
- Routes server (
pyrevit.routes): it has no authentication (# TODO: authinpyrevitcore_api.py) and falls back to0.0.0.0. Code execution must never be exposed through it. ScriptExecutor.RequestExecuteScript: it spins onIsPendingand uses a single handler slot that a concurrent request can overwrite. The agent host owns its own queue.
| Area | Contents |
|---|---|
dev/pyRevitLabs.PyRevit.Runtime/Agent/ |
Host, dispatcher, pipe server, run guard, approval and isolate preview, script runner, inspector, API lookup. It lives in the runtime so it can call ScriptExecutor directly; every per-year runtime build shares the source. |
pyRevitAssemblyBuilder SessionManagerService |
Starts, refreshes or stops the host on every LoadSession to match the config. |
pyrevitlib/pyrevit/agent/ |
In-engine runner: executes the agent source, captures output, serializes result. Must stay parseable by IronPython 2.7, IronPython 3.4 and CPython 3. |
pyRevitLabs.PyRevit PyRevitConfigs |
Get/SetAgentEnabled, Get/SetAgentPolicy, Get/SetAgentEngine. |
pyRevitCLI |
PyRevitAgentClient (discovery and pipe protocol), PyRevitCLIAgentCmds (agent, configs agent, mcp install and uninstall), PyRevitMcpServer (pyrevit mcp). |
pyRevitDevTools Debug.panel/Agent Tests.pulldown |
In-Revit end-to-end tests that drive the host through AgentHost.HandleRequest. |
dev/pyRevitLabs/tests/pyRevitLabs.PyRevit.Runtime.Agent.Tests |
Unit tests of the run guard against Revit test doubles; run in CI. |
Newline-delimited JSON-RPC 2.0, one request per connection. Revit serves one connection
at a time and closes one that sends nothing for 30 seconds. Methods: ping,
get_context, run, inspect_elements, show, capture, lookup_api. Errors carry a stable
data.type, such as revit_busy, no_active_document, policy_readonly or
invalid_params.
A client that gets revit_busy, or loses the connection, has to know whether its request ran
before it sends it again. Every request goes out under a request id (the CLI and MCP server
use req- and twelve hex digits as the JSON-RPC id), and a failure says what is known:
outcome |
Meaning | What to do |
|---|---|---|
not_sent |
The request never reached the host: nobody was listening, another client held the pipe, or the pipe belongs to another process. | Send it again. |
not_run |
The host refused the request before running any of it and discarded it. It is removed from the queue and never runs later. | Send it again once Revit is idle. |
unknown |
The request was sent and the answer was lost (the connection broke or closed). It may have run, may still be waiting to run, or may never run. | Look it up by request id before sending it again. |
Every revit_busy the host raises is a not_run. For a run request the host also leaves a
run folder with status and decision not_run, and returns its run_id and run_dir in the
error's data. Without it a refused request would leave no trace. A run that did start records
the request id in request.json and response.json (request_id), so a request can be found
either way:
pyrevit agent show req-0123456789ab
The MCP tool get_run accepts the same request id. Errors from the MCP server carry
outcome, request_id and, when the host recorded one, run_id. A request that is still
queued has no record yet, and one that never arrived has none at all. Only a pipe error after
sending, or an answer that cannot be read, leaves the outcome unknown.
A missing record does not prove that resending is safe. A request waits to start for
start_timeout_s (30 seconds by default, up to an hour for a client that sets it on the pipe) and
leaves no record until it starts or is refused, so it may still run. Look the request up again
after that wait has passed, and send it again only if there is still no record.
Every run executes in one ExternalEvent callback on the Revit main thread:
- Arm guards (only while the run is active):
DocumentSynchronizingWithCentral,DocumentSavingandDocumentSavingAsare cancelled for documents open when the run started and for documents the script creates or opens during the run, when Revit marks the event cancellable.DocumentClosingis cancelled only for documents open when the run started. The script may close documents it creates or opens during the run, but cannot save them. After the guard is disposed, the host closes any of these documents still open in the background without saving; documents with an open view are left open.DialogBoxShowingis captured and dismissed.FailuresProcessingwarnings are recorded and deleted, and errors roll back the failing transaction, in every non-linked document. A failure in a family or a new document fails the run withrevit_failureinstead of raising a dialog.DocumentChangedcollects added, modified and deleted ids, per document.
TransactionGroup.Start("Agent: <title>")on the active document if it is writable, and on every other open, editable, non-linked document that has no transaction already open. Documents the script creates or opens during the run get no rollback group. Changes to documents opened from disk are discarded when they are closed without saving, not by rolling back a group.- Run the script. Its own transactions nest inside the group.
- Decide:
- any mode, another document open at the start or opened from disk during the run changed: roll back every group.
The run fails with
other_document_modifiedif the script completed successfully and left no transaction open. Only the active document's group is ever assimilated. - query: always roll back. A recorded change becomes a
query_modified_modelerror. - dry_run: roll back and return the change set.
- modify with policy
readonly: the policy is read before the script and again here, and the stricter value (readonly, thenask, thenauto) decides. A run queued before the switch toreadonlyrolls back withpolicy_readonlyinstead of committing, and a script that rewrites the policy can't loosen it for its own run. - modify with policy
auto:Assimilate()straight away. The response saysapproval: auto. - modify with policy
ask: while the group is still open, select and temporarily isolate the changed elements, and show the approval prompt. The isolation is switched off again inside the group before deciding. Keep →Assimilate(), one undo entry. Discard →RollBack().
- any mode, another document open at the start or opened from disk during the run changed: roll back every group.
The run fails with
- Disarm guards and write the run record.
The approval must happen inside the same callback, because Revit won't keep a transaction group open after the callback returns. The benefit is that the human approves inside Revit, and no agent tool can approve a run.
Honest limits:
- Rollback protects the model, not the file system or the network. There is no sandbox;
readonlymeans query-only model access, not restricted Python execution. - The guard covers the run itself, not code the run leaves behind. A script has the live
Revit API, so it can subscribe to a Revit event such as
Idling, or start a thread, that changes the model or answers the approval prompt after the guard has ended. Those changes are neither approved nor recorded. Approval andreadonlystop an agent's mistakes, not a hostile script: connect only agents you trust, and don't feed them untrusted content. - Revit serves one agent request at a time. While one session's run is executing, which can
last until its
timeout_s, a second agent session can't connect and getsrevit_busyafter the CLI's 10-second connect timeout. Keep runs short, and run one agent per Revit. - ExternalEvents only fire while Revit is idle, so a busy Revit returns
revit_busyafter 30 seconds. The error names Revit's open windows and whether an earlier agent request is still running: a modal dialog Revit shows while idle, such as the save reminder, isn't part of any run and blocks every request until the user answers it. The refused request is discarded and never runs after the dialog closes; see What became of a request that got no answer. - A rollback must not touch committed work, but rolling back a dry run once undid the
previous committed run as well (that run used
StairsEditScope; Revit's journal loggedUndoElement::simplify PROBLEMS). The host remembers a sample of the elements each committed run created and checks them after every rollback and before every run; a loss comes back aswarningsin the run result. - Revit regeneration inflates change sets: two new walls can report 100+ modified
elements (rooms, tags, paths of travel, schedules). The MCP tools therefore return only
counts, categories and the first added ids; the element list is in
get_run. Phase 2 will rank the elements the script touched ahead of these side effects.
Commands will opt in through bundle.yaml:
agent:
expose: true
mode: query
description: Reports doors missing company-standard parameters.
inputs:
level:
type: string
description: Level name to limit the check to
required: false
returns: List of door ids with the missing parameter namesformsprompts readagent.inputs[key]. When an input is missing they raiseInputRequired, and the tool returnsneeds_inputwith the options so the agent can call again.- Output-window markdown and tables become text.
__result__andagent.result()become structured data.
| Phase | Deliverable | Status |
|---|---|---|
| 0: Spike | In-Revit host, pipe, guarded run, script runner, test client | Done on Revit 2024 (IronPython 3.4). CPython and Revit 2026 passes pending. |
| 1: MCP MVP | pyrevit mcp, pyrevit agent, pyrevit configs agent, mcp install, get_context, inspect_elements, lookup_revit_api, run_query, run_modify, get_run, policy |
In progress |
| 2: Writes | Ranked change sets with before/after values, cancellation (timeout_s is done) |
In progress |
| 3: Commands | agent: bundle block, list_commands / run_command, forms agent mode |
Planned |
| 4: Authoring | save_as_command (promote a script to a ribbon button), shipped agent skill |
Planned |
| 5: Scale | batch_run across models, persistent script sessions |
Planned |
- Model data leaves the machine through the LLM provider; an admin lock for the
[agent]settings should be available. - Whether the CPython engine is mature enough to be the default for agent scripts.
- How to relate to the third-party
mcp-server-for-revit-pythonextension. - Behaviour on workshared models with elements owned by other users.
Validated on Revit 2024 (build 24.1.11.26), IronPython 3.4.2, Snowdon Towers sample:
| Check | Result |
|---|---|
| Pipe, ExternalEvent, main-thread queue | Works; warm runs take under 1 s |
DocumentChanged inside a TransactionGroup |
Fires for every inner commit, so query detection and change sets work |
| Query and dry-run rollback | Verified in the model afterwards |
| Warning capture | Overlap warning recorded and removed, no popup |
| Dialog capture | API TaskDialog dismissed and reported. DialogBoxShowing fires for API dialogs too, so the host disarms it before its own prompt. |
| Save blocking | Revit itself refuses to save while an API transaction group is open; the DocumentSaving cancel covers runs without a group |
| Script error | Rolled back, traceback points at the script line |
| Transaction left open | Detected; Revit cleans up the open transaction |
| Modify, Keep / Discard | Committed or rolled back as chosen, verified in the model |
| Isolate preview | View isolation cleared after the prompt |
The pyRevitDevTools extension has an Agent Tests pulldown under pyRevitDev > Debug. Its buttons
drive the host from inside Revit through AgentHost.HandleRequest, which runs the same JSON-RPC
requests the pipe carries synchronously on the Revit main thread. A command can't use the pipe
itself: the pipe executes requests through an ExternalEvent that never fires while a command runs.
| Button | Covers |
|---|---|
| Run Agent Tests | get_context, inspect_elements, show, capture, lookup_api; what each request refuses (invalid parameters, unsupported or closed views); query runs on both engines (results, error lines, timeouts, workspaces, records, dismissed dialogs); modify runs (decisions, change sets, rollback, open transactions, save and export blocks, other documents, warnings, readonly, a policy changed during the run or outside Revit); the agent settings; the real named pipe (ping, lookup_api, errors, revit_busy while a command runs) |
| Run Agent Tests (CPython) | The same tests started from a CPython command, which also checks that the command survives nested agent runs |
| Run Agent Library Tests | The shipped and user skills, lookup_pyrevit_api and list_automation through a short-lived pyrevit mcp process, and the pyrevitlib side of the automation operations on both engines |
| Run Agent Approval Tests | The ask prompt's Keep and Discard, answered through Revit's DialogBoxShowing event; if a prompt stays on screen, click the button its title names |
The tests create two scratch projects in the temp folder, set the policy to auto (or ask for the
approval tests) while they run, then close the projects without saving, reactivate the document
that was open before, and restore the policy. The settings tests change the real pyRevit config and
put every value back. The pipe tests are skipped when the host is disabled, and the other tests have
only been run with it enabled.
Each test is written to %TEMP%\pyrevit-agent-tests.log before it starts, so if Revit ever hangs, the
last line of that file names the test that was running.
run_automation, navigate_revit_link and the hints the MCP server adds to run errors call back
into Revit through the pipe, which can't answer while a command runs, so these buttons don't cover them.