How to Write a Custom AutoCAD Solar Plugin in C# with Claude as Your Pair Programmer
A working guide for solar engineers who want to build their own AutoCAD plugin in C# — the setup that compiles, the Transaction trap, the selection-set wall, and where Claude actually helps versus where it hallucinates LISP from 2005.
How to Write a Custom AutoCAD Solar Plugin in C# with Claude as Your Pair Programmer
The first version of C# code Claude gives you for an AutoCAD plugin will not compile. Not because Claude is bad at C# — it isn't — but because the AutoCAD .NET API has a specific Transaction pattern that every single operation requires, and Claude will skip it unless you tell it not to. We spent two years building Branch against this API, reading every thread on Kean Walmsley's Through the Interface and the Autodesk DevBlog. Here's the cheat sheet.
By the end of this post you'll have a working plugin that places geometry at a picked point and reads panel block attributes. From there you'll know enough to go further, or enough to decide you'd rather not.
The Three DLLs and One Critical Property
Every AutoCAD .NET plugin references three assemblies from your AutoCAD install's bin folder:
acmgd.dll— application layeracdbmgd.dll— database layeraccoremgd.dll— core (required since AutoCAD 2013)
On a default install, look in C:\Program Files\Autodesk\AutoCAD 202x\. Add references to all three in Visual Studio (right-click your project → Add Reference → Browse).
The gotcha nobody puts on page one: after adding each reference, select it in the References list, open the Properties pane, and set Copy Local = False. If you leave it True, Visual Studio copies these DLLs into your build output. AutoCAD then tries to load them twice and refuses to load your plugin at all. Multiple community posts identify this as the most common reason new plugins fail to load.
One more setup item: if you build the DLL on a network drive or a OneDrive-synced folder, NETLOAD will fail with "Operation is not supported." The fix is a one-line addition to acad.exe.config (found next to acad.exe, needs admin rights):
<runtime>
<loadFromRemoteSources enabled="true"/>
</runtime>
Kean Walmsley documented this in 2011. The error message gives no indication that the path is the problem.
A 40-Line Plugin That Compiles
This places a circle at a point the user picks. It compiles and runs. The parts that matter are explained underneath.
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.AutoCAD.Geometry;
using Autodesk.AutoCAD.Runtime;
[assembly: CommandClass(typeof(LeafPlugin.PanelCommands))]
namespace LeafPlugin
{
public class PanelCommands
{
[CommandMethod("LEAFTEST")]
public void PlaceCircle()
{
Document doc = Application.DocumentManager.MdiActiveDocument;
Database db = doc.Database;
Editor ed = doc.Editor;
// Ask the user to pick a point
PromptPointResult ppr = ed.GetPoint("\nPick a point: ");
if (ppr.Status != PromptStatus.OK) return;
using (Transaction tr = db.TransactionManager.StartTransaction())
{
BlockTable bt = (BlockTable)tr.GetObject(
db.BlockTableId, OpenMode.ForRead);
BlockTableRecord ms = (BlockTableRecord)tr.GetObject(
bt[BlockTableRecord.ModelSpace], OpenMode.ForWrite);
using (Circle circle = new Circle())
{
circle.Center = ppr.Value;
circle.Radius = 1.0;
ms.AppendEntity(circle);
tr.AddNewlyCreatedDBObject(circle, true);
}
tr.Commit(); // <-- do not forget this
}
}
}
}
Build this, run NETLOAD in AutoCAD, browse to your DLL, type LEAFTEST, pick a point. You'll have a circle.
Ben Rand's Autodesk University handout "Create Your First AutoCAD Plug-In" is the clearest free reference for the full setup walkthrough.
Every Operation Needs a Transaction
The Transaction is not optional. Every object you read or write in AutoCAD's database — every block, every line, every attribute — must be accessed inside an active Transaction. Miss this and you get "Object reference not set to an instance of an object," a message that tells you nothing useful.
Two bugs from the AutoCAD DevBlog's canonical post on this that catch everyone:
Bug 1: Forgetting tr.Commit(). An uncommitted transaction is silently aborted when it's disposed. Your circle, your block, your attribute write — gone, no error. The DevBlog calls this "the first thing to check when something you expect to have been added to the database isn't there." Claude will frequently generate code that opens a transaction, does work, and returns — without committing. Check every snippet it gives you.
Bug 2: Leaving a transaction open. If you return from a command without committing or disposing the transaction, AutoCAD graphics start misbehaving. Entities you added won't display. Close AutoCAD and you'll see an error in the Visual Studio output window. The using block in the example above handles disposal automatically — keep it.
One non-obvious rule from Kean Walmsley: always call Commit() even on read-only transactions — an aborted read carries a performance cost. The using block plus tr.Commit() is the right pattern regardless of what you're doing.
GetSelection Doesn't Return a List
Before you write a command that processes selected panels, here's what you're about to hit: Editor.GetSelection() returns a PromptSelectionResult, not a list of entities. To iterate the actual objects, you unpack it through SelectionSet, then open each ObjectId in a transaction. There's a 47-reply thread on the Autodesk forums of people trying to filter block names in selection sets — worth bookmarking.
[CommandMethod("COUNTPANELS")]
public void CountPanelBlocks()
{
Document doc = Application.DocumentManager.MdiActiveDocument;
Database db = doc.Database;
Editor ed = doc.Editor;
// Filter for block references whose name starts with SOLAR_MOD
TypedValue[] filterList =
{
new TypedValue((int)DxfCode.Start, "INSERT"),
new TypedValue((int)DxfCode.BlockName, "SOLAR_MOD*")
};
SelectionFilter filter = new SelectionFilter(filterList);
PromptSelectionResult psr = ed.SelectAll(filter);
if (psr.Status != PromptStatus.OK)
{
ed.WriteMessage("\nNo panel blocks found.");
return;
}
int count = psr.Value.Count;
ed.WriteMessage($"\nFound {count} panel blocks.");
}
The TypedValue pairs use DXF group codes — 0 for entity type, 2 for block name. AfraLISP's DXF reference is old but still the fastest lookup.
Always check psr.Status != PromptStatus.OK before touching psr.Value. If the user presses Esc or selects nothing, psr.Value is null and you'll get a NullReferenceException.
Your Panel Blocks Have a Hidden Name
Your panel blocks are almost certainly dynamic blocks — a single block definition that shows up in the block table as SOLAR_MOD_400W but in model space with generated names like *U123. Reading attributes and dynamic parameters from them takes a bit more setup than you'd expect.
Reading attribute values (panel serial number, module type, row/string label):
using (Transaction tr = db.TransactionManager.StartTransaction())
{
BlockReference br = (BlockReference)tr.GetObject(panelId, OpenMode.ForRead);
foreach (ObjectId attId in br.AttributeCollection)
{
AttributeReference ar = (AttributeReference)tr.GetObject(
attId, OpenMode.ForRead);
if (ar.Tag.ToUpper() == "PANEL_TYPE")
{
ed.WriteMessage($"\nPanel type: {ar.TextString}");
}
}
tr.Commit();
}
Getting the real block name when working with dynamic blocks:
string effectiveName = br.IsDynamicBlock
? ((BlockTableRecord)tr.GetObject(
br.DynamicBlockTableRecord, OpenMode.ForRead)).Name
: br.Name;
Without this, br.Name returns *U123 and your block-name filters don't match. Kean Walmsley's post on dynamic block selection filters covers this exact problem.
Reading dynamic parameters (tilt angle, row count) — warning first: setting a dynamic property value to the wrong type hard-crashes AutoCAD with no error. If the property expects a double, passing "1234" as a string crashes. Always match the type. The Autodesk forum thread on this has multiple engineers reporting they lost hours to it.
// Reading dynamic block parameters safely
foreach (DynamicBlockReferenceProperty prop in
br.DynamicBlockReferencePropertyCollection)
{
if (prop.PropertyName == "PanelTilt")
{
double tiltAngle = (double)prop.Value; // must cast to double
ed.WriteMessage($"\nTilt: {tiltAngle}°");
}
}
Where Claude Helps and Where It Hands You LISP From 2005
Where it actually helps:
- Transaction scaffolding. Give Claude a task description and it will generate the
using (Transaction tr = ...)pattern correctly if you tell it you're working with the AutoCAD .NET API. - Namespace imports. It knows
Autodesk.AutoCAD.DatabaseServices,Autodesk.AutoCAD.EditorInput,Autodesk.AutoCAD.Runtime, and the rest. - Attribute iteration patterns. The
AttributeCollectionloop is something it handles well once you've established context. - Error debugging. Paste the exact exception message back into the conversation and it will usually identify the Transaction or
UpgradeOpenissue.
Where it fails:
- It forgets
tr.Commit(). Every time. Review every code block for this. - It hallucinates API methods like
BlockReference.GetAttributeValue("TAG")— this doesn't exist. You iterateAttributeCollection. The Autodesk DevBlog is the authoritative source here, not Claude. - It gives you AutoLISP when you asked for C#. The Autodesk community thread on ChatGPT-generated LISP documents this exact pattern. Specify "C# targeting the AutoCAD .NET API, .NET Framework 4.8" (for AutoCAD 2024 and earlier) or ".NET 8" (for AutoCAD 2025+) in every prompt.
- It confuses VBA/COM patterns with managed .NET. You may see
ThisDrawing.Database.ModelSpace.Add(...)in the output — that's VBA, not C#. The correct pattern isBlockTableRecord.AppendEntity(...).
The prompt pattern that improves results significantly:
"I'm writing a C# plugin for AutoCAD using the managed .NET API (.NET Framework 4.8, targeting AutoCAD 2024). I need to [specific task]. Use the Transaction pattern with
using (Transaction tr = db.TransactionManager.StartTransaction()). Do not use VBA or AutoLISP patterns. Show the full method including namespace declarations. Includetr.Commit()before the closing brace."
Paste your existing working code into the same prompt so Claude matches your patterns. Then paste the error message back when it breaks.
The PackageContents.xml Trap That Crashes AutoCAD 2025
When you're ready to install the plugin without using NETLOAD every session, AutoCAD uses a .bundle folder structure. Place it in one of these locations (none of these folders exist by default — create them):
%AppData%\Autodesk\ApplicationPlugins\— per-user install%ProgramData%\Autodesk\ApplicationPlugins\— machine-wide
Structure:
LeafPlugin.bundle\
PackageContents.xml
Contents\
2024\
LeafPlugin.dll
2025\
LeafPlugin.Net8.dll
The PackageContents.xml manifest controls which DLL loads for which AutoCAD version. The SeriesMin and SeriesMax attributes are critical. An Autodesk warning from 2025 states verbatim: "if SeriesMax is not specified, the Autoloader assumes it is a compatible component and loads it. However, since the API breaks because of the .NET upgrade, the app may crash while loading."
AutoCAD 2025 moved from .NET Framework 4.8 to .NET 8. These are not compatible. A DLL built for .NET Framework 4.8 cannot load into AutoCAD 2025. You need separate builds. CAD Guardian's migration guide covers the full 2024→2025 transition. gileCAD's project template on GitHub has a pre-configured .csproj that handles both targets if you want to start there.
Minimal PackageContents.xml for a dual-version plugin:
<?xml version="1.0" encoding="utf-8"?>
<ApplicationPackage Name="LeafPlugin" AppVersion="1.0.0"
FriendlyVersion="1.0" ProductCode="{your-guid-here}"
Description="Solar panel automation" Author="Leaf Automation">
<CompanyDetails Name="Leaf Automation" Url="https://leafautomation.ai"/>
<Components>
<ComponentEntry AppName="LeafPlugin" ModuleName="./Contents/2024/LeafPlugin.dll"
LoadOnCommandInvocation="False" LoadOnAutoCADStartup="True">
<RuntimeRequirements OS="Win64" Platform="AutoCAD"
SeriesMin="R24.0" SeriesMax="R24.3"/>
</ComponentEntry>
<ComponentEntry AppName="LeafPlugin25" ModuleName="./Contents/2025/LeafPlugin.Net8.dll"
LoadOnCommandInvocation="False" LoadOnAutoCADStartup="True">
<RuntimeRequirements OS="Win64" Platform="AutoCAD"
SeriesMin="R25.0" SeriesMax="R25.2"/>
</ComponentEntry>
</Components>
</ApplicationPackage>
The Iceberg
At this point you have a working plugin that places geometry, reads panel attributes, and loads automatically when AutoCAD starts. That gets you a long way. A team that ships one-off layer cleanup and panel-renaming utilities can absolutely live here.
Now try to build what Branch does.
Voltage window validation means reading Voc and Vmp for every module spec in your equipment database, pulling the temperature coefficients, applying the NEC 690.7(A) cold-temperature correction for your project's site (different for Phoenix and Minneapolis), and flagging every string that violates limits before the engineer submits for permit. That's not a Transaction pattern problem — it's a domain problem. The code is straightforward once you have the logic. Getting the logic right for every module/inverter combination in common use takes time.
Dynamic block parameter access for string counting works fine for standard single-module-type layouts. When a row has two module types because the array didn't divide evenly, you need to handle mixed-string voltage calculations. When panels are portrait on one section and landscape on another because of obstructions, your block-reading logic has to account for both orientations.
Homerun routing is K-means clustering over string endpoints with cable-length optimization — solvable, documented, and something we've written about in detail in how solar stringing automation works. But then: routing homeruns with clean 90-degree angles around structural conduit runs, across multiple roof sections with different elevations, without crossing strings — that's where the geometry work starts compounding.
Then cable schedules. Then NEC updates when the code cycle changes. Then supporting AutoCAD 2018 through 2026, where the ABI changes between versions mean your PackageContents.xml is a maintenance object that outlasts the code it describes.
The first version of Branch was written in a weekend. It placed strings. It was completely wrong for production use — no voltage window logic, no mixed-string support, no multi-version packaging. The two years after that weekend built what's actually in the product. If your solar projects look like the clean uniform array in this post, ship the code above and call it done. If they don't, Branch is what two years of that looks like.
For the predecessor to this question — whether to stay on AutoLISP or move to a compiled plugin at all — see AutoLISP scripts vs. solar plugins. For the full landscape of what's available today, see the best AutoCAD solar stringing software of 2026.