Universal.Operative.Sdk
3.2.0
See the version list below for details.
dotnet add package Universal.Operative.Sdk --version 3.2.0
NuGet\Install-Package Universal.Operative.Sdk -Version 3.2.0
<PackageReference Include="Universal.Operative.Sdk" Version="3.2.0" />
<PackageVersion Include="Universal.Operative.Sdk" Version="3.2.0" />
<PackageReference Include="Universal.Operative.Sdk" />
paket add Universal.Operative.Sdk --version 3.2.0
#r "nuget: Universal.Operative.Sdk, 3.2.0"
#:package Universal.Operative.Sdk@3.2.0
#addin nuget:?package=Universal.Operative.Sdk&version=3.2.0
#tool nuget:?package=Universal.Operative.Sdk&version=3.2.0
About
Universal.Operative.Sdk is a provider-neutral framework for building long-running, agentic LLM-driven applications. It defines the domain model (Conversation, Message, content Blocks), the extension seams (IModel, ITool, ITransformer), and two complementary building blocks that sit on top of them: an Engine, which drives a single agentic turn end-to-end (stream a model response, dispatch any tool calls, feed results back, loop until the model stops), and an Operative, which wraps an Engine around one ongoing Conversation and decides whether a new inbound message starts a fresh turn or steers into one already running. Pair it with Universal.Operative.Sdk.Anthropic or Universal.Operative.Sdk.OpenAI for a concrete IModel.
How to Use
Installation
dotnet add package Universal.Operative.Sdk
Quick start: a single turn
Use Engine directly when there's no ongoing session to manage — a one-shot request/response, a batch job, a single CLI invocation.
using Universal.Operative.Sdk;
IModel model = /* an IModel, e.g. from Universal.Operative.Sdk.Anthropic or .OpenAI */;
IEngine engine = EngineBuilder.FromDefaults(model)
.AddBaselineTools()
.Build();
var conversation = new Conversation
{
System = new List<Block> { new TextBlock("You are a helpful assistant.") },
Messages = { new Message { Role = MessageRoles.User, Content = { new TextBlock("List the files in this repo.") } } },
};
await foreach (EngineEvent e in engine.ExecuteAsync(conversation))
{
switch (e)
{
case ModelStreamEvent s: break;
case AssistantMessageEvent a: Console.WriteLine(a.Message.Content); break;
case ToolCallStartedEvent t: Console.WriteLine($"Running {t.Call.Name}..."); break;
case TurnCompletedEvent done: Console.WriteLine($"Done: {done.Reason}"); break;
}
}
AddBaselineTools() registers Read, Write, Edit, Glob, Grep, a shell (Bash and/or PowerShell, by availability), TodoWrite, and NotebookEdit. EngineBuilder.FromDefaults wires a pre-transformer stack (boundary-aware history windowing, tool-result budgeting, history snipping, auto-compaction) so long-running conversations don't blow the model's context window; use new EngineBuilder() instead to opt into transformers individually. conversation.Messages holds the full transcript once the turn ends, including tool calls and results.
Quick start: a long-running session
Use IOperative when something outside your control — a chat UI, a Slack app, a webhook — can send a new message while a turn is already running, and you want that message to steer the in-progress turn instead of queuing behind it.
IOperative bot = OperativeBuilder.Create(engine, conversation).Build();
SubmitResult result = await bot.SubmitAsync(
new Message { Role = MessageRoles.User, Content = { new TextBlock("hi") } });
await foreach (OperativeEvent e in result.Events)
{
if (e is EngineEventOccurred occurred)
{
// same EngineEvent types as the IEngine example above
}
}
Whether this call started the turn (result.Outcome == SubmitOutcome.StartedTurn) or steered a message into one already running (SubmitOutcome.Injected), result.Events is the same live remainder of that turn either way — one shared consciousness: every caller who touched a turn sees its real outcome, not just whoever happened to start it. Call bot.ResumeAsync() to continue the conversation with no new input at all — e.g. after a restart, when the loaded conversation already ends in an unanswered message; if a turn is already running this joins it too (SubmitOutcome.AlreadyRunning) rather than starting a redundant second one.
Two OperativeBuilder options worth knowing:
.WithReloadBeforeStart(reload)— reload the conversation from durable storage immediately before each turn starts, so a previous turn that crashed mid-flight self-heals instead of wedging every future one. Runs only once a turn has actually been claimed, so it can't race a turn already in flight..WithFrameInjectedMessage(frame)— customize (or disable) the note prefixed to a message before it's steered into someone else's in-progress turn.
Core concepts
Conversation/Message/Block— the provider-neutral transcript.Message.Contentis a list ofBlocks (TextBlock,ToolCallBlock,ToolResultBlock, …); round-tripping aConversationthrough JSON is enough to resume it later.IModel— the seam to a concrete provider. ImplementStreamAsyncto translate aConversationinto a provider request and its response stream intoStreamEvents;CompleteAsyncis provided for free as a fold over the stream.ITool/Tool<TArgs>— a callable the model can invoke. SubclassTool<TArgs>with a typed argument record and a hand-authored JSON-SchemaDefinition; the base class handles (de)serialization and turns malformed arguments into a model-readable error instead of an exception. SeeTools/BaselineTools.csandTools/FileSystem/GlobTool.csfor worked examples.ITurnContext— host-supplied identity/provenance for one turn (e.g. who sent the message that started it), deliberately opaque — the SDK prescribes nothing about what it carries, only its scope: established once by whichever call starts a turn, held for that turn's entire duration. A message steered into a turn already running does not get its own; the turn's original context still applies. Its only consumer isITransformer.IExecutionContext— the caller-supplied configuration for oneIEngine.ExecuteAsynccall: anITurnContextplus an optional mid-loop injection queue, bundled together since both are per-call inputs the engine reads from (as opposed toConversation, the thing being acted on).ITransformer— rewrites the conversation (or the ambientITurnContext) around a model call — compaction, tool-result pruning, history snipping. Receives anITransformerContext(the currentITurnContext, plusIteration/Stage— engine-owned loop-position facts it can read but never replace).Engineruns four stages:BeforeTurnTransformersonce before the turn's first iteration,BeforeIterationTransformersbefore each model call,AfterIterationTransformersafter the assistant message lands but before tools run, andAfterTurnTransformersonce after the loop ends.Engine<TExecutionContext>/IEngine<TExecutionContext>— drives one turn to completion, exactly as shown above. Every step is aprotected virtualmethod, so subclass to override one piece (tool-result formatting, dispatch policy, timeouts) without reimplementing the whole turn. Generic so a host needingExecuteAsyncstrongly typed over its ownIExecutionContextshape can subclassEngine<TMyExecutionContext>directly and inherit the whole loop;Engine/IEngine(non-generic, equivalent toEngine<IExecutionContext>/IEngine<IExecutionContext>) is whatOperativeand everything else in this SDK is built against, and what most hosts want. AnEngineholds no session state of its own — oneEngine(one model/tool/transformer configuration) is meant to be reused across many independentIOperativesessions, the same way oneIModelis reused across many engines.OperativeBuilder/Operative/IOperative— owns one ongoingConversationand single-flight-serialises turns against it through anEngine.SubmitAsyncstarts a fresh turn if none is running, or steers a new message into the turn already in flight if one is;ResumeAsynccontinues the conversation with no new input. Build one throughOperativeBuilder, not theOperativeconstructor directly.EngineEvent— the observable channel for one turn, as driven directly byIEngine.ExecuteAsync: model stream events, the settled assistant message, tool start/complete/error events, the synthesized tool-result message, transformer applications, and a terminalTurnCompletedEvent.OperativeEvent— whatIOperative.SubmitAsync/ResumeAsync'sSubmitResult.Eventsactually yields.EngineEventOccurredrelays an underlyingEngineEventas-is;MessageQueuedForNextTurnEventisIOperative-only — it fires when a message arrives too late for the engine's own mid-turn drain to catch, soIOperativeappends it directly instead of losing it, ready for the next turn to answer. Kept separate fromEngineEventbecauseIEnginehas no notion ofIOperativeand is fully usable without one.- Session isolation — one
IOperativeis one conversation; every message submitted to it shares that same history and turn. If two channels or two users need to stay unaware of each other, give each its ownIOperative(e.g. aDictionary<string, IOperative>keyed by user/channel/thread) rather than routing everyone through one — the sameIEngine(and its model/tools) can safely back any number of them at once, sinceIEngineholds no session state of its own.
Writing a custom tool
using System.Text.Json;
using System.Text.Json.Serialization;
using Universal.Operative.Sdk.Tools;
public sealed class WeatherTool : Tool<WeatherTool.Args>
{
public override string Name => "get_weather";
public override bool IsConcurrencySafe => true;
public override ToolDefinition Definition { get; } = new()
{
Name = "get_weather",
Description = "Get the current weather for a city.",
Parameters = JsonDocument.Parse("""
{ "type": "object", "properties": { "city": { "type": "string" } }, "required": ["city"] }
""").RootElement.Clone(),
};
public sealed record Args
{
[JsonPropertyName("city")] public string City { get; init; } = string.Empty;
}
protected override async Task<ToolResult> ExecuteAsync(Args args, CancellationToken cancellationToken)
=> ToolResult.Json(new { city = args.City, tempF = 72 });
}
IsConcurrencySafe = true marks a read-only tool as safe to run in parallel with other calls in the same turn; mutating tools default to false and run serially in call order. Register a tool with builder.AddTool(new WeatherTool()) and the harness auto-publishes its Definition on outbound messages and dispatches calls to it by name.
Background tool execution
A tool whose work can outlive a single call implements IBackgroundTool (most conveniently via BackgroundTool<TArgs>, which mirrors Tool<TArgs>'s typed-argument split). ShellTool (and so BashTool/PowerShellTool) already does this — pass run_in_background: true in a call's arguments and it hands back a handle instead of running to completion inline.
IEngine engine = new EngineBuilder()
.WithModel(model)
.AddBaselineTools()
.WithBackgroundTasks(new InMemoryBackgroundTaskRegistry())
.Build();
WithBackgroundTasks does two things: it wires Engine.Options.BackgroundDispatch to route any run_in_background: true call on an IBackgroundTool through the given IBackgroundTaskRegistry instead of running it inline, and it registers TaskOutputTool/TaskStopTool against that same registry so the model can poll or stop what it started, by the task id the initial call returns. Without a registry wired in, run_in_background is simply ignored and every tool runs the ordinary way.
HTTP proxy tools
A tool that just calls an external REST API — build a request from the model's arguments, send it, hand the response back — can subclass HttpProxyTool<TArgs> instead of hand-rolling ExecuteAsync. It owns the build → send → format template; a subclass overrides BuildRequestAsync (URI, method, auth headers, body) and, optionally, FormatResponseAsync (default is "{status} {reason}\n{body}"). HttpClientProxyTool<TArgs> is the plain-HttpClient implementation — override CreateHttpClient for custom transport (a handler with custom decompression, for instance), otherwise a lazily-created default HttpClient is reused for the tool's lifetime.
public sealed class WeatherTool(string apiKey) : HttpClientProxyTool<WeatherTool.Args>
{
public override string Name => "get_weather";
public override ToolDefinition Definition { get; } = /* ... */;
protected override Task<HttpRequestMessage> BuildRequestAsync(Args args, CancellationToken ct)
{
var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.example.com/weather?city={args.City}");
request.Headers.Authorization = new("Bearer", apiKey);
return Task.FromResult(request);
}
public sealed record Args(string City);
}
Pair this with Universal.Operative.Sdk.Http's HttpServiceClientProxyTool<TServiceClient, TArgs> when the target API already has a Universal.Common.Net.Http.HttpServiceClient — auth and success/failure handling then live once, on that client, rather than being reimplemented on the tool.
Subagents
AgentTool spawns a subagent — a fresh, single-turn IOperative run against its own scratch Conversation seeded with a prompt — and returns its final answer. It's an IBackgroundTool, so run_in_background: true works the same way as any other background tool.
builder.AddAgentTool(new Dictionary<string, AgentTool.SubagentType>
{
["research"] = new("Web research only.",
Factory: () => new EngineBuilder().WithModel(model).AddTools(webSearchTool, webFetchTool)),
["code"] = new("Filesystem and shell only.",
Factory: () => new EngineBuilder().WithModel(model).AddBaselineTools()),
["reviewer"] = new("Reviews code for correctness.",
Factory: () => new EngineBuilder().WithModel(cheaperModel).AddTools(readTool, grepTool),
SystemPrompt: "You are a meticulous, skeptical code reviewer..."),
});
Register named AgentTool.SubagentTypes to let the calling model choose what kind of subagent it gets. Each type's Factory is a complete, independent EngineBuilder recipe — model, tools, transformers, timeouts, whatever that type needs, computed fresh on every spawn — rather than a filtered subset of some shared menu, so at least one type must be registered; there's nothing to build an implicit fallback from. SystemPrompt sets that type's own persona (the scratch conversation's system prompt). With only one type registered, subagent_type is omitted from the schema entirely and every spawn just uses it.
Because each type builds its own engine from scratch, the self-recursion guard isn't automatic the way a shared-tool-pool design could make it: unless a type sets AllowRecursion: true, AgentTool strips any tool literally named "Agent" from whatever that type's Factory builds, so a type still can't spawn further subagents just because its own factory closure happened to include this same tool instance.
EngineBuilder.AddAgentTool(types) is a small naming/discoverability convenience alongside AddBaselineTools/WithBackgroundTasks — equivalent to AddTool(new AgentTool(types)). The constructor is still there directly if you'd rather not go through EngineBuilder at all.
AgentTool isn't sealed — like Engine, every step is protected virtual so a subclass overrides one piece without reimplementing the rest: BuildOperativeFactory (how a resolved type becomes engine wiring), BuildConversation (how the scratch conversation gets seeded), BuildDefinition (the schema/prompt shown to the calling model), or ExtractFinalAnswer (how the subagent's last message becomes the tool's return text).
Under the hood, a spawn's turn runs through an IOperativeFactory/IOperative (built by BuildOperativeFactory) rather than driving IEngine directly — ResumeAsync() is called once against a scratch conversation already seeded with the prompt.
From EngineBuilder straight to a long-lived IOperative
EngineBuilder.BuildOperative(conversation, configureOperative?) builds this configuration into an IEngine and wraps it, plus a Conversation, into a ready IOperative in one call — the common "one engine, one session on top of it" case, without a separate OperativeBuilder.Create(builder.Build(), conversation).Build() to keep in sync by hand:
IOperative operative = new EngineBuilder()
.WithModel(model)
.AddBaselineTools()
.BuildOperative(conversation,
configureOperative: b => b.WithReloadBeforeStart(reload));
The returned IOperative's engine is exactly what this Build() call produced, so it automatically shares this builder's model, tools, and transformers.
License
Free for noncommercial use under the Universal.Operative Noncommercial License. Source is closed; commercial use requires a separate license from Andrew Ong.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
NuGet packages (12)
Showing the top 5 NuGet packages that depend on Universal.Operative.Sdk:
| Package | Downloads |
|---|---|
|
Universal.Operative.Sdk.Anthropic
Anthropic extensions for the Universal.Operative.Sdk. |
|
|
Universal.Operative.Sdk.OpenAI
OpenAI extensions for Universal.Operative.Sdk. |
|
|
Universal.Operative.Sdk.xAI
xAI (Grok) extensions for the Universal.Operative.Sdk. |
|
|
Universal.Operative.Sdk.GoogleGemini
Google Gemini extensions for the Universal.Operative.Sdk. |
|
|
Universal.Operative.Sdk.AwsBedrock
Amazon Bedrock extensions for the Universal.Operative.Sdk. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 4.0.0 | 54 | 7/28/2026 |
| 3.3.0 | 50 | 7/28/2026 |
| 3.2.0 | 115 | 7/27/2026 |
| 3.1.0 | 99 | 7/26/2026 |
| 3.0.0 | 151 | 7/23/2026 |
| 2.0.0 | 164 | 7/21/2026 |
| 2.0.0-beta1 | 91 | 7/21/2026 |
| 1.5.0 | 104 | 7/21/2026 |
| 1.4.0 | 129 | 7/20/2026 |
| 1.3.1 | 117 | 7/19/2026 |
| 1.3.0 | 168 | 7/6/2026 |
| 1.2.0 | 98 | 7/6/2026 |
| 1.1.0 | 102 | 7/4/2026 |
| 1.0.3 | 108 | 7/4/2026 |
| 1.0.2 | 115 | 7/4/2026 |
| 1.0.1 | 119 | 7/4/2026 |
| 1.0.0 | 140 | 7/3/2026 |
3.2.0 — A message steered into an already-running turn now keeps the ITurnContext it arrived with (previously discarded), and the pending queue can be inspected/rewritten before it reaches the model.
Breaking:
- IExecutionContext.InjectedMessages (ChannelReader<Message>?) replaced by DrainInjections (Func<CancellationToken, Task<IReadOnlyList<ContextMessage>>>?); ExecutionContext's constructor takes the new delegate. Only affects code driving Engine.ExecuteAsync directly with a hand-built context — Operative/OperativeBuilder callers see no behavior change.
Added:
- IMessageTransformer: transforms one message using the ITurnContext it arrived with.
- IInjectionTransformer (OnEnqueueAsync/OnDrainAsync) + OperativeBuilder.AddInjectionTransformer: stamp, merge, or collapse pending messages before they're spliced into the conversation.
- MessageTransformerAdapter/MessageInjectionAdapter (.AsBeforeTurnTransformer()/.AsInjectionTransformer()): wrap an IMessageTransformer into either seam.
- HttpProxyTool<TArgs>/HttpClientProxyTool<TArgs> (Tools namespace): REST-passthrough tool base backed by a plain HttpClient. New Universal.Operative.Sdk.Http package adds HttpServiceClientProxyTool<TServiceClient, TArgs>, routed through an existing HttpServiceClient's own auth pipeline instead.
3.1.0 — Added a global tool-call hook pipeline (IToolCallHook; EngineBuilder.AddBeforeToolDispatchHook/AddAfterToolDispatchHook) and a shell-call-scoped one (IShellHook; ShellToolBuilder), for observing, rewriting, or vetoing calls. New Universal.Operative.Sdk.Compression package adds shell-output compression and rtk-proxy hooks built on the latter. BaselineTools.Create/EngineBuilder.AddBaselineTools take an optional ShellToolBuilder to wire hooks into the built-in shell tools.
Changed: EngineBuilder.MaxIterations now defaults to unbounded (was 32).
Fixed: Engine's loop-termination check only looked for ToolCallBlocks in the assistant message, ignoring StopReason. A model whose own tool-calling loop runs entirely inside its own subprocess (e.g. one driving the Claude Code CLI) can report already-executed tool calls while genuinely finished (StopReason == EndTurn) — Engine mistook that for pending work, re-dispatching the call a second time and triggering an extra, unrequested model call. Fixed by also stopping on StopReason == EndTurn.
3.0.0 — Breaking release: per-call context handling overhauled.
Breaking changes:
- IContext/Context renamed to ITurnContext/TurnContext (pure rename, no behavior change).
- ITransformer.TransformAsync's first parameter is now ITransformerContext instead of ITurnContext?. Get the turn context via context.Context (was context directly); Iteration and Stage are new, read-only, engine-owned facts. Fix: update the parameter type and read context.Context.
- IOperative.SubmitAsync/ResumeAsync: context now comes first and is non-nullable — SubmitAsync(ITurnContext, Message, CancellationToken) and ResumeAsync(ITurnContext, CancellationToken). No-context overloads (SubmitAsync(Message, CancellationToken), ResumeAsync(CancellationToken)) still exist, defaulting to a fresh TurnContext(). Fix: reorder positional call sites; substitute a real ITurnContext (or the no-context overload) anywhere null used to be passed.
- IEngine.ExecuteAsync(ITurnContext, Conversation, ChannelReader<Message>?, CancellationToken) is now ExecuteAsync(IExecutionContext, Conversation, CancellationToken), bundling the turn context and injection queue into IExecutionContext. The no-context ExecuteAsync(Conversation, CancellationToken) overload is removed — replace with ExecuteAsync(new ExecutionContext(new TurnContext()), conversation, cancellationToken).
- IEngine/Engine are now generic (IEngine<TExecutionContext>/Engine<TExecutionContext>); the non-generic IEngine/Engine (equivalent to the <IExecutionContext> closing) is unchanged as the default most code should keep using. Engine.Options is unaffected.
- EngineBuilder.WithContext and Engine.Options.Context (a construction-time default context) are removed — context is always supplied per call now.
Added:
- IOperativeFactory/OperativeFactory, mirroring IDbContextFactory<TContext>: fixed engine configuration, a fresh IOperative built per Conversation passed to Create.
- AgentTool: spawns a subagent as a fresh, single-turn IOperative seeded with a prompt, and returns its final answer. Register named kinds via AgentTool(IReadOnlyDictionary<string, AgentTool.SubagentType> types) — at least one is required; each SubagentType.Factory returns a fresh, independent EngineBuilder per spawn. Unless a type sets AllowRecursion: true, any tool named "Agent" is stripped from what that type's Factory builds. EngineBuilder.AddAgentTool(types) and EngineBuilder.BuildOperative(conversation, configureOperative?) are new convenience methods.