Goal
Build a working C# console app that loads a published Speckle model, counts elements by category, and writes a CSV report you can open in Excel.
What you will build
A console application that:
Reads credentials and target IDs from environment variables
Loads the latest version of a model from Speckle Server
Traverses the object graph and groups elements by category
Writes model-health-report.csv with category names and counts
When to use this
Use this pattern when you need a first real automation — BIM QA summaries, quantity checks, or a starting point for door schedules and property audits. This is the recommended onboarding path before connector development or advanced architecture.
Recommended approach
The simplest path for most AEC automation scripts:
Paste the one-time AddSpeckleSdk bootstrap (see Automate with scripts )
Authenticate with a personal access token from SPECKLE_TOKEN
Fetch the latest version with client.Version.GetVersions
Load the object graph with Receive2
Use Flatten() and read DataObject.properties for BIM fields
Write results to CSV with StreamWriter
Avoid dependency injection beyond the bootstrap, repository patterns, and custom abstractions until you have a script that runs end to end.
Prerequisites
.NET SDK installed
A personal access token from Speckle
A published model on Speckle Server (from a connector or prior send)
Project ID and model ID from the Speckle web app URL: https://app.speckle.systems/projects/{projectId}/models/{modelId}
Create a console project and install packages:
dotnet new console -n ModelAnalysis
cd ModelAnalysis
dotnet add package Speckle.Sdk
dotnet add package Speckle.Objects
Complete example
Complete example using System . Globalization ;
using Microsoft . Extensions . DependencyInjection ;
using Speckle . Objects . Data ;
using Speckle . Sdk ;
using Speckle . Sdk . Api ;
using Speckle . Sdk . Credentials ;
using Speckle . Sdk . Models ;
using Speckle . Sdk . Models . Extensions ;
static class SpeckleBootstrap
{
static readonly ServiceProvider Provider = new ServiceCollection ()
. AddSpeckleSdk (
new Application ( "Model Analysis" , "model-analysis" ),
"1.0.0" ,
typeof ( Speckle . Objects . Geometry . Point ). Assembly
)
. BuildServiceProvider ();
public static IOperations Operations => Provider . GetRequiredService < IOperations >();
public static IAccountFactory AccountFactory => Provider . GetRequiredService < IAccountFactory >();
public static IClientFactory ClientFactory => Provider . GetRequiredService < IClientFactory >();
}
var token = Environment . GetEnvironmentVariable ( "SPECKLE_TOKEN" )
?? throw new InvalidOperationException ( "Set SPECKLE_TOKEN" );
var serverUrl = Environment . GetEnvironmentVariable ( "SPECKLE_SERVER" ) ?? "https://app.speckle.systems" ;
var projectId = Environment . GetEnvironmentVariable ( "SPECKLE_PROJECT_ID" )
?? throw new InvalidOperationException ( "Set SPECKLE_PROJECT_ID" );
var modelId = Environment . GetEnvironmentVariable ( "SPECKLE_MODEL_ID" )
?? throw new InvalidOperationException ( "Set SPECKLE_MODEL_ID" );
var account = await SpeckleBootstrap . AccountFactory . CreateAccount ( new Uri ( serverUrl ), token );
using var client = SpeckleBootstrap . ClientFactory . Create ( account );
var versions = await client . Version . GetVersions ( modelId , projectId , limit : 1 );
var latest = versions . items . FirstOrDefault ()
?? throw new InvalidOperationException ( "No versions found on this model" );
Console . WriteLine ( $"Loading version: { latest . id } ( { latest . message } )" );
var root = await SpeckleBootstrap . Operations . Receive2 (
client . ServerUrl ,
projectId ,
latest . referencedObject ! ,
account . token ,
onProgressAction : null ,
cancellationToken : default
);
static string GetCategory ( Base obj )
{
if ( obj is DataObject dataObject )
{
if ( dataObject . properties . TryGetValue ( "category" , out var cat ) && cat is string s )
return s ;
if ( dataObject . properties . TryGetValue ( "builtInCategory" , out var builtIn ) && builtIn is string b )
return b ;
}
return obj [ "category" ] as string ?? obj . speckle_type ?? "Unknown" ;
}
var counts = root . Flatten ()
. GroupBy ( GetCategory )
. OrderByDescending ( g => g . Count ())
. ToDictionary ( g => g . Key , g => g . Count ());
var outputPath = Path . Combine ( Environment . CurrentDirectory , "model-health-report.csv" );
await using ( var writer = new StreamWriter ( outputPath ))
{
await writer . WriteLineAsync ( "Category,Count" );
foreach ( var ( category , count ) in counts )
{
var escaped = category . Contains ( ',' ) ? $" \" { category } \" " : category ;
await writer . WriteLineAsync ( $" { escaped } , { count } " );
}
}
Console . WriteLine ( $"Wrote { counts . Count } categories to { outputPath } " );
foreach ( var ( category , count ) in counts . Take ( 10 ))
Console . WriteLine ( $" { category } : { count } " );
Set environment variables before running:
export SPECKLE_TOKEN = "your_token_here"
export SPECKLE_PROJECT_ID = "your_project_id"
export SPECKLE_MODEL_ID = "your_model_id"
dotnet run
How it works
Bootstrap. AddSpeckleSdk registers IOperations, IClientFactory, and IAccountFactory. You paste this once per app — see Automate with scripts .
Authentication. IAccountFactory.CreateAccount builds an in-memory Account from your PAT. No local account database is required for scripts.
Latest version. client.Version.GetVersions returns versions for a model; the API returns the newest first when limit is 1. The version’s referencedObject id points at the root of the object graph.
Receive. Receive2 downloads and deserializes the full graph. This is the recommended server-facing receive path for scripts — no transport factory required.
Traverse. Flatten() walks every Base in the graph. Connector-published BIM data is usually DataObject with semantic fields in properties — for example category, level, or fireRating.
Report. Grouping by category produces a simple model health summary. Extend the same pattern for door schedules or property audits — see Export model data to CSV .
Common mistakes
Mistake What happens Fix Missing SPECKLE_PROJECT_ID or SPECKLE_MODEL_ID App throws on startup Copy IDs from the Speckle web app URL Model has no published versions No versions foundPublish from a connector first Reading obj["category"] on all objects Many Unknown rows Prefer DataObject.properties for connector data Calling Flatten() inside a loop for each query Slow on large models Build one index or dictionary, then query it Forgetting Speckle.Objects in AddSpeckleSdk assemblies Geometry types deserialize as plain Base Pass typeof(Point).Assembly in bootstrap
Next steps
Export model data to CSV Door schedules and room lists with specific columns
Find objects by property Filter walls, check required properties, find duplicate IDs
BIM data patterns How connector data is structured in v3
Last modified on July 10, 2026