Updating Shared Parameter Descriptions in Revit Projects Without the Cloud
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.dll25.4.0.0 · API 2025.0.NET 8.0.31 · IronPython 3.4.2 (RevitPythonShell) and IronPython 2.7.12 under
pyrevit run, headlessWindows 11 x64
Uses reflection: Yes.
GetParameterSchema(non-public) andIForgeSchemaServer(internal, implemented throughReflection.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
SharedParameterElementand 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/SKIPPEDper family, and a sentinel file per step. Those are in the descriptions post of this series.Two unsupported surfaces.
GetParameterSchemais invoked by reflection;IForgeSchemaServeris internal and its implementation is emitted in IL. Autodesk guarantees nothing about either across releases.DownloadParameteris 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
DownloadParameteroverwriting 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-07503e80046fon this build. Never derive it from whatever is active.The family route is more capable than
SetDescriptionand more expensive.SetDescriptionis 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 thatSetDescriptiondoes not have.specis immutable. Changing a parameter's type isReplaceParameterplus 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
