AI Integrations¶
Foundry provides first-class integrations for AI agent frameworks, taking care of function discovery, workflow wiring, Harness composition, and factory lifecycle so that you focus on writing agent logic rather than plumbing.
Three upstream ecosystems are supported:
- Microsoft Agent Framework package family — core generated tools and agents, with separate packages for workflows, testing, the optional complete Harness bundle, and Needlr integration
- Semantic Kernel (
NexusLabs.Foundry.Needlr.SemanticKernel) — for[KernelFunction]-annotated plugin classes wired into aKernelviaMicrosoft.SemanticKernel - GitHub Copilot (
NexusLabs.Foundry.Copilot) — anIChatClientbacked by the GitHub Copilot API, plus a web searchAIFunction. See the Copilot integration page for details.
The Microsoft Agent Framework and Semantic Kernel integrations follow the same two-layer architecture explained below.
The Two-Layer Model¶
Understanding what Foundry owns versus what the upstream framework owns is the key to understanding which parts are AOT-compatible and which are not.
Layer 1 — Discovery (Foundry)¶
What: Identifying which types in an assembly contain annotated methods.
For MAF this means finding classes with [AgentFunction] methods. For SK this means finding classes with [KernelFunction] methods. This is purely a type-collection step — no instances are created, no schemas are built.
Foundry provides two paths for this layer:
| Path | How | AOT safe? |
|---|---|---|
| Source generation | At compile time, a Roslyn generator scans for the attribute and emits a static IReadOnlyList<Type> |
✅ Yes |
| Reflection | At runtime, assemblies are scanned for the attribute | ❌ No ([RequiresUnreferencedCode]) |
The reflection overloads (AddAgentFunctionsFromAssemblies(), AddSemanticKernelPluginsFromAssemblies(), etc.) are annotated with [RequiresUnreferencedCode] and [RequiresDynamicCode] to surface this at the call site.
Layer 2 — Function wrapper construction¶
What: Turning annotated methods into AIFunction objects with JSON schemas and argument marshalling.
Foundry's MAF source generator emits concrete AIFunction implementations, including schemas and argument coercion, and registers an IAIFunctionProvider through a module initializer. This path does not call reflection-based AIFunctionFactory.Create(MethodInfo, target) at runtime.
The MAF reflection path still uses AIFunctionFactory.Create(MethodInfo, target). Semantic Kernel plugin construction remains upstream-owned and may use reflection even when Foundry generated discovery supplies the plugin types.
What this means in practice¶
For Foundry MAF tools, source generation removes runtime assembly scanning and reflection-based function wrapper construction. The practical effect is:
- No
[RequiresUnreferencedCode]warnings from Foundry's own discovery code - No dynamic
MethodInfo-based wrapper construction for generated functions - Faster startup and a NativeAOT-compatible minimum Harness profile
Dynamic skills, scripts, reflection fallbacks, and other provider features can still carry their own trimming constraints. See Microsoft Agent Framework Harness for the tested minimum profile.
Microsoft Agent Framework¶
Packages¶
<!-- Core runtime: agents, generated tools, diagnostics, progress, workspace -->
<PackageReference Include="NexusLabs.Foundry.MicrosoftAgentFramework" />
<!-- Add only when you use workflow APIs such as UsingResilience -->
<PackageReference Include="NexusLabs.Foundry.MicrosoftAgentFramework.Workflows" />
<!-- Add only when you use deterministic scenario runners -->
<PackageReference Include="NexusLabs.Foundry.MicrosoftAgentFramework.Testing" />
<!-- Add only when you use the complete upstream Harness bundle -->
<PackageReference Include="NexusLabs.Foundry.MicrosoftAgentFramework.Harness" />
<!-- Required by the Syringe/UsingAgentFramework sample below -->
<PackageReference Include="NexusLabs.Foundry.Needlr.MicrosoftAgentFramework" />
<!-- Source generator (add as analyzer — no runtime dep) -->
<PackageReference Include="NexusLabs.Foundry.MicrosoftAgentFramework.Generators"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
Agent construction choices¶
| Choice | Use when | Ownership |
|---|---|---|
| Plain Foundry MAF agent | You want generated agents/tools and ordinary AIAgent construction |
Foundry |
| Optional complete Harness bundle | You want the official batteries-included upstream pipeline | Harness owns loop and OpenTelemetry; Foundry can add progress |
| Selected-provider candidate | You are contributing conformance work inside Foundry | Internal, not a consumer API |
| Iterative loop | Workspace files should drive fresh per-iteration prompts | Foundry outer loop |
See Microsoft Agent Framework Harness for complete-bundle configuration, effective defaults, progress, AOT, and current limitations.
The packages are independent choices. Installing the core runtime alone does
not provide UsingAgentFramework, workflow middleware, scenario runners, or
the complete Harness factory.
Quick start¶
using NexusLabs.Foundry.MicrosoftAgentFramework;
using NexusLabs.Needlr.Injection;
using NexusLabs.Needlr.Injection.Reflection;
internal sealed class WeatherTools
{
[AgentFunction]
[Description("Gets the current temperature for a city.")]
public string GetTemperature(string city) => $"22°C in {city}";
}
var agentFactory = new Syringe()
.UsingReflection()
.UsingAgentFramework(af => af
.UsingChatClient(sp => sp.GetRequiredService<IChatClient>())
.AddAgentFunctionsFromAssemblies()) // reflection path
.BuildServiceProvider(configuration)
.GetRequiredService<IAgentFactory>();
var agent = agentFactory.CreateAgent(opts =>
{
opts.Instructions = "You are a helpful weather assistant.";
opts.FunctionTypes = [typeof(WeatherTools)];
});
Source gen path (recommended)¶
When NexusLabs.Foundry.MicrosoftAgentFramework.Generators is referenced as an analyzer, it emits a generated IAIFunctionProvider and registers it through a module initializer:
if (!AgentFrameworkGeneratedBootstrap.TryGetAIFunctionProvider(
out var functionProvider) ||
!functionProvider.TryGetFunctions(
typeof(WeatherTools),
services,
out var functions))
{
throw new InvalidOperationException("Generated weather tools were unavailable.");
}
AddFoundryAgentFramework and generated factory paths consume the registered provider automatically. Lower-level callers, including the optional Harness bundle, can pass the resolved functions directly through ChatOptions.Tools.
The generated provider path carries no reflection fallback and is used by the executed AotHarnessApp NativeAOT fixture.
Published function and parameter names¶
A tool name is part of the model contract, not an implementation detail. It appears in
the tool schema sent to the model, comes back in FunctionCallContent, and is used by
tool-name metrics, termination conditions, transcripts, and evaluation datasets.
Renaming a C# method therefore renames all of those by default.
MEAI's naming attributes let the public contract stay stable across a refactor:
#pragma warning disable MEAI001
[AgentFunction]
[AIFunctionName("get_temperature")]
[Description("Gets the current temperature for a city.")]
public string GetTemperature(
[AIParameterName("city_name")]
[Description("The city to look up.")] string city) =>
$"22°C in {city}";
#pragma warning restore MEAI001
AIFunctionNameAttribute and AIParameterNameAttribute are experimental MEAI APIs,
which is why using them produces MEAI001. Foundry does not duplicate them with its
own naming properties. The reflection path already delegates to AIFunctionFactory;
the generated path reads the same attributes and emits the same function name, JSON
schema keys, required-property list, and argument lookup.
Declaring a name replaces the C# identifier in the model contract. A function
published as get_temperature is no longer resolved as GetTemperature, and the
model must supply city_name, not city. Changing the published name is therefore
still a breaking contract change; declaring it only moves the stable name into a
place the author controls.
Blank names are rejected by FDRYMAF032. Duplicate names
within one function type or one parameter list are rejected by
FDRYMAF033. Same-named functions in separate types remain
valid when agents use them independently; IAgentFactory fails closed only if an
agent actually resolves both into one ambiguous tool set.
src/Examples/AgentFramework/HarnessProviderApp uses published names for both
workspace tools and their parameters. Its scripted provider pins the generated
contract offline, and its Harness__Provider=copilot mode exercises a real model
calling write_note and read_note.
Per-agent tool scoping¶
Multiple agents can be created from the same IAgentFactory, each with a tailored subset of the registered tools:
// Agent 1: geography tools only
var geographyAgent = agentFactory.CreateAgent(opts =>
{
opts.Instructions = "You are a geography expert.";
opts.FunctionTypes = [typeof(GeographyFunctions)];
});
// Agent 2: no tools (pure reasoning)
var writerAgent = agentFactory.CreateAgent(opts =>
{
opts.Instructions = "You are a technical writer.";
opts.FunctionTypes = [];
});
// Agent 3: all registered tools (default when FunctionTypes is null)
var generalAgent = agentFactory.CreateAgent();
FunctionTypes = null means all registered types are available. FunctionTypes = [] means no tools.
Recommended: surviving tool-call failures¶
Tool bodies throw — sometimes from the user's logic (NRE, validation), sometimes from infrastructure (DB timeout, transient HTTP). By default the exception bubbles all the way to FunctionInvokingChatClient and fails the entire agent turn. Call .UsingToolResultMiddleware() on the AgentFrameworkBuilder to catch these exceptions and translate them into structured { error: … } results the LLM can recover from:
serviceProvider.UsingAgentFramework()
.AddAgentFunctionsFromGenerated(...)
.UsingResilience() // Innermost — retries first
.UsingToolResultMiddleware() // Outermost — catches what resilience couldn't recover
.BuildAgentFactory();
See Tool Result Middleware for the full behavior breakdown, ordering rules, and trade-offs.
Semantic Kernel¶
Packages¶
<!-- Runtime -->
<PackageReference Include="NexusLabs.Foundry.Needlr.SemanticKernel" />
<!-- Source generator (add as analyzer — no runtime dep) -->
<PackageReference Include="NexusLabs.Foundry.Needlr.SemanticKernel.Generators"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
Quick start¶
using NexusLabs.Needlr.Injection;
using NexusLabs.Needlr.Injection.Reflection;
using NexusLabs.Foundry.Needlr.SemanticKernel;
internal sealed class WeatherPlugin
{
[KernelFunction]
[Description("Gets the current temperature for a city.")]
public string GetTemperature(string city) => $"22°C in {city}";
}
var kernelFactory = new Syringe()
.UsingReflection()
.UsingSemanticKernel(sk => sk
.Configure(opts => opts.KernelBuilderFactory = sp =>
Kernel.CreateBuilder()
.AddAzureOpenAIChatCompletion(deploymentName, endpoint, apiKey))
.AddSemanticKernelPluginsFromAssemblies()) // reflection path
.BuildServiceProvider(configuration)
.GetRequiredService<IKernelFactory>();
var kernel = kernelFactory.CreateKernel();
var result = await kernel.InvokePromptAsync("What is the weather today?");
Source gen path (recommended)¶
When NexusLabs.Foundry.Needlr.SemanticKernel.Generators is referenced as an analyzer, it emits a compile-time registry:
// Generated: SemanticKernelPlugins.g.cs
namespace YourAssemblyName.Generated;
public static class KernelPluginRegistry
{
public static IReadOnlyList<(Type PluginType, bool IsStatic)> Entries { get; } = new (Type, bool)[]
{
(typeof(WeatherPlugin), false),
// ...
};
}
Pass this to AddSemanticKernelPluginsFromGenerated:
.UsingSemanticKernel(sk => sk
.Configure(opts => opts.KernelBuilderFactory = sp => ...)
.AddSemanticKernelPluginsFromGenerated(
YourAssemblyName.Generated.KernelPluginRegistry.Entries))
Multi-Agent Orchestration¶
Foundry extends the IoC principle from the tool layer upward to the agent and topology layers. Agents are declared as plain C# classes with attributes; Foundry discovers them, builds the workflow graph, and emits source-generated factory methods. Adding a new agent role means adding a class, not editing orchestration wiring.
Packages¶
<!-- Runtime: agents, workflows, termination conditions -->
<PackageReference Include="NexusLabs.Foundry.MicrosoftAgentFramework" />
<PackageReference Include="NexusLabs.Foundry.MicrosoftAgentFramework.Workflows" />
<!-- Source generator (analyzer — no runtime dep) -->
<PackageReference Include="NexusLabs.Foundry.MicrosoftAgentFramework.Generators"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
<!-- Roslyn analyzers (optional but recommended) -->
<PackageReference Include="NexusLabs.Foundry.MicrosoftAgentFramework.Analyzers"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
Declaring agents¶
The [FoundryAgent] attribute marks a class as a registered agent. It accepts Instructions, Description, FunctionTypes, and FunctionGroups to configure the agent's identity and tool access.
[FoundryAgent(
Instructions = "You are a geography expert. Answer questions about countries and cities.",
Description = "Handles geographic queries")]
public class GeographyAgent { }
[FoundryAgent(
Instructions = "You are a travel writer. Produce engaging summaries.",
FunctionGroups = ["travel"])] // scoped to the "travel" function group
public class TravelWriterAgent { }
[FoundryAgent(
Instructions = "You triage requests and route them.",
FunctionTypes = [])] // empty array → no tools (pure routing)
public class TriageAgent { }
Foundry discovers all [FoundryAgent] classes in the compilation and emits a static agent registry at build time.
Function groups¶
[AgentFunctionGroup] buckets related tool methods under a named group. Agents reference groups by name via FunctionGroups, keeping tool scoping declarative and typo-detectable (FDRYMAF005).
[AgentFunctionGroup("geography")]
internal sealed class GeographyFunctions
{
[AgentFunction]
[Description("Returns countries the user has lived in.")]
public IReadOnlyList<string> GetCountriesLived() => ["Canada", "USA"];
[AgentFunction]
[Description("Returns the user's favourite cities.")]
public IReadOnlyList<string> GetFavoriteCities() => ["Toronto", "New York"];
}
Scoping rules:
FunctionTypes |
FunctionGroups |
Tools agent receives |
|---|---|---|
null |
null |
All registered types (default) |
[] (empty) |
— | No tools |
[typeof(T)] |
— | Only T |
| — | ["group"] |
All types in the named group |
Topology types¶
Foundry supports four topology patterns. Each is declared with attributes; the source generator emits a corresponding typed factory method on IWorkflowFactory.
Handoff¶
One agent handles a request and optionally routes it to another agent when a condition is met. The routing decision is made by the LLM at runtime; the When parameter describes the condition as a natural language hint for the model.
[FoundryAgent(Instructions = "Triage the request and hand off.")]
[AgentHandoffsTo(typeof(GeographyAgent), When = "The question is about geography")]
[AgentHandoffsTo(typeof(TravelWriterAgent), When = "The question is about travel writing")]
public class TriageAgent { }
Generator emits: factory.CreateTriageAgentHandoffWorkflow()
Group chat¶
Multiple agents collaborate in a shared round-robin conversation. All participants are peers; the workflow runs for up to maxIterations turns unless a termination condition triggers earlier.
[FoundryAgent(Instructions = "Review code for correctness.")]
[AgentGroupChatMember("code-review")]
public class ReviewerAgent { }
[FoundryAgent(Instructions = "Author code changes based on review feedback.")]
[AgentGroupChatMember("code-review")]
public class AuthorAgent { }
Generator emits: factory.CreateCodeReviewGroupChatWorkflow()
A group chat requires at least two members (FDRYMAF002).
Sequential pipeline¶
Agents run in a fixed order, each receiving the prior agent's output. Use Order to control the sequence.
[FoundryAgent(Instructions = "Extract key facts from the source material.")]
[AgentSequenceMember("content-pipeline", Order = 1)]
public class ContentExtractorAgent { }
[FoundryAgent(Instructions = "Enrich the extracted facts with examples.")]
[AgentSequenceMember("content-pipeline", Order = 2)]
public class ContentEnricherAgent { }
[FoundryAgent(Instructions = "Publish the enriched content.")]
[AgentSequenceMember("content-pipeline", Order = 3)]
public class ContentPublisherAgent { }
Generator emits: factory.CreateContentPipelineSequentialWorkflow()
Graph / DAG (Phase 1)¶
Agents form a directed acyclic graph with conditional routing, fan-out, and fan-in convergence. Edges are declared on source agents; the graph name groups them.
[FoundryAgent(Instructions = "Analyze the request and route to research paths.")]
[AgentGraphEntry("research", RoutingMode = GraphRoutingMode.AllMatching)]
[AgentGraphEdge("research", typeof(WebResearchAgent), Condition = "NeedsWebData")]
[AgentGraphEdge("research", typeof(DatabaseAgent), Condition = "NeedsDbLookup")]
[AgentGraphEdge("research", typeof(SummarizerAgent))]
public class AnalyzerAgent { }
[FoundryAgent(Instructions = "Search the web for data.")]
[AgentGraphEdge("research", typeof(SummarizerAgent))]
public class WebResearchAgent { }
[FoundryAgent(Instructions = "Query internal databases.")]
[AgentGraphEdge("research", typeof(SummarizerAgent))]
public class DatabaseAgent { }
[FoundryAgent(Instructions = "Synthesize findings into a report.")]
[AgentGraphNode("research", JoinMode = GraphJoinMode.WaitAll)]
public class SummarizerAgent { }
Runtime: Use RunGraphAsync for execution — it auto-selects the optimal executor:
// RunGraphAsync handles both WaitAll and WaitAny graphs automatically:
// - WaitAll-only graphs → MAF's native BSP executor
// - Graphs with WaitAny nodes → Foundry's executor using Task.WhenAny
var results = await factory.RunGraphAsync("research", question);
Alternatively, factory.CreateGraphWorkflow("research") returns the raw MAF Workflow object for direct integration with MAF tooling — but this only supports WaitAll. If you use CreateGraphWorkflow on a graph with WaitAny nodes, analyzer FDRYMAF025 reports a compile-time error directing you to RunGraphAsync.
A runnable end-to-end example lives in src/Examples/AgentFramework/GraphWorkflowApp/. It uses CopilotChatClient (no Azure credentials required) to run a four-agent research pipeline DAG with fan-out from an analyzer to parallel web/database research branches that converge at a summarizer.
Key attributes:
| Attribute | Purpose |
|---|---|
[AgentGraphEntry] |
Marks the entry point; sets RoutingMode |
[AgentGraphEdge] |
Declares a directed edge with optional Condition and IsRequired |
[AgentGraphNode] |
Per-node join semantics (WaitAll / WaitAny) |
DAG Diagnostics¶
After running a DAG workflow, cast the result to IDagRunResult for per-node diagnostics:
var result = (IDagRunResult)await InProcessExecution.RunAsync(workflow, input, ct);
Console.WriteLine($"DAG {(result.Succeeded ? "succeeded" : "failed")} " +
$"in {result.TotalDuration.TotalSeconds:F1}s, " +
$"nodes: {result.NodeResults.Count}");
foreach (var (nodeId, nodeResult) in result.NodeResults)
{
Console.WriteLine($" {nodeId}: {nodeResult.Kind}, " +
$"{nodeResult.Duration.TotalMilliseconds}ms");
}
foreach (var (branchId, stages) in result.BranchResults)
{
Console.WriteLine($" Branch {branchId}: {stages.Count} stages");
}
IDagRunResult extends IPipelineRunResult, so existing code that consumes the flat Stages list continues to work unchanged. The NodeResults dictionary provides the richer graph-aware view with edge connectivity, timing offsets, and the NodeKind discriminator (Agent vs Reducer).
DAG Progress Events¶
DAG workflows emit the standard progress events (AgentInvokedEvent, SuperStepStartedProgressEvent, etc.) with optional DAG-specific metadata:
| Field | Type | Event | Purpose |
|---|---|---|---|
GraphName |
string? |
AgentInvokedEvent |
Identifies which named graph is executing |
NodeId |
string? |
AgentInvokedEvent, ReducerNodeInvokedEvent |
Identifies the node within the graph |
BranchId |
string? |
AgentInvokedEvent, ReducerNodeInvokedEvent |
Identifies the parallel branch |
IncomingEdgeLabel |
string? |
AgentInvokedEvent |
The condition label of the activating edge |
ParallelBranchCount |
int? |
SuperStepStartedProgressEvent |
Active parallel branches in this superstep |
Reducer nodes (deterministic aggregation functions) emit ReducerNodeInvokedEvent instead of AgentInvokedEvent, carrying InputBranchCount and Duration without LLM-specific metadata.
See ADR-0001 for the full design rationale.
Running workflows¶
Obtain an IWorkflowFactory from DI and use the generated extension methods. The factory resolves and wires agents automatically.
var factory = serviceProvider.GetRequiredService<IWorkflowFactory>();
var workflow = factory.CreateTriageAgentHandoffWorkflow();
var responses = await workflow.RunAsync("Which countries has Nick visited?");
// responses: IReadOnlyDictionary<string, string> (agentId → text)
RunAsync is an extension method from NexusLabs.Foundry.MicrosoftAgentFramework.Workflows that wraps the underlying MAF streaming execution.
When a fixed macro workflow needs recovery between autonomous phases, use the checkpoint-aware streaming extensions instead of a terminal convenience method. See Checkpointed Workflows for caller-owned storage, artifact boundaries, restore, replay, cancellation, and NativeAOT behavior.
Termination conditions¶
Termination conditions let you stop a workflow early when a content-based criterion is met. Two layers are available:
Layer 1 — group chat (fires before the next turn):
[AgentTerminationCondition] on a group chat member. The condition is evaluated inside MAF's group chat loop after each agent response. When it triggers, the current turn is the last one — the next agent is never called.
[AgentGroupChatMember("code-review")]
[AgentTerminationCondition(typeof(KeywordTerminationCondition), "APPROVED")]
public class ReviewerAgent { }
Layer 2 — workflow-level (fires after a response is fully emitted):
[WorkflowRunTerminationCondition] on any agent. The condition is evaluated in Foundry's RunAsync event loop after the agent's complete response is received. Works for all topology types; for group chat, prefer Layer 1 (FDRYMAF011).
[AgentSequenceMember("content-pipeline", Order = 1)]
[WorkflowRunTerminationCondition(typeof(KeywordTerminationCondition), "EXTRACTION_FAILED")]
public class ContentExtractorAgent { }
Conditions are evaluated for every agent's turn¶
In both layers, the class an attribute is applied to determines only which workflow the condition is
wired into — not whose turns it runs against. Every declared condition is collected into one set
and offered every turn, so in the example above KeywordTerminationCondition("APPROVED") stops the
workflow whenever any member of code-review says APPROVED, not only ReviewerAgent.
That is usually what you want for an approval gate. When it isn't, the condition has to scope itself
using TerminationContext.AgentId:
public sealed class ApprovedByReviewer : IWorkflowTerminationCondition
{
public bool ShouldTerminate(TerminationContext context) =>
context.AgentId == "ReviewerAgent"
&& context.LastMessage?.Text?.Contains("APPROVED") == true;
}
Two things to know about AgentId before comparing against it:
- It differs by layer. Under Layer 1 it is the agent's published name — the class name, or
[FoundryAgent(Name = "...")]when one is declared. Under Layer 2 it is the workflow executor id. A condition written for one layer will not necessarily match under the other. - It can be empty. The initial input message carries no author, so the first evaluation sees an empty string. Compare defensively rather than assuming an agent name is always present.
The built-in conditions do not scope themselves, by design — they match on content alone.
When an agent carries [WorkflowRunTerminationCondition], the generator emits a paired Run*Async method that packages creation and execution together with the declared conditions already wired in:
// Generated Run*Async bundles conditions automatically
var responses = await factory.RunContentPipelineSequentialWorkflowAsync(message);
Built-in conditions: KeywordTerminationCondition, RegexTerminationCondition, ToolCallTerminationCondition. Custom conditions implement IWorkflowTerminationCondition.
Topology graph diagnostic¶
Set FoundryDiagnostics=true in the agent class library's project properties to emit a Mermaid diagram of the agent topology at build time:
The diagram is written to bin/{Configuration}/{TFM}/FoundryDiagnostics/AgentTopologyGraph.md and visualises all declared handoff, group chat, and sequential topologies in the compilation.
Philosophy: IoC for AI Components¶
Foundry applies the same inversion-of-control principle to AI components that a DI container applies to services. Rather than manually creating tool objects and passing them to an agent, you declare what a method is ([AgentFunction] / [KernelFunction]) and Foundry assembles the tools automatically. The component declares itself; the framework wires it.
This principle now applies at three layers:
- Tool layer: methods are discovered, schema-built, and injected into the right agent or kernel instance
- Agent layer: agent definitions (instructions, tool groups, name) are declared as types, auto-discovered, and instantiated from the registry — the same pattern
[AgentFunction]establishes, one level up - Topology layer: relationships between agents are declared as attributes; Foundry builds the orchestration graph and emits typed factory methods automatically
Adding a new agent role to a system means adding a class. Adding it to a topology means adding an attribute. The orchestration wiring is owned by the framework, not the application — the same promise dependency-injection containers deliver for services.