Adding a New Node Type
Every built-in type — Agent, LanguageModel, Thread — follows the same six-step recipe. The pattern is strict by design: miss any one piece and the symptoms cascade in ways that look unrelated (empty dropdowns, deserialization falling back to raw JsonElement, sticky cluster errors). Follow all six steps in order and the type just works.
Before you start, look at the reference implementations listed at the bottom of this page. Reading one concrete example end-to-end takes five minutes and prevents the most common mistakes.
The Six Required Pieces
A new node type needs all six of the following. The table below is a quick orientation; the sections that follow give the full detail. Six pieces wired together: steps ①–④ feed the module entry point ⑤, the optional static provider ⑥ emits built-in instances, and MeshBuilder.Build() activates the type.
| # | What | Why it matters |
|---|---|---|
| 1 | Content record | The typed payload for MeshNode.Content |
| 2 | NodeType definition class | Discriminator constant + partition meta-node |
| 3 | Add{NodeType}Type extension |
Wires the partition, access policy, and provider into the mesh |
| 4 | TypeRegistry entry | Maps the $type discriminator to the runtime type |
| 5 | Call in the module entry point | Keeps every type under one auditable umbrella |
| 6 | Static-node provider (if needed) | Emits built-in instances (agents, platform models, …) |
1. Content record
The deserialized payload that lives in MeshNode.Content. Keep it a plain record — data only, no behavior, no DI. This is the content-shape contract for the type.
// src/MeshWeaver.AI/ModelDefinition.cs
public record ModelDefinition
{
public required string Id { get; init; }
public string? DisplayName { get; init; }
public required string Provider { get; init; }
public string? Endpoint { get; init; }
public string? ApiKeySecretRef { get; init; }
public int Order { get; init; }
}
2. NodeType definition class
A static class that holds the discriminator constant and the partition meta-node. Mirrors AgentNodeType.cs and LanguageModelNodeType.cs.
public static class LanguageModelNodeType
{
public const string NodeType = "LanguageModel";
public const string RootNamespace = "Model";
public static MeshNode CreateMeshNode() => new(NodeType)
{
Name = "Language Model",
Icon = "/static/NodeTypeIcons/sparkle.svg",
AssemblyLocation = typeof(LanguageModelNodeType).Assembly.Location,
HubConfiguration = config => config
.AddMeshDataSource(source => source
.WithContentType<ModelDefinition>())
};
}
The HubConfiguration lambda tells per-node hubs to deserialize Content as ModelDefinition instead of leaving it as a raw JsonElement.
Skipping
WithContentType<T>()is the #1 cause of "my dropdown is empty even though the synced query returned 9 nodes" bugs. Content arrives unparsed and all downstreamContent is Tcasts fail silently.
3. `Add
This extension wires four things at builder time:
- The partition meta-node (so a
path:LanguageModellookup succeeds) - Public-read access policy for the type
- A static-node provider (only if the type has built-in instances)
- Any ancillary singletons the provider depends on
public static TBuilder AddLanguageModelType<TBuilder>(this TBuilder builder)
where TBuilder : MeshBuilder
{
builder.AddMeshNodes(CreateMeshNode());
builder.ConfigureNodeTypeAccess(a => a.WithPublicRead(NodeType));
builder.ConfigureServices(services =>
{
services.TryAddSingleton<LanguageModelCatalogOptions>();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IStaticNodeProvider, BuiltInLanguageModelProvider>());
return services;
});
return builder;
}
AddMeshNodes(CreateMeshNode())is mandatory. It registers the type definition so the type resolves by name (FindStaticNode) and itsHubConfigurationdelegate is available. Without it the type exists conceptually but is undiscoverable, and the catalog query that feeds the picker returns nothing.
🚨 If the type's catalog is served from the DB, the definition must be registered
IsDefinitionOnly = true. The sample above is the minimal shape; the realAddLanguageModelTypetakes aserveFromPartitionset and, when the catalog partition is DB-synced, (a) skips the in-memory static provider (Postgres serves the instances) and (b) registers the type-def as definition-only. Skip (b) and the per-node-hub persistence sampler auto-persists the type-def to a phantom schema named after the lowercased discriminator (languagemodel) that was never provisioned →42P01. Full rules: NodeType Catalogs.
⚠️ Namespace note for this example.
LanguageModelNodeType.RootNamespaceis still"Model", but that is the legacy partition name, honoured for backwards-compatible configs. The live model catalog lives under theProviderpartition — providers atProvider/{provider}, models nested atProvider/{provider}/{model}— and the composer's model picker queriesnamespace:Provider nodeType:LanguageModel scope:descendants sort:order, notnamespace:Model. Read the recipe for its shape, not for that constant.
4. TypeRegistry entry
The TypeRegistry maps $type JSON discriminators to runtime types. Without an entry, polymorphic deserialization falls through to JsonElement and all downstream Content is T checks fail silently.
// src/MeshWeaver.AI/AIExtensions.cs — AddAITypes()
public static ITypeRegistry AddAITypes(this ITypeRegistry typeRegistry)
=> typeRegistry
.WithType(typeof(AgentConfiguration), nameof(AgentConfiguration))
.WithType(typeof(ModelDefinition), nameof(ModelDefinition)) // ← this line
...;
AddAITypes() must then be called on every hub that reads the content — at minimum the mesh hub and every per-user portal hub. See AIExtensions.AddAI() for the canonical wiring:
.ConfigureHub(config => { config.TypeRegistry.AddAITypes(); return config; })
.ConfigureDefaultNodeHub(config => { config.TypeRegistry.AddAITypes(); ... })
A type registered on the mesh hub but missed on the portal hub deserializes correctly in queries that hit the mesh, but appears as raw JSON in queries scoped to the portal. The symptom: "the dropdown is full when I navigate but empty after a reload."
5. Wire-up call in AddAI() (or your module entry point)
This is where everything comes together. Every type belongs under one umbrella extension (AddAI(), AddGraph(), etc.) — never register a type directly from app code. This keeps the type catalog auditable and prevents "I added the type but forgot the registry" half-states.
public TBuilder AddAI()
{
return (TBuilder)builder
.AddThreadMessageType()
.AddThreadType()
.AddAgentType()
.AddLanguageModelType() // ← new line
.ConfigureServices(services => services.AddAgentChatServices())
.ConfigureHub(config => { config.TypeRegistry.AddAITypes(); return config; })
.ConfigureDefaultNodeHub(config => { config.TypeRegistry.AddAITypes(); ... });
}
6. Static-node provider (optional — for built-in instances)
If the type ships with built-in nodes (built-in agents, platform models, embedded markdown), implement IStaticNodeProvider and emit them from GetStaticNodes(). Two patterns are in common use:
Direct — ships from embedded resources:
public class BuiltInAgentProvider : IStaticNodeProvider
{
public IEnumerable<MeshNode> GetStaticNodes()
{
// Read embedded .md resources, parse frontmatter, emit MeshNodes.
}
}
Config-driven — reads IConfiguration at runtime:
public class BuiltInLanguageModelProvider : IStaticNodeProvider
{
public BuiltInLanguageModelProvider(
IConfiguration configuration,
LanguageModelCatalogOptions options)
{ ... }
}
Note that the constructor takes a plain singleton options object, not IOptions<T>. The IOptions<> pipeline does not propagate Configure delegates across the mesh hub's DI scope — live namespace:Model queries returned only the access policy because Sources was empty at provider-resolve time. Use a direct singleton with idempotent Add() and mutate it from ConfigureServices blocks. See LanguageModelNodeType.AddLanguageModelCatalogSource for the helper.
Common Pitfalls
| Symptom | Root cause |
|---|---|
Content is JsonElement instead of a typed record |
TypeRegistry entry missing, or AddAITypes not called on the consuming hub |
path:Foo returns nothing |
AddMeshNodes(CreateMeshNode()) not called in the Add{NodeType}Type extension |
| Partition exists but dropdown is empty | Provider didn't emit; or IConfiguration is missing the section the provider reads |
nodeType:Foo\|Bar query returns Bar but not Foo |
Foo's TypeRegistry entry missing on one of the queried hubs |
| New type works in dev but not in prod | Type registered in the monolith config but not in the AppHost's per-process config (env vars, Parameters:*) |
Tests to Write
For each new node type, write two test layers — one for provider logic, one for the registration pipeline.
Unit test for the provider — catches logic regressions:
public class FooProviderTest
{
[Fact]
public void Provider_OneSection_OneNodePerEntry() { ... }
}
Integration test for the synced-query path — catches registration-pipeline regressions. This is the hardest class of bug because the runtime symptom (empty dropdowns) is far removed from the cause (a missing wiring step). Back it with MonolithMeshTestBase:
public class FooSyncedQueryTest : MonolithMeshTestBase
{
protected override MeshBuilder ConfigureMesh(MeshBuilder builder)
=> builder.UseMonolithMesh()
.ConfigureServices(s => s.AddInMemoryPersistence(new InMemoryPersistenceService()))
.AddAI();
[Fact]
public async Task SyncedQuery_NodeTypeFoo_ReturnsConfiguredCatalog() { ... }
}
See test/MeshWeaver.Hosting.Monolith.Test/LanguageModelSyncedQueryTest.cs for the canonical example.
Consuming the New Type's Instances
Anything that reads nodeType:LanguageModel (or any synced collection of MeshNodes) must go through workspace.GetQuery(id, queries...) — the SyncedQueryMeshNodes API. See Synced Mesh Node Queries for the full rationale and canonical patterns.
The short version:
workspace.GetQuery(id, "namespace:Foo nodeType:Foo")— gives you provider fan-out (including static nodes), all-Initial gating, path-keyed dedup, and workspace-level caching.- Do not roll your own merge with
IMeshService.Query— you lose every one of those properties. Most notably,IMeshQueryCore.Querydoes NOT see static-node-provider entries; your dropdown will be silently empty even though MCP shows 9 nodes.
Reference Implementations
| Type | Files | Notes |
|---|---|---|
| Agent | src/MeshWeaver.AI/AgentNodeType.cs + BuiltInAgentProvider.cs |
Embedded-markdown static provider |
| LanguageModel | src/MeshWeaver.AI/LanguageModelNodeType.cs + BuiltInLanguageModelProvider.cs |
Config-driven static provider |
| Thread / ThreadMessage | src/MeshWeaver.AI/ThreadNodeType.cs |
No static provider; content-only |