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. ① Content Record ModelDefinition ② NodeType Definition discriminator + meta-node ③ Add Extension partition + access + provider ④ TypeRegistry Entry $type → runtime type ⑤ Module Entry Point AddAI() / AddGraph() umbrella ⑥ Static Node Provider built-in instances (optional) MeshBuilder.Build() type is live in the mesh 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 downstream Content is T casts fail silently.


3. `Add

This extension wires four things at builder time:

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 its HubConfiguration delegate 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 real AddLanguageModelType takes a serveFromPartition set 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.RootNamespace is still "Model", but that is the legacy partition name, honoured for backwards-compatible configs. The live model catalog lives under the Provider partition — providers at Provider/{provider}, models nested at Provider/{provider}/{model} — and the composer's model picker queries namespace:Provider nodeType:LanguageModel scope:descendants sort:order, not namespace: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:


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
Reconnecting…
The server was updated. Reloading the page to pick up the latest version.