Revit ships annually. Firms do not upgrade annually. A practice with a 2021 project still in construction administration and a 2026 project in design is completely ordinary, and enterprise clients routinely have three or four versions live at once across different teams.
For a commercial add-in, that makes single-version support a non-starter and per-version branches a maintenance trap. NexusAI covers Revit 2021 through 2027 from one source tree. This is how, and more usefully, what went wrong on the way.
The snippets below are simplified from the production project and source: comments condensed, repetition elided, and unrelated properties removed. They illustrate the shape of the solution rather than being complete.
1. Why you do not get to pick one
The first thing to be clear about is that multi-version support does not mean one binary. It cannot. Revit 2021 through 2024 host .NET Framework 4.8, Revit 2025 and 2026 host .NET 8, and Revit 2027 hosts .NET 10. An assembly compiled for one of those runtimes will not load into another, so you are shipping several assemblies no matter what you do.
What you get to choose is how many source trees produce them. The options are roughly:
- A branch per version. Every fix has to be applied and tested seven times, and they drift. This is how most in-house tools end up, and it is why they stop being maintained.
- A shared library plus thin per-version shells. Reasonable, and it front-loads a lot of interface design for a codebase where the version differences turn out to be small and local.
- One project, one configuration per version. The version is a build input. This is what NexusAI does.
The third option is the cheapest to maintain and the most annoying to set up, because MSBuild is involved.
2. Configurations, not multi-targeting
The instinct is to reach for <TargetFrameworks> and let the SDK produce three
builds. It does not fit, because the framework is not the axis of variation. Revit 2021 through
2024 all target net48, but they reference four different versions of
RevitAPI.dll and need four different compile symbols. The Revit year is the real
input; the framework is derived from it.
So the project declares one configuration per year, and the configuration name is the only thing the build is told:
<Configurations>Debug-2021;Release-2021; ... ;Debug-2027;Release-2027</Configurations>
<!-- One of these per year: the configuration name yields the year and its symbol. -->
<PropertyGroup Condition="'$(Configuration)'=='Debug-2024' or '$(Configuration)'=='Release-2024'">
<RevitYear>2024</RevitYear>
<DefineConstants>$(DefineConstants);REVIT2024</DefineConstants>
</PropertyGroup>
<!-- This block MUST come after every RevitYear assignment above. MSBuild evaluates
top to bottom, and a condition on a property that has not been set yet is not an
error. It is silently false, and you get the default framework for every year. -->
<PropertyGroup>
<TargetFramework Condition="'$(RevitYear)'=='2025' or '$(RevitYear)'=='2026'">net8.0-windows</TargetFramework>
<TargetFramework Condition="'$(RevitYear)'=='2027'">net10.0-windows</TargetFramework>
<!-- net48 remains the default for 2021-2024. -->
</PropertyGroup>
That ordering comment is not decoration. Evaluation order bugs in MSBuild do not produce errors, they produce a plausible-looking build with the wrong framework, which then fails at assembly load inside Revit with a message about a manifest mismatch. It cost me an afternoon once and it would cost the same afternoon again.
The API references resolve the same way, from a per-year install path, with one detail that matters:
<Reference Include="RevitAPI">
<HintPath>$(RevitApiDir)\RevitAPI.dll</HintPath>
<SpecificVersion>false</SpecificVersion>
<!-- Private=false means "do not copy to output". Shipping a copy of RevitAPI.dll
next to the add-in gives you two identities for every Revit type, and casts
that should obviously succeed start throwing InvalidCastException. -->
<Private>false</Private>
</Reference>
Output paths are also per configuration, so a debug build lands directly in the folder Revit loads add-ins from for that year and F5 is a working development loop. Revit 2027 moved that location into Program Files, which means development for 2027 requires an elevated Visual Studio. Slightly irritating, and worth knowing before you spend an hour wondering why your build output vanished.
3. Where the years actually diverge
After several years of this, the differences that have actually required code are narrower than the version span suggests:
- The hosting runtime. .NET Framework 4.8 for 2021 to 2024, .NET 8 for 2025 and 2026, .NET 10 for 2027. This is the big one, and most of its consequences are about dependencies and the loader rather than about Revit.
- Type signature changes. The canonical example is
ElementId, whose constructor took anintthrough 2023 and takes alongfrom 2024. - Removed members.
ElementId.IntegerValuewas removed in 2026 in favour ofValue. - Namespaces that did not exist yet. Newer API areas are simply absent in older versions, which matters more than it sounds when code is generated rather than written.
- Deployment locations. Covered in the signing and deployment write-up: Revit 2027 moved all-users add-in discovery from ProgramData to Program Files.
What is not on that list is most of the API. Filtered element collectors, transactions, parameters, views and families have been stable for years. The version problem is real but it is concentrated, and that concentration is what makes a single source tree practical.
4. Bridging an API change once
The compile-time half of a signature change is unremarkable:
/// Builds an ElementId from a raw id in a version-safe way: the constructor takes
/// an int on 2023 and earlier, a long on 2024 and later.
public static ElementId MakeElementId(long value)
{
#if REVIT2024 || REVIT2025 || REVIT2026 || REVIT2027
return new ElementId(value);
#else
return new ElementId((int)value);
#endif
}
The discipline around it is the part worth stating. Every version-sensitive API gets exactly one
helper, and the conditional compilation lives only inside that helper. Nothing else in the
codebase contains a #if REVIT…. Adding Revit 2028 then means editing a handful of
helpers, not auditing every call site.
The alternative is what happens naturally: a developer hits the compile error at a call site, wraps that line in an ifdef, and moves on. Do that a hundred times and the next migration is a hundred-file change with no way to tell whether you found them all.
Look at the symbol list in that snippet. REVIT2024 || REVIT2025 || REVIT2026 ||
REVIT2027 means every new Revit year requires editing every shim, and forgetting
one produces a build that compiles for the new version and takes the old code path. A
cumulative symbol emitted by the project file makes each shim final once written:
REVIT2024_OR_GREATER, defined for every year at or above 2024. NexusAI still
uses the enumerated form, and it costs a small mechanical edit every year. It is the kind
of thing that is obviously correct in hindsight and easy to leave alone once it works.
5. Code you did not compile
Above the C# layer there is a second version problem, and it is harder. NexusAI executes Python against the Revit API, and that Python is generated by a language model at run time. It cannot be compiled against a specific year, and the model will quite reasonably produce code written against whichever era of the API is best represented in its training data.
Constraining the generator is the obvious move and the wrong one. Version-conditional prompt instructions are untestable, they grow with every release, and they fail silently when the model ignores them. The approach that works is the opposite: widen the runtime so more of what the model writes is correct.
# Revit 2026 removed ElementId.IntegerValue in favour of .Value (a long). Patch the
# property back on so scripts written against the older API keep working, rather than
# trying to teach the generator which Revit year it is talking to.
try:
_probe = ElementId.InvalidElementId
if not hasattr(_probe, 'IntegerValue'):
ElementId.IntegerValue = property(lambda self: int(self.Value))
except Exception:
pass
The same principle covers namespaces. Python.NET's from Autodesk.Revit.DB import *
reaches only the types directly in that namespace, not its children, so the runtime imports each
sub-namespace explicitly. Each import is wrapped individually, because a namespace that does not
exist in an older Revit must not take down the script that needed a different one:
try:
from Autodesk.Revit.DB.Mechanical import * # Duct, DuctType, FlexDuct, ...
except Exception:
pass
try:
from Autodesk.Revit.DB.Electrical import * # ElectricalSystem, Wire, WireType, ...
except Exception:
pass
# ... one block per sub-namespace: Structure, Plumbing, Architecture, Analysis,
# ExtensibleStorage, Fabrication, Visual, Lighting, UI.Selection
Wrapping all of them in a single try would be shorter and would mean one missing
namespace silently skipped every import after it. The verbose form is correct; that is the whole
argument for it.
6. What breaks that is not the API
This is the section I wish someone had written before I did the .NET 8 migration. Almost none of the real pain was in the Revit API. It was in the runtime, the dependency closure, and the assembly loader.
Revit gives you a framework, not your dependencies
Revit's process provides the base desktop framework. It does not provide ASP.NET Core, so the SignalR client used for the cloud connection has to ship its whole dependency closure next to the add-in. That is one property, and it is needed on both sides of the runtime split for different reasons:
<!-- net8 / net10: Revit provides the shared framework but not the ASP.NET Core
SignalR assemblies. This copies package assets without duplicating Revit's
own runtime System.* DLLs. -->
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<!-- net48: the same client is a netstandard2.0 asset, which drags in the shim
assemblies too: System.Memory, System.Buffers, Unsafe, Bcl.AsyncInterfaces,
System.Threading.Channels, System.Text.Json. Without the full closure on
disk the connection throws FileNotFoundException the first time it is used. -->
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
Binding redirects exist on one side of the split only
Binding redirects are a .NET Framework mechanism. On .NET Core and later they are ignored at run
time, assemblies being resolved through the generated dependency manifests instead. Leaving
autogeneration on for the .NET 10 build produced a redirect targeting 10.0.0.0 that collided
with the explicit redirects in App.config capped at 8.0.0.0, and the build failed
with a resolution conflict. The fix is to disable autogeneration for the .NET Core targets and
let App.config serve net48 alone.
A facade and an implementation assembly look identical
This one is genuinely subtle. On net48, a NuGet copy of
System.Runtime.dll in the output folder shadows the framework facade and breaks
type unification, so the build deletes it after copying. System.Memory.dll sits in
the same folder, has a similar name, and is the opposite case: it is a real implementation
assembly that is genuinely not in the .NET Framework, and the SignalR closure needs it.
Deleting it produced a FileNotFoundException the moment the cloud connection was
enabled on 2021 through 2024.
The lesson is not "know which is which." It is that a post-build step which deletes assemblies is a loaded gun, and each deletion needs a comment explaining what would break if it were removed. Both of those deletions now have one.
Runtime switches that must be set before the runtime cares
Python.NET uses BinaryFormatter internally, which .NET 8 disables by default.
Re-initialising the Python engine after a shutdown fails with an invalid-stream error, and the
documented way to re-enable it is an environment variable. That does nothing here: environment
variables are read at CLR startup, and by the time an add-in runs, Revit's CLR has been up for a
while. The switch has to be set programmatically, first:
public Result OnStartup(UIControlledApplication uiControlledApp)
{
// Must run BEFORE AssemblyResolve and before any Python operation. The documented
// environment variable is read at CLR startup, so setting it from inside an add-in
// has no effect. AppContext.SetSwitch is the form that works at run time.
AppContext.SetSwitch(
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization", true);
// Then the resolver, so the first attempt to load a dependency already has it.
SetupAssemblyResolve();
// Ribbon construction is wrapped so that a resource or API hiccup can never fail
// add-in load: Autodesk rejects add-ins that destabilise Revit, and resource
// handling is exactly the sort of thing that differs between versions.
// ...
}
Three ordering constraints in the first ten lines of the add-in, none of which produce a useful
error when violated. The BinaryFormatter one in particular only manifests on the
second Python initialisation, which is why the embedded runtime is now started once per
Revit session and deliberately never shut down until the session ends.
7. Build what is installed
Compiling the 2027 configuration requires Revit 2027's RevitAPI.dll on the build
machine. A build script that assumes all seven versions are present works on exactly one
computer.
So the build probes for each version's API assembly, compiles the configurations it can, and then generates the deployment catalog from the years that actually succeeded rather than from the years it hoped for. A machine with three Revit versions produces a correct installer for three versions instead of failing.
That has a consequence worth writing down rather than discovering: the release machine is the one with all seven versions installed, and a release built anywhere else silently ships fewer versions than intended. The build prints the list it built, which is the cheapest possible guard.
8. The compiler cannot tell you it works
Seven configurations compiling cleanly proves that every API you call exists in every version. It says nothing about behavior, and behavior is where the remaining differences live: an enumeration value that moved, an export option whose default changed, a dialog that appears in one release and not another, a unit conversion that was deprecated and then removed.
None of that is catchable without launching each version and doing the same short sequence of real operations in each. It is tedious, it does not scale, and there is no substitute. The only useful mitigation is to write the sequence down as a checklist that ships with the release process, because a list you keep in your head is a list you will shorten under deadline.
The other half of that is telemetry the desktop reports rather than assumes. The add-in tells the cloud its Revit version and a protocol version for feature detection, so server-side behavior can adapt to what a given client actually supports instead of inferring it from a release number.
9. Takeaways
- Make the version the only build input. Framework, API path, symbols and output location should all derive from it, so adding a year is one configuration.
- Funnel each version-sensitive API through one helper and keep conditional compilation out of everything else.
- Prefer cumulative version symbols to enumerated lists, so a shim written once stays correct.
- When you cannot control the code, widen the runtime. Patching a removed member back on beats trying to constrain a generator.
- Isolate each optional import. One shared try block turns one missing namespace into many.
- Most migration pain is not the API. Budget for the dependency closure, the loader, and runtime switches whose documented configuration does not apply inside a host process.
- Comment every assembly you delete post-build, with what breaks if it comes back.
- Build what is installed, and print what you built.
- Launch every version before every release. The compiler checks existence, not behavior.
Seven versions from one tree is entirely achievable, and after setup it is not where the time goes. The work is front-loaded into the build and into a handful of shims, and after that a feature is written once.