Consulting

Transactions and Failure Handling

Keeping a Revit model valid when the code operating on it was generated a second ago and nobody has read it. Atomicity, the modal dialog that freezes Revit, and errors a machine can act on.

August 31, 2026 · ~15 min read · C#, Python, Revit API

Ordinary Revit add-in code has a property that is easy to take for granted: somebody read it before it ran. It was written against models the developer chose, tested by hand, and its author knew what it would do.

NexusAI generates Revit API code at run time from a plain-language request and executes it against whatever model the user has open. Every one of those properties is gone. The code is new, nobody has read it, the model is one nobody has seen, and the person who asked for the work usually cannot read Python.

Correctness therefore cannot come from reviewing the code. It has to come from what the runtime guarantees around it. This is a write-up of those guarantees and the Revit API behavior that fights them.

About the code

The snippets below are simplified from the production source: names shortened, defensive branches trimmed, and logging removed. Nothing here concerns authentication, device pairing, transport security, or the contents of the runtime's code-validation layer.

1. Authored code and generated code

Three properties have to hold before it is reasonable to point generated code at somebody's project model:

  1. An operation either fully happens or does not happen at all. There is no acceptable partial state, because nobody is watching to clean it up.
  2. Nothing can ever block waiting for a human. The request came from a browser. The user may have switched windows.
  3. Failures have to be machine-readable. The thing that will write the next attempt is a language model, not a developer reading a stack trace.

Revit supports the first one well, actively works against the second, and is indifferent to the third. Most of the engineering below follows from that split.

2. One transaction, all or nothing

Every generated script runs inside exactly one Transaction. Not one per operation, and not a transaction group with sub-transactions.

The reason is that partial application is the worst available outcome. A script that creates forty of sixty walls and then throws leaves a model that is neither the before state nor the after state. No automated retry is safe against it, because the retry cannot know what is already there, and the user cannot undo it in one step. A single transaction turns a failure into a non-event: Revit rolls back, and the document is byte-for-byte what it was.

That atomicity is also what makes aggressive retrying reasonable in the first place, which is the foundation the agentic loop is built on. If failure is free, the loop can afford to be wrong.

A thing we deliberately do not do

An early design analysed the generated Python to decide whether it needed a transaction at all, on the theory that read-only scripts should skip one. It is not worth doing. Proving that arbitrary generated code never writes is unreliable, and an unnecessary transaction that commits with no changes costs essentially nothing. Every script gets a transaction, and whether the model actually changed is observed afterwards rather than predicted beforehand. See section 8.

The failure mode that matters most in Revit automation is not an exception. It is Revit deciding to ask a question.

Revit API work has to happen on the main thread. When a commit raises an error-severity failure, Revit's default behavior is to show a modal failure dialog on that thread and wait for a human. In interactive use that is exactly right. During automation it is a hang, and a particularly nasty one, because of what else is now blocked:

  • The cooperative Python watchdog cannot fire, because it needs line events and the thread is inside native code.
  • The user's Stop button cannot take effect, because taking it requires acquiring the Python GIL.
  • The local request handler cannot respond, so from the browser it looks like the machine went away.

The result is a frozen Revit that only a force-quit resolves, with the user's unsaved work inside it. So the rule is absolute: during automation, no modal, ever.

C# Three settings, one of which is load-bearing
var failureOptions = trans.GetFailureHandlingOptions();

// Our resolver gets first refusal on every failure Revit raises.
failureOptions.SetFailuresPreprocessor(failureResolver);
failureOptions.SetClearAfterRollback(true);

// The critical one. Without this, an error-severity failure (or ANY failure the
// preprocessor returns Continue for) makes Revit show a modal dialog on the API
// thread and wait for a click. Suppressing modal handling lets the preprocessor's
// ProceedWithRollBack decision stand instead of blocking the thread.
failureOptions.SetForcedModalHandling(false);

trans.SetFailureHandlingOptions(failureOptions);

The subtlety in that comment is worth restating: installing a preprocessor is not sufficient. If the preprocessor returns Continue, which is the natural thing to return when it has no opinion, Revit falls back to its default modal handling and you get the dialog anyway. The preprocessor and SetForcedModalHandling(false) are a pair, and only one of them is obvious.

The other source of dialogs

Failure handling options cover transaction failures. They do not cover a plain TaskDialog or message box that a Revit API call decides to raise on its own: unresolved references, family prompts, and similar. Same frozen thread, different origin.

C# Subscribed for the run only, and never confirming
// Auto-dismiss any modal dialog Revit tries to raise WHILE a script runs. Cancel is
// chosen deliberately: it is the safest non-committal answer, so this can never
// auto-confirm a destructive prompt. Subscribed for the duration of the run and
// removed in finally, so dialogs the user triggers normally are untouched.
EventHandler<DialogBoxShowingEventArgs> onDialogShowing = (sender, e) =>
{
    if (e is TaskDialogShowingEventArgs taskArgs)
        taskArgs.OverrideResult((int)TaskDialogResult.Cancel);
    else
        e.OverrideResult(2);   // IDCANCEL for a standard Win32 message box

    suppressedDialogs.Add(e.DialogId ?? "(unidentified dialog)");
};

Two choices in there took a while to settle on. Answering Cancel rather than OK means an unexpected prompt can never be silently agreed to, which is the only defensible default when the question is unknown. And recording each suppressed dialog by id means a class of prompt that starts appearing regularly shows up in diagnostics instead of being invisibly swallowed forever.

4. Dismissing warnings without lying about it

Warnings during automation are routine. "Insert conflicts with joined Wall" is not a bug, it is Revit being conscientious, and dismissing it is the correct action. Dismissing it silently is not, because both the next model turn and the user's report need to know it happened.

So the preprocessor collects the text of every warning before dismissing it, and the collected list is appended to the result under its own header. Two implementation details in there are pure experience rather than design:

  • Revit may call PreprocessFailures several times for a single commit. Warnings therefore accumulate across calls, and without a de-duplication key the same warning is reported four times, or collapsed into a misleading "(x4)". The count has to be of distinct warnings, not of callbacks.
  • Clearing a warning is a resolution. An early version dismissed every warning and then returned Continue, on the grounds that it had not really decided anything. Revit read that as "no opinion" and fell through to default modal handling, producing the exact dialog the whole mechanism existed to prevent. If you cleared something, say so and return ProceedWithCommit.
C# The return value is the whole contract
// Never let failure processing throw: an exception escaping here aborts the commit,
// which converts a recoverable warning into a failed run.
public FailureProcessingResult PreprocessFailures(FailuresAccessor accessor)
{
    try { return ProcessFailures(accessor); }
    catch { return FailureProcessingResult.Continue; }
}

private FailureProcessingResult ProcessFailures(FailuresAccessor accessor)
{
    // ... collect warning text, attempt Revit's offered resolutions, queue deletions ...

    // Roll back from inside failure processing rather than letting Revit open a modal
    // error dialog on the API thread.
    if (unresolvedError) return FailureProcessingResult.ProceedWithRollBack;

    // We cleared something, so this is a decision, not an abstention.
    if (anyResolved)    return FailureProcessingResult.ProceedWithCommit;

    return FailureProcessingResult.Continue;
}

Note the shape of the error path. An unresolved error-severity failure produces ProceedWithRollBack, which means Commit() returns RolledBack instead of throwing. That return value has to be checked, because a rollback that nobody inspects reads as success and the run reports that it completed work it undid.

5. Counting what was actually lost

Some warnings are resolved by deleting elements: duplicate instances at the same location, overlapping inserts. That is real element loss in the user's model and has to be reported plainly.

The trap is that the number of element ids Revit flags overstates the loss, because Revit keeps one member of each overlap group. Reporting the flagged count tells the user twelve elements were removed when six were. Under-reporting would be worse, but over-reporting a destructive action is its own kind of damage to trust.

So flagged ids are queued as candidates and confirmed against the committed document:

C# Must run after the commit, not before
/// Counts how many elements queued during failure resolution are actually gone from
/// the committed document. Revit keeps one instance of each overlap group, so the raw
/// count of flagged ids always overstates the real loss.
///
/// Call AFTER Commit(). Before the commit these ids still resolve, and the answer is
/// a confident zero.
public int CountConfirmedDeletions(Document doc)
{
    int confirmed = 0;
    foreach (var eid in candidateDeletedIds.Values)
        if (doc.GetElement(eid) == null) confirmed++;
    return confirmed;
}

The candidate dictionary is keyed by id value rather than being a list, for the same reason the warnings need a de-duplication set: the same element flagged across Revit's repeated preprocessing passes must be counted once.

6. A rollback the model can act on

When a script fails, the consumer of the error message is a language model that is about to write the next version. That changes what a good error message is. Object reference not set to an instance of an object is nearly useless to it, in the same way it is nearly useless to a person.

So a failure is formatted into explicit sections, each of which answers a different question the next attempt needs answered:

C# Four sections, each with a hard character cap
private static void AppendDiagnosticSections(
    StringBuilder sb, Exception ex, string capturedOutput,
    IReadOnlyList<string> autoResolvedWarnings)
{
    AppendCauseSection(sb, ex);            // exception chain, inner exceptions, ParamName
    AppendPythonTracebackSection(sb, ex);  // formatted via Python's traceback module
    AppendManagedStackSection(sb, ex);     // filtered to Revit / Python.NET / our frames
    AppendCapturedOutputSection(sb, capturedOutput);   // what the script printed first

    if (autoResolvedWarnings?.Count > 0)
        AppendWarningsSection(sb, autoResolvedWarnings);
}

Every section is truncated at an explicit cap, because this text goes back into a token budget that the conversation is also competing for. A full managed stack trace from inside Python.NET is dozens of frames of interop plumbing, of which about three matter, so it is filtered to frames mentioning the Revit API, Python.NET, or our own assembly and capped at eight.

The single most valuable line in the whole formatter

C# Which line failed, in the script the model wrote
private static readonly Regex ScriptLineRegex = new Regex(
    @"File\s+""<string>"",\s*line\s+(\d+)",
    RegexOptions.Compiled | RegexOptions.CultureInvariant);

// Take the LAST match, not the first. Python orders traceback frames outermost to
// innermost, so the last <string> frame is the line that actually raised, and
// the first is whichever wrapper called into it.
Match last = null;
foreach (Match m in ScriptLineRegex.Matches(traceback)) last = m;

Executed code has no filename, so it appears as <string> in the traceback. Extracting that line number and stating it explicitly turns "something went wrong" into "line 47 went wrong," which is the difference between the next attempt being a targeted edit and being a rewrite. Getting first-versus-last wrong produces a plausible number that points at the wrong line, which is worse than no number at all.

Captured output survives the rollback

The rollback is what makes retrying safe, and it also destroys the evidence. After the transaction rolls back, the model state the script observed is gone; anything the script printed before it died is the only surviving record of what it saw.

So the captured stdout and stderr are read out of the Python scope at the point of failure and carried through the rollback on a dedicated exception type, rather than being discarded with the scope. In practice this is often the section that identifies the actual problem: the traceback says a parameter lookup returned null, and the captured output shows the script printing the family name it searched for, which is not the one in the model.

7. Breaking a runaway loop from inside

A generated while True: will hold Revit's main thread indefinitely, and you cannot abort a thread that is doing Revit API work from outside. There is no safe cancellation primitive available.

What does work is a cooperative watchdog. Python's sys.settrace installs a callback invoked on every line event of the executing frames; once a wall-clock deadline has passed, it raises. That unwinds out of the execution, the transaction rolls back, and the thread is released.

Python Installed before the generated code runs
# Raised as a subclass of BaseException, NOT Exception. Generated code very often
# includes a broad `except Exception:` retry wrapper, a reasonable-looking pattern
# that would swallow a plain TimeoutError, re-enter the loop, and then swallow every
# subsequent abort forever. Deriving from BaseException passes straight through it.
class _NexusAbortSignal(BaseException):
    pass

def _nexus_watchdog(frame, event, arg):
    # Time is sampled every N line events rather than every line, so the per-line
    # cost stays negligible for normal scripts while a tight loop is still caught
    # within a fraction of a second.
    if _nx_should_sample() and time.time() > _nexus_deadline_epoch:
        raise _NexusAbortSignal(
            'NEXUS_TIMEOUT_MARKER: script exceeded the execution time budget and was '
            'stopped. The transaction was rolled back so the model is unchanged.')
    return _nexus_watchdog

_nx_sys.settrace(_nexus_watchdog)

The BaseException detail is my favourite thing in this codebase, because it is a bug that only exists when your code is written by a model. A human author would not wrap an entire Revit automation in except Exception: pass and retry. Language models do it constantly, because it looks defensive. Choosing a base class that their favourite pattern cannot catch is a one-line fix for an otherwise unfixable hang.

Two more things worth being explicit about. The watchdog is uninstalled in a finally, always, so its per-line callback never leaks into a later execution on the same thread. And it has a real limit: a single long native Revit call produces no line events and cannot be interrupted this way. That case is covered by the outer transport timeouts, and it is better to state the gap than to imply the mechanism is total.

The same channel carries user cancellation. The Stop button in the browser flips a flag that the watchdog reads on its next sample, so a user-initiated stop and a budget timeout unwind through identical code and both leave the model unchanged.

8. Observing change instead of predicting it

Whether the script modified the model is answered by watching, not by analysis. The application's DocumentChanged event is subscribed for the duration of the transaction and the added, modified and deleted ids are collected.

C# Scoped to one document, and never fatal
EventHandler<DocumentChangedEventArgs> onChanged = (sender, args) =>
{
    try
    {
        // Only count changes to the document we executed against. A linked model, a
        // family document, or a background document can raise changes in the same
        // application instance, and attributing those to this script is worse than
        // missing them.
        if (args.GetDocument()?.Title != doc.Title) return;

        AddElementIds(changedElementIds, args.GetAddedElementIds());
        AddElementIds(changedElementIds, args.GetModifiedElementIds());
        AddElementIds(changedElementIds, args.GetDeletedElementIds());

        // Additions are tracked separately so a caller can find newly created
        // elements (a new view, for instance) without confusing them with
        // modifications to existing ones.
        AddElementIds(addedElementIds, args.GetAddedElementIds());
    }
    catch
    {
        // Defensive: a counting error must never abort the transaction. Bad
        // bookkeeping is recoverable; a failed commit is not.
    }
};

This is what removes the need for static write detection, and it is also what feeds the post-commit change report the agentic loop uses to decide whether the work landed. Observation gives you the element ids, which is strictly more than a boolean prediction would have.

The empty catch deserves its comment. Swallowing exceptions is usually wrong, and here it is right, because the handler runs inside Revit's change notification during a transaction: a throw from it endangers the commit. The trade is explicit and the comment records which way it was made.

9. Guards before the transaction

Transaction.Start() throws if the document is not currently modifiable, which happens whenever the user is mid-sketch, has another command open, or has a transaction group in progress. Letting that throw and formatting it as an error produces a technically accurate message about an invalid operation that tells the user nothing useful.

C# A precondition, answered in the user's terms
// Defence in depth: the primary check is upstream, before the request reaches the
// executor. This backstops any path that gets here while Revit is non-modifiable.
if (doc.IsReadOnly || doc.IsModifiable)
{
    return new ExecutionOutcome(
        "Error: Revit is in an edit mode (a command, sketch, or another transaction "
        + "is open), so NexusAI can't modify the model right now. Finish what you're "
        + "doing in Revit (or press Esc) and resend. Nothing was changed.",
        modifiedRevitModel: false, EmptyChangedElementIds);
}

Two things about that message. It names the cause in language a BIM user recognises, and it states that nothing changed, which is the first thing anyone wants to know when an automation reports an error against their live model. It also reads well to the language model, which will relay it rather than trying to work around it.

Validation of the generated code runs earlier still, before the transaction is opened at all, so a script that fails validation never starts one. Ordering the checks from cheapest and least invasive to most is not just performance; each guard that fires earlier is one fewer piece of Revit state to unwind.

10. Takeaways

  • One transaction per operation, always. Atomic rollback is what makes automated retrying safe, and partial application is unrecoverable.
  • The hang is worse than the exception. In a single-threaded host, a modal dialog is a total outage; an exception is a bad result.
  • A failure preprocessor is not enough on its own. Suppress forced modal handling too, or the default dialog reappears through the path where you had no opinion.
  • Never throw from failure handling or from a change notification. Both convert a recoverable problem into a failed commit.
  • Report destructive actions from the committed state, not from what was flagged. Over-reporting element loss costs trust too.
  • Format errors for whoever retries. When that is a model, the line number, the traceback and the script's own output are worth more than the exception type.
  • Preserve captured output across a rollback. After the undo it is the only evidence of the state the script saw.
  • Pick an exception base class your caller cannot accidentally catch. When the caller is generated code, assume the broadest possible handler.
  • Observe what changed; do not predict it. The observation is cheaper than the analysis and returns more information.
  • Answer preconditions in the user's language, and always say whether anything changed.

Nearly all of this came from watching real automations fail against real project models. The transaction model in the Revit API is genuinely good and does most of the work. The rest is making sure nothing in the process ever stops to ask a question nobody is there to answer.

About the author

I'm James Allen. I spent twenty years in AEC as a Revit and BIM specialist, on projects including One World Trade Center, the Disney Skyliner and Hagrid's Magical Creatures Motorbike Adventure, before moving into development. NexusAI is a commercial AI agent for Autodesk Revit that I designed, built and maintain end to end, from the cloud backend through the web application to the desktop add-in this article describes.

Autodesk Developer Network member, USUS0360. Microsoft Partner, ID 7092119.

Transaction and failure handling in model-modifying automation is one of the things I get called in for on contract, for AEC technology firms, white-label, with a mutual non-circumvent signed before anything starts. Availability and the rest of the write-ups are on the consulting page.