Skip to main content

Command Palette

Search for a command to run...

Updating Shared Parameter Descriptions in Revit Projects Without the Cloud

Updated
23 min readView as Markdown
D
BIM Development Lead at DRA Global (Chile), self-taught. I build C# .NET plugins for Autodesk products and find things in the Revit API nobody has documented yet. Everything here is measured before it's written; the research is mine, the writing is done with AI. BIM Development Lead en DRA Global (Chile), autodidacta. Construyo plugins en C# .NET para productos de Autodesk y encuentro cosas en la API de Revit que nadie ha documentado. Todo lo que está acá se midió antes de escribirse; la investigación es mía, la redacción la hago con IA.

ParameterUtils.DownloadParameter is documented as a cloud operation. It is not. Register your own schema server and it works entirely offline — in project documents, and, as it turns out, in family documents too, where it reaches parameters that no other route can. Measured batch by batch, with the procedure that fails silently and the one that does not.

Environment

  • Revit 2025 · RevitAPI.dll 25.4.0.0 · API 2025.0

  • .NET 8.0.31 · IronPython 3.4.2 (RevitPythonShell) and IronPython 2.7.12 under pyrevit run, headless

  • Windows 11 x64

  • Uses reflection: Yes. GetParameterSchema (non-public) and IForgeSchemaServer (internal, implemented through Reflection.Emit)

  • Status: Production. Project route validated on 148 parameters and 36 schedules; family route validated headless on 3 root families, 6 nested families, 270 parameters

  • The C# below has not been executed; it is the documented equivalent of the Python, which has


The Question

Descriptions written in a family do not follow it into a project. That was the wall for the third part of standardising my library, and it comes from a rule I had already met: a shared parameter's definition is owned by the document, not by the .txt file. The first time a GUID enters a document, Revit copies the definition and stores it. Every later load of the same GUID is silently discarded, even if the incoming definition carries a description and the stored one does not.

So a parameter that arrived in a project inside a family, or was created as a project parameter, cannot acquire a description through any ordinary API operation. Writing it into the shared parameter file and re-binding does nothing. Two routes were known, and both cost something:

  • Release the GUID: delete the SharedParameterElement and reload. Resets the first-load condition, but drops the parameter from every schedule field that references it.

  • Autodesk Parameters Service: overwrites the established definition. Requires connectivity, an authenticated session, and a repository outside the organisation's control.

I wanted a third route: local, lossless, automatable. That route exists, and this post is its measurement — first in projects, then in the case that turned out to be harder: family documents with nested families, where the same first-load rule acts between the root and its children, and where the obvious procedure fails without reporting it.


The Idea

ParameterUtils.DownloadParameter is public and documented since Revit 2024. The documentation presents it as a cloud operation: creates a shared parameter element from a definition downloaded from the Parameters Service. That is accurate about what it does and silent about what it talks to.

DownloadParameter does not call the cloud. It calls the active server registered in ForgeSchemaService. Autodesk's server happens to call the cloud; that is a detail of their implementation, not a constraint of the API. Register your own server and DownloadParameter calls yours. That one line is the whole post.

The contract. IForgeSchemaServer is internal in RevitAPI.dll, with three methods:

SchemaDownloadResult   GetSchema(ForgeTypeId typeId)
BindingsDownloadResult GetBindings(ForgeTypeId typeId)
AccountDownloadResult  GetAccountDetails(ForgeTypeId typeId)

In the DownloadParameter flow only GetSchema is invoked. Bindings and group come from the ParameterDownloadOptions you pass, not from the server — I verified it by instrumenting the server and watching what gets called. All three return types have constructors reachable by reflection: SchemaDownloadResult(String) takes the JSON. ParameterDownloadOptions is public: (ISet<ElementId> categories, bool isInstance, bool visible, ForgeTypeId groupTypeId).

Copy the schema, do not build it. In one production document, 138 parameters produced 15 distinct combinations of inherits and spec, including nested spec values that declare the family category. A lookup table covering all of them is fragile by construction. Read the existing schema from the document, change the one constant you mean to change, increment the version. Whatever Autodesk adds later, the copy carries it.

What the operation actually does, in each document type. This is the point the rest of the post rests on. DownloadParameter applies the schema to the SharedParameterElement and binds the parameter. In a project, binding is an entry in ParameterBindings, separate from the element, and it can be removed afterwards. In a family, there is no ParameterBindings: binding is the FamilyParameter itself. The same call that writes the definition creates a family parameter. Measured, and it changes how the family route has to be built.


The Procedure

What every block below assumes

The snippets in this section share one preamble. Nothing in them is defined anywhere else, so it goes first.

Python

# -*- coding: utf-8 -*-
import clr, System, json
from System.Reflection import BindingFlags
from Autodesk.Revit.DB import (FilteredElementCollector, SharedParameterElement,
                               ParameterUtils, ParameterDownloadOptions,
                               ElementId, BuiltInCategory, ForgeTypeId,
                               Transaction, InstanceBinding)

doc  = __revit__.ActiveUIDocument.Document
FS   = BindingFlags.Static   | BindingFlags.Public | BindingFlags.NonPublic
FI   = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic
rapi = [a for a in System.AppDomain.CurrentDomain.GetAssemblies()
        if a.GetName().Name == 'RevitAPI'][0]

SERVER_ID = System.Guid('9a1b7c40-5d63-4e28-b0f4-77ce91a3d502')   # any GUID you own
CATEGORY  = BuiltInCategory.OST_StructuralColumns                  # vehicle only; see below

def args(*a):
    return System.Array[System.Object](list(a))

# Produced by the schema-server emission (its own follow-up in this series):
#   server   - the emitted IForgeSchemaServer instance
#   fSchema  - FieldInfo of the helper's static 'object' slot that GetSchema returns
#   cSDR     - ConstructorInfo of SchemaDownloadResult(string), resolved by reflection

C# (not executed)

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Autodesk.Revit.DB;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

const BindingFlags FS = BindingFlags.Static   | BindingFlags.Public | BindingFlags.NonPublic;
const BindingFlags FI = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;

static readonly Assembly rapi = AppDomain.CurrentDomain.GetAssemblies()
    .First(a => a.GetName().Name == "RevitAPI");

static readonly Guid ServerId = new Guid("9a1b7c40-5d63-4e28-b0f4-77ce91a3d502");
const BuiltInCategory Category = BuiltInCategory.OST_StructuralColumns;

// Produced by the schema-server emission (its own follow-up in this series):
//   object          server   - the emitted IForgeSchemaServer instance
//   FieldInfo       fSchema  - the helper's static 'object' slot that GetSchema returns
//   ConstructorInfo cSDR     - SchemaDownloadResult(string), resolved by reflection

CATEGORY is only what ParameterDownloadOptions demands: the binding it produces is a vehicle, removed after the write in both document types. Use any category present in the document.

Step 1 — register the server, restore by constant

IForgeSchemaServer being internal, a C# add-in cannot declare class Server : IForgeSchemaServer: CS0050 and CS0051 are compiler rules, and the publicised-reference trick does not apply because RevitAPI.dll is mixed-mode. The type is emitted in IL at runtime with IgnoresAccessChecksToAttribute("RevitAPI") on the dynamic assembly, which is what disables the JIT accessibility check. That applies to both languages.

The emitted method bodies must not mention an internal type in a declaration, so a small helper holds three object fields; the host constructs the return values by reflection and leaves them there, and the emitted IL reduces to Ldsfld + Castclass + Ret. The full emission is planned as a follow-up to this series; what matters here is the registration, and two operational rules.

Python

ESR   = rapi.GetType('Autodesk.Revit.DB.ExternalService.ExternalServiceRegistry')
BIES  = rapi.GetType('Autodesk.Revit.DB.ExternalService.ExternalServices') \
            .GetNestedType('BuiltInExternalServices', FS)          # internal
pFSS  = BIES.GetProperty('ForgeSchemaService', FS)
sid   = pFSS.GetValue(None)
svc   = next(x for x in ESR.GetMethods(FS)
             if x.Name == 'GetService' and x.GetParameters().Length == 1) \
            .Invoke(None, args(sid))
stype = svc.GetType()

def m(name, argc):
    return next(x for x in stype.GetMethods(FI)
                if x.Name == name and x.GetParameters().Length == argc)

# Autodesk's server id is FIXED by constant, never read from GetActiveServerId():
# if a previous session died with our server active, "active" is ours, and a
# restore() built on it would leave the native functionality diverted.
AUTODESK_FS = System.Guid('298e3f16-2ca1-42ee-af77-07503e80046f')

if not m('IsRegisteredServerId', 1).Invoke(svc, args(SERVER_ID)):
    m('AddServer', 1).Invoke(svc, args(server))
m('SetActiveServer', 1).Invoke(svc, args(SERVER_ID))

def restore():
    m('SetActiveServer', 1).Invoke(svc, args(AUTODESK_FS))

C# (not executed)

var esr   = rapi.GetType("Autodesk.Revit.DB.ExternalService.ExternalServiceRegistry");
var bies  = rapi.GetType("Autodesk.Revit.DB.ExternalService.ExternalServices")
                .GetNestedType("BuiltInExternalServices", FS);     // internal
var pFSS  = bies.GetProperty("ForgeSchemaService", FS);
var sid   = pFSS.GetValue(null);
var svc   = esr.GetMethods(FS)
               .First(x => x.Name == "GetService" && x.GetParameters().Length == 1)
               .Invoke(null, new[] { sid });
var stype = svc.GetType();

MethodInfo M(string name, int argc) => stype.GetMethods(FI)
    .First(x => x.Name == name && x.GetParameters().Length == argc);

static readonly Guid AutodeskFs = new Guid("298e3f16-2ca1-42ee-af77-07503e80046f");

if (!(bool) M("IsRegisteredServerId", 1).Invoke(svc, new object[] { ServerId }))
    M("AddServer", 1).Invoke(svc, new[] { server });
M("SetActiveServer", 1).Invoke(svc, new object[] { ServerId });

void Restore() => M("SetActiveServer", 1).Invoke(svc, new object[] { AutodeskFs });

GetDefaultServerId() returns Guid.Empty for this service: there is no automatic reversion. While yours is active the native functionality is diverted. Restore in a finally.

The server must never return null. Only GetSchema is invoked in this flow, but GetBindings and GetAccountDetails must still return valid objects: returning null from any of the three throws SEHException from PersistenceDBAPI, which Revit catches and records in the journal.

Step 2 — copy the schema, change one constant

Python

tPU  = rapi.GetType('Autodesk.Revit.DB.ParameterUtils')
mGet = next(x for x in tPU.GetMethods(FS) if x.Name == 'GetParameterSchema')

def schema_of(spe, doc):
    return json.loads(str(mGet.Invoke(None, args(spe.Id, doc))))

def bump(typeid):
    base, ver = typeid.rsplit('-', 1)
    return '%s-%d.0.0' % (base, int(ver.split('.')[0]) + 1)

def with_description(schema, text):
    e = dict(schema)
    e['typeid']    = bump(e['typeid'])
    e['constants'] = [c for c in e.get('constants', []) if c.get('id') != 'description'] \
                   + [{'id': 'description', 'value': text}]
    return e

C# (not executed)

static readonly MethodInfo GetSchema = rapi.GetType("Autodesk.Revit.DB.ParameterUtils")
    .GetMethods(FS).First(x => x.Name == "GetParameterSchema");

JObject SchemaOf(SharedParameterElement spe, Document doc) =>
    JObject.Parse((string) GetSchema.Invoke(null, new object[] { spe.Id, doc }));

string Bump(string typeId)
{
    var sep = typeId.LastIndexOf('-');
    var ver = int.Parse(typeId.Substring(sep + 1).Split('.')[0]);
    return typeId.Substring(0, sep + 1) + (ver + 1) + ".0.0";
}

JObject WithDescription(JObject schema, string text)
{
    var e = (JObject) schema.DeepClone();
    e["typeid"] = Bump(e["typeid"].ToString());
    var cs = (JArray) e["constants"];
    cs.RemoveAll(c => c["id"]?.ToString() == "description");
    cs.Add(JObject.FromObject(new { id = "description", value = text }));
    return e;
}

The group is read from the schema's own group constant when the options are built — never from defn.GetGroupTypeId(), which fails and blames the wrong argument. If a schema has no group constant the parameter is skipped; an empty group moves the parameter to Other, and that state is not recoverable through this route.

Step 3 — capture before you open the transaction

Inside a transaction, reads of the document can return the previous or an inconsistent state: ParameterBindings.get_Item() answered None on a parameter that was bound, and GetDefinition() is invalidated the moment DownloadParameter runs. Everything the loop needs is captured before Start(); inside, the code only applies.

Python

def binding_kind(doc, defn):
    """None | 'INSTANCE' | 'TYPE'. get_Item does not throw; Contains does."""
    try:
        b = doc.ParameterBindings.get_Item(defn)
    except Exception:
        return None
    if b is None:
        return None
    return 'INSTANCE' if isinstance(b, InstanceBinding) else 'TYPE'

captured = {}
for spe in FilteredElementCollector(doc).OfClass(SharedParameterElement):
    d = spe.GetDefinition()
    s = schema_of(spe, doc)
    g = next((c for c in s.get('constants', []) if c.get('id') == 'group'), None)
    captured[str(spe.GuidValue).lower()] = {
        'name'    : d.Name,
        'visible' : d.Visible,
        'group'   : g and g['typedValue']['typeid'],
        'binding' : binding_kind(doc, d),
        'schema'  : s,
    }

C# (not executed)

string BindingKind(Document doc, Definition defn)
{
    Binding b;
    try { b = doc.ParameterBindings.get_Item(defn); } catch { return null; }
    if (b == null) return null;
    return b is InstanceBinding ? "INSTANCE" : "TYPE";
}

var captured = new Dictionary<string, (string Name, bool Visible, string Group, string Binding, JObject Schema)>();
foreach (var spe in new FilteredElementCollector(doc).OfClass(typeof(SharedParameterElement)).Cast<SharedParameterElement>())
{
    var d = spe.GetDefinition();
    var s = SchemaOf(spe, doc);
    var g = ((JArray) s["constants"]).FirstOrDefault(c => c["id"]?.ToString() == "group")
                ?["typedValue"]?["typeid"]?.ToString();
    captured[spe.GuidValue.ToString().ToLower()] = (d.Name, d.Visible, g, BindingKind(doc, d), s);
}

Step 4a — apply in a project document

The binding is a vehicle. It is created by DownloadParameter, and removed afterwards only if the parameter was not already bound — otherwise you strip a real project parameter. isInstance comes from the captured binding, never from VariesAcrossGroups, which answers a different question and corrupts the binding silently.

Python

def apply_in_project(doc, guid, c, text):
    e = with_description(c['schema'], text)
    fSchema.SetValue(None, cSDR.Invoke(args(json.dumps(e))))
    cats = System.Collections.Generic.HashSet[ElementId]()
    cats.Add(ElementId(CATEGORY))
    opts = ParameterDownloadOptions(cats, c['binding'] == 'INSTANCE',
                                    c['visible'], ForgeTypeId(c['group']))
    ParameterUtils.DownloadParameter(doc, opts, ForgeTypeId(e['typeid']))
    if c['binding'] is None:                       # vehicle only: remove it
        spe = next(x for x in FilteredElementCollector(doc).OfClass(SharedParameterElement)
                   if str(x.GuidValue).lower() == guid)
        try:
            doc.ParameterBindings.Remove(spe.GetDefinition())
        except Exception:
            pass

C# (not executed)

void ApplyInProject(Document doc, string guid, (string Name, bool Visible, string Group, string Binding, JObject Schema) c, string text)
{
    var e = WithDescription(c.Schema, text);
    fSchema.SetValue(null, cSDR.Invoke(new object[] { e.ToString(Formatting.None) }));
    var cats = new HashSet<ElementId> { new ElementId(Category) };
    var opts = new ParameterDownloadOptions(cats, c.Binding == "INSTANCE", c.Visible, new ForgeTypeId(c.Group));
    ParameterUtils.DownloadParameter(doc, opts, new ForgeTypeId(e["typeid"].ToString()));
    if (c.Binding == null)
    {
        var spe = new FilteredElementCollector(doc).OfClass(typeof(SharedParameterElement))
            .Cast<SharedParameterElement>().First(x => x.GuidValue.ToString().ToLower() == guid);
        try { doc.ParameterBindings.Remove(spe.GetDefinition()); } catch { }
    }
}

Step 4b — apply in a family document

Here the vehicle is a FamilyParameter, and it is created whatever you pass as categories — an empty set does not prevent it (measured, see below). So the vehicle is removed with FamilyManager.RemoveParameter. The SharedParameterElement survives with its new definition because the nested families still reference it, and the root ends with exactly the parameters it started with.

One difference from the project route: isInstance is passed as False here, and that is not a violation of the rule above. In a family the isInstance argument only shapes the vehicle FamilyParameter, and the vehicle is removed in the same transaction. Measured in Run 4: the type parameter in every family stayed a type parameter. In a project the argument shapes a binding that may be permanent, which is why it must come from the captured state there.

Python

def apply_in_family(doc, guid, c, text):
    e = with_description(c['schema'], text)
    fSchema.SetValue(None, cSDR.Invoke(args(json.dumps(e))))
    cats = System.Collections.Generic.HashSet[ElementId]()
    cats.Add(ElementId(CATEGORY))
    opts = ParameterDownloadOptions(cats, False, c['visible'], ForgeTypeId(c['group']))
    ParameterUtils.DownloadParameter(doc, opts, ForgeTypeId(e['typeid']))

    fm = doc.FamilyManager
    vehicle = next((p for p in fm.Parameters
                    if p.IsShared and str(p.GUID).lower() == guid), None)
    if vehicle is not None:
        fm.RemoveParameter(vehicle)

C# (not executed)

void ApplyInFamily(Document doc, string guid, (string Name, bool Visible, string Group, string Binding, JObject Schema) c, string text)
{
    var e = WithDescription(c.Schema, text);
    fSchema.SetValue(null, cSDR.Invoke(new object[] { e.ToString(Formatting.None) }));
    var cats = new HashSet<ElementId> { new ElementId(Category) };
    var opts = new ParameterDownloadOptions(cats, false, c.Visible, new ForgeTypeId(c.Group));
    ParameterUtils.DownloadParameter(doc, opts, new ForgeTypeId(e["typeid"].ToString()));

    var fm = doc.FamilyManager;
    var vehicle = fm.Parameters.Cast<FamilyParameter>()
        .FirstOrDefault(p => p.IsShared && p.GUID.ToString().ToLower() == guid);
    if (vehicle != null) fm.RemoveParameter(vehicle);
}

Both go inside one transaction per document, with the server restored in a finally. Verification happens after the commit, against the document — a commit that raises no exception does not prove the change was applied.


In Family Documents: Four Runs to Get It Right

This section is the reason the post was rewritten. The family route was not designed; it was arrived at by measurement, and each run changed the design.

The setup, held constant across all runs. Three root families from a production library — structural pedestals: circular, square and rectangular — each carrying nested connection and profile families down to two levels. 270 Acme_ parameters in total across the three trees; 81 distinct shared GUIDs. Every run reads the full state of every parameter at every level before and after, through GetParameterSchema, with the shared parameter file absent. All runs are headless through pyrevit run, one Revit process per step.

Level Circular Square Rectangular
Root (0) 27 59 59
Nested (1) 17 45 45
Nested (2) 4 11 4

Run 1 — the published procedure fails silently

The procedure this series had published for descriptions: traverse leaf-to-root with EditFamily, write with SetDescription, reload each child into its parent with LoadFamily. Log: 270 DESCRIBE OK, 3 families SAVED_OK, 0 errors. State after, read against the saved files:

Nested parameter Description arrived
GUID also present in the root 90 of 90
GUID present only in the nested family 0 of 35

Zero of thirty-five. The SetDescription in the nested document did happen — the log is truthful — but the write did not survive LoadFamily. The root already held a SharedParameterElement for that GUID from the original load; on reload it kept its own definition and discarded the incoming one. The first-load rule, acting between a root family and its children. And the 90 that "worked" were not the nested write surviving either: they show the root's definition, which had been written at level 0.

The published procedure does not reach parameters that live only in nested families, and nothing in its log says so.

Run 2 — writing in the root propagates, but leaves ghosts

If the root owns every definition it sees, the place to write is the root. SetDescription for the parameters the root exposes as FamilyParameter; DownloadParameter on the root's SharedParameterElement for the ones it does not — no EditFamily, no LoadFamily.

Route Applied
SetDescription, root parameters 145 of 145
DownloadParameter, nested-only parameters 26 of 28

The descriptions reached every nested level — confirmed in the family editor, including profiles two levels down. And the circular root family had twelve new FamilyParameters that were not there before — one per nested-only parameter it had processed: DownloadParameter had bound each one as a family parameter, because in a family document that is what binding means.

Run 3 — empty categories do not help

Hypothesis: with no categories there is nothing to bind. Measured on one nested-only parameter, on a clean root, in a fresh Revit process:

Measure Before After
Shared FamilyParameters in root 38 39
SharedParameterElement ElementId 163302 163302
Schema typeid …-1.0.0 …-3.0.0
Description in schema empty applied

Rejected. The FamilyParameter is created by the operation, not by the category.

And then, on the same document: FamilyManager.RemoveParameter on the parameter just created.

Measure After RemoveParameter
Shared FamilyParameters in root 38
SharedParameterElement survives, ElementId 163302
Description in schema still applied
Tooltip in the nested family visible

The vehicle can be removed and the definition stays. That is the procedure.


The Decisive Test

Run 4: the full batch, headless, with RemoveParameter after every DownloadParameter, in one transaction per root family. Same three families, same 270 parameters, same reader.

Measure Before After
Root parameters, circular 27 27
Root parameters, square 59 59
Root parameters, rectangular 59 59
GUID preserved 270 / 270
Formula preserved 270 / 270
Instance / type preserved 270 / 270
Shared / non-shared preserved 270 / 270
Description, level 0 0 145 / 145
Description, level 1 0 110 / 110
Description, level 2 0 15 / 15
Shared parameters without description 270 0
Elements created 0
Family SetDescription DownloadParameter + RemoveParameter Time
Circular 27 12 46 s
Square 59 7 (+1, see below) 37 s
Rectangular 59 7 (+1, see below) 36 s

No ghosts. No losses. Every shared parameter at every level described, including the 35 that Run 1 could not reach.

The project route, for reference, measured earlier on a production template: 182 parameters in the document, 148 matched and updated, 0 failures, 14 of 14 pre-existing bindings retained, 36 of 36 schedule field counts unchanged, tooltips visible in the properties palette for 148 of 148.


What Can and Cannot Be Changed

Field Modifiable Notes
typeid Yes — incremented Required; DownloadParameter must see it as newer
name Yes Validated in projects; tags follow by GUID, view filters by ElementId, both without intervention
description Yes 148 in projects; 270 in families
group Yes Parameter moves to the new palette group. Empty group → Other, not recoverable by this route
spec No Rejected by GUID, regardless of name
inherits No Tied to spec

spec is immutable, and this was tested three ways. Two test parameters with known values: a Text one holding "12345" — deliberately convertible to a number — and a Length one holding 1.5 internal feet.

# Incoming Existing Result
1 Length, same name Text Rejected
2 Length, different name Text Rejected
3 Area (same inherits: measurable) Length Rejected

The journal records all three without any schema parse error, which rules out a malformed JSON:

The shared parameter "<guid>" cannot be added with name "Acme_SpecTestLen"
and type "Length" because it conflicts with the existing name "Acme_SpecTest"
and type "Text".

Scenario 2 is the one that settles the nature of the block: the incoming name was different, and the message still refers to the conflict with the existing name. Revit resolves the GUID first and only then looks at the name. Scenario 3 rules out the base-class hypothesis: Length and Area share inherits: measurable and the rejection is identical. The limit is the spec itself. In all three the transaction rolled back and the parameters kept type and value.

This is consistent with how Revit stores values — a text as a string, a length as a number in internal feet; they are not the same slot with a different label — and with what commercial parameter managers do: none of them convert the type, they replace the parameter and migrate the values.

visible and isInstance live in ParameterDownloadOptions, not in the schema. Hardcode visible = true and you make hidden parameters visible — 37 internal control parameters became visible that way in the project validation, until visible was read from the source. Pass VariesAcrossGroups as isInstance and you corrupt the binding silently. Both come from the captured state.


Failure Modes

The name-uniqueness check fires after the schema is applied. Autodesk's reference states that family parameters must have unique names, and that DownloadParameter errors if the downloaded parameter's name matches one already in the family. Measured: the square and rectangular pedestals each carry a parameter named Acme_SocketType twice — once in the root with GUID eb01c699…, once in a nested connection with GUID 34fa1706…. DownloadParameter on the nested one throws:

Parameter with a matching name is already present in the family document
Parameter name: parameterTypeId

And the state read after the commit shows all four Acme_SocketType instances with their own description — the nested ones with the text keyed to 34fa1706. The schema was applied to the SharedParameterElement; the FamilyParameter creation failed afterwards; the description stayed. For this procedure that exception is a success with no vehicle to remove, and the script classifies it by re-reading the schema rather than trusting the exception.

A name with two GUIDs in one family tree is a library defect the UI cannot create. Shared parameter creation refuses duplicate names; loading a nested family that carries one does not. The plan generator flags such names as ambiguous and resolves them by GUID only. Fixing the library is a separate operation.

ForgeTypeId wants the full schema identifier, revit.local.shared:<32 hex>-N.0.0, not the bare GUID.

The fourth options argument is the group from the schema, not defn.GetGroupTypeId() — and the error message blames the wrong argument.

ParameterBindings.Contains and .Remove throw on unbound definitions; get_Item returns null without throwing.

DownloadParameter invalidates earlier references, both the element and its InternalDefinition. Capture by GUID, look the element up each pass.

Reads inside the transaction return the previous state. Verify after the commit.

Dynamic assemblies accumulate and are never unloaded. This holds for every helper compiled with Roslyn in-process, not only the schema server. A second emission in the same Revit process can leave Revit resolving the schema against the previous server — or, with a helper compiled several times in one session, crash the process; the transaction commits cleanly and the change is not applied. Measured twice. One run per Revit process — which pyrevit run gives you for free.


Notes and Caveats

  • The code shows the mechanism. The batch scripts — state reader, describer, orchestrator — add the plan lookup, per-parameter and per-family error isolation, a CSV log with SAVED_OK / SAVED_WITH_ERRORS / ERROR / SKIPPED per family, and a sentinel file per step. Those are in the descriptions post of this series.

  • Two unsupported surfaces. GetParameterSchema is invoked by reflection; IForgeSchemaServer is internal and its implementation is emitted in IL. Autodesk guarantees nothing about either across releases.

  • DownloadParameter is deprecated in Revit 2027. The 2027 reference marks this overload obsolete and points to one that takes a region argument instead. This post is measured on 2025; on 2027 the call still exists but check the new overload before building on it.

  • Watch RVTUP-1905. A ticket asks Autodesk to stop DownloadParameter overwriting definitions created by add-ins with fixed GUIDs. If implemented, it may break this.

  • Restore Autodesk's server in a finally, by constant. 298e3f16-2ca1-42ee-af77-07503e80046f on this build. Never derive it from whatever is active.

  • The family route is more capable than SetDescription and more expensive. SetDescription is public and needs nothing; it cannot reach nested-only parameters. This route reaches them, at the cost of unsupported surface and a name-uniqueness constraint that SetDescription does not have.

  • spec is immutable. Changing a parameter's type is ReplaceParameter plus value migration.

  • Not validated: Revit versions after 2025, worksharing, linked models, and the C# equivalents.


Credit and License

The enabling observation — that DownloadParameter calls a registered service rather than the cloud — came from reading the call chain instead of the documentation's framing; RVTUP-1905, a developer trying to prevent this behaviour, confirmed it independently. The family route came out of a production run that reported success and had not done the work: reading the state after the commit, rather than the log, is what found it.

Licensed under the MIT License, free to use. If you republish or build on this, attribution to Beyond The Docs is appreciated.

Found a bug in the code, or hit the same problem from another angle? The C# here is the documented equivalent of the Python that ran, so if you spot a mistake in it, please say so. Questions, corrections and your own cases are welcome in the comments.

Diego Peña Esnida · Beyond The Docs · beyond-the-docs.hashnode.dev

More from this blog

B

Beyond The Docs

10 posts

Undocumented findings and verified procedures for the Revit API, Advance Steel, Plant 3D and the broader Autodesk ecosystem. Everything here is measured before it's written.