Build .NET AI agents
Run tool-using agents as observable stages with approvals, routing, and durable sessions.
PipelogiqSDK.Agent turns an agent run into ordinary pipeline stages. A planner proposes actions, tool handlers execute them, optional confirmation and critic stages review them, and a responder delivers the result. You can inspect those stages through the same pipeline API and dashboard as other workflows.
Use version 0.5.0 from the configured package feed. The Agent package currently depends on the AMQP worker runtime. RegisterAgentHandlers() is an extension for PipelineRunner; the HTTP worker package does not provide an equivalent agent registration extension in this release.
Select an execution model
Plan-and-execute asks for a plan of tool calls upfront. ReAct calls a think step repeatedly, executes a proposed tool, and supplies its result to the next decision. Set UseReActMode = true for ReAct. MaxThinkSteps bounds the reasoning loop; this is separate from stage retry limits.
The built-in planner supports Anthropic, OpenAI, and Ollama. You can supply an ILlmPlanner implementation for your own provider or deterministic application logic. Its methods are PlanAsync, ThinkAsync, and SynthesizeAsync. The built-in class retains the historical name ClaudeLlmPlanner even when configured for another supported provider.
Run an agent with a native tool
This complete console program exposes a local discount calculator and prints notifications. It uses a real Anthropic account when run. Select a model available to your account through PIPELOGIQ_AGENT_MODEL; the example does not assume a particular model subscription.
Create a net8.0 console project with the feed configuration from the quickstart, then reference PipelogiqSDK.Agent version 0.5.0 and Microsoft.Extensions.Hosting version 8.0.1. Replace Program.cs:
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using PipelogiqSDK.Abstractions;
using PipelogiqSDK.Agent.Configuration;
using PipelogiqSDK.Agent.Extensions;
using PipelogiqSDK.Agent.Models;
using PipelogiqSDK.Agent.Services;
using PipelogiqSDK.Api;
using PipelogiqSDK.Configuration;
using PipelogiqSDK.Runner;
static string Required(string name) =>
Environment.GetEnvironmentVariable(name) is { Length: > 0 } value
? value : throw new InvalidOperationException($"Set {name}.");
var options = new PipelogiqRunnerOptions
{
ApiKey = Required("PIPELOGIQ_API_KEY"),
ApiUrl = Environment.GetEnvironmentVariable("PIPELOGIQ_API_URL")
?? "http://localhost:8081",
WorkerName = "discount-agent"
};
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddPipelogiq(options);
var agent = builder.Services.AddPipelogiqAgent(settings =>
{
settings.LlmProvider = AgentLlmProvider.Anthropic;
settings.LlmApiKey = Required("ANTHROPIC_API_KEY");
settings.LlmModel = Required("PIPELOGIQ_AGENT_MODEL");
settings.UseReActMode = true;
settings.MaxThinkSteps = 6;
settings.RequireConfirmationForMutations = true;
settings.SystemPrompt = "Use calculateDiscount for arithmetic. Explain the result briefly.";
});
agent.AddNativeTool(new AgentToolDefinition
{
Name = "calculateDiscount",
Description = "Calculate a discounted price from the original price and percentage.",
IsMutating = false,
Params = new Dictionary<string, AgentToolParam>
{
["price"] = new() { Type = "number", Description = "Original nonnegative price." },
["percent"] = new() { Type = "number", Description = "Discount from 0 to 100." }
}
}, new CalculateDiscount());
builder.Services.AddSingleton<IAgentNotificationChannel, ConsoleChannel>();
using var host = builder.Build();
var lifetime = host.Services.GetRequiredService<IHostApplicationLifetime>();
using var stop = CancellationTokenSource.CreateLinkedTokenSource(lifetime.ApplicationStopping);
var runner = host.Services.GetRequiredService<PipelineRunner>();
runner.RegisterAgentHandlers();
await host.StartAsync();
var workerTask = runner.StartAsync(stop.Token);
try
{
using var pipeline = AgentPipelineBuilderExtensions.CreateAiAgent(
"What is the price after a 15 percent discount on 200?",
replyTo: new AgentReplyTarget { Channel = "console", Address = "local" },
sessionId: $"discount-{Guid.NewGuid():N}",
options: options);
var created = await pipeline.SendAsync(stop.Token);
Console.WriteLine($"Agent pipeline {created.Id}. Press Ctrl+C to stop the worker.");
await workerTask;
}
catch (OperationCanceledException) when (stop.IsCancellationRequested) { }
finally
{
stop.Cancel();
try { await workerTask; }
catch (OperationCanceledException) when (stop.IsCancellationRequested) { }
finally
{
using var shutdown = new CancellationTokenSource(TimeSpan.FromSeconds(40));
await host.StopAsync(shutdown.Token);
}
}
internal sealed record DiscountInput(decimal Price, decimal Percent);
internal sealed class CalculateDiscount : AgentToolHandlerBase<DiscountInput>
{
protected override Task<AgentToolOutput> ExecuteAsync(
DiscountInput input, IStageContext? context = null, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
if (input.Price < 0 || input.Percent is < 0 or > 100)
return Task.FromResult(AgentToolOutput.Failure("Price or discount is outside its valid range."));
var result = Math.Round(input.Price * (1 - input.Percent / 100), 2);
return Task.FromResult(AgentToolOutput.Success(JsonSerializer.Serialize(new { price = result })));
}
}
internal sealed class ConsoleChannel : IAgentNotificationChannel
{
public string Name => "console";
public bool CanHandle(AgentReplyTarget target) => target.Channel == Name;
public Task NotifyAsync(AgentReplyTarget target, AgentNotification notification,
CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
Console.WriteLine($"[{notification.Type}] {notification.Message}");
return Task.CompletedTask;
}
}Set PIPELOGIQ_API_KEY, PIPELOGIQ_API_URL, ANTHROPIC_API_KEY, and PIPELOGIQ_AGENT_MODEL, then dotnet run. The numerical tool result is deterministic; the provider's response text is not. The worker stays running after the response. Inspect the printed pipeline ID with the status API or dashboard. To test without an account or a server, use the agent harness.
Describe and authorize tools
Native tools implement IAgentToolHandler or AgentToolHandlerBase<TInput>. The typed base converts the supplied parameter dictionary to your record/class, including nested values. It supports case-insensitive property names and numeric strings. Required and unknown parameters are checked by the agent tool stage; business ranges and permissions still belong in your handler or policy.
Return concise structured text, often JSON, using AgentToolOutput.Success. A domain failure uses AgentToolOutput.Failure. Tool failure is recorded in conversation history so the agent can recover; the containing orchestration stage intentionally succeeds. A green tool stage alone does not prove the business operation succeeded.
Use IsMutating explicitly for native tools. For HTTP tools, the default classification treats POST, PUT, PATCH, and DELETE as mutations unless overridden. Native handlers take precedence over HTTP dispatch for the same tool name. Native handler instances can be reused; design their concurrency and dependency lifetime accordingly.
The default tool policy allows all registered tools. Register an IAgentToolPolicy with agent.UseToolPolicy<MyPolicy>(builder.Services) to check each resolved call immediately before execution. The policy receives the tool name, effective mutation classification, resolved parameters, and stage context. Derive permissions from trusted application identity and business data, not user-provided text or a model's claimed role.
Add HTTP and OpenAPI tools
agent.AddTool(new AgentToolDefinition { ... }) registers an HTTP tool. Define its method, URL template, parameter locations (path, query, body), and authentication. Named target APIs allow one agent to call several services. Static headers and context-derived header templates are supported, as are references to earlier tool results such as {{ref:lookup.id}}.
Import selected operations before building the host:
await agent.UseOpenApiSpecAsync("./inventory.openapi.json", load =>
{
load.IncludeOperations = new List<string> { "getInventory", "reserveInventory" };
});The supplied file must exist. Import supports JSON OpenAPI 3.x and Swagger 2.x, local references, inherited parameters, nested objects/arrays, and allOf required fields. It is a subset: YAML, external or cyclic references, unions, and primitive/array/non-JSON bodies need custom tool definitions or handlers. Security schemes do not provision credentials. Header/cookie parameters and advanced constraints are not comprehensively enforced. Review imported tools before exposing them.
Request and resolve approval
With RequireConfirmationForMutations = true, proposed mutations can produce a confirmation stage and a confirmation_required notification. Its pipeline and stage IDs let your application display the pending action and call ResumeStageApprovalAsync after authenticating the reviewer. The console sample only has a read-only tool; it does not provide a human approval UI.
The server completes or skips a waiting confirmation stage. It does not execute AgentConfirmationHandler a second time. For built-in agent plans, rejection skips the pending tool/confirmation continuation and permits a think/responder stage to explain the outcome. Rejection requires a reason.
Approval is not a replacement for authorization. Tool policy still runs after references are resolved and immediately before invoking the native handler or target API.
Telegram
AddTelegramAgentChannel(token) adds the Telegram transport; the host must be started for its listener to run. Register agent services and runner.RegisterAgentHandlers() separately. Use TelegramAgentChannelOptions.TelegramAllowedChatIds when limiting the incoming channel to selected chats.
Current commands are:
/approve <pipelineId>:<stageId>
/reject <pipelineId>:<stageId> <reason>The adapter verifies pipeline membership, waiting handler/status, and immutable sender/chat bindings. Legacy bare stage IDs are rejected. A different group member cannot approve another sender's pending action. Sessions are chat-scoped, so a group's conversation history is shared. Integrate a trusted business identity mapping before treating a Telegram username as an employee identity.
Provider routing, critic, and budgets
ModelRouter chooses models within a provider. StepRouter can set a provider/model separately for Plan, Think, Synthesize, and Critic; configure additional provider credentials and endpoints in Providers. Provider settings remain worker-owned, outside per-run pipeline overrides. OpenAI uses its own API credentials; Ollama requires a reachable local service and installed model.
Critic modes are Off, CriticOnFinal, CriticOnMutating, and CriticOnEveryStep. A rejection supplies feedback for another think step. Critic review can increase latency and calls. Critic.FailOpen defaults to true; explicitly set it to false when a failed review must prevent the proposal from running. A model review does not replace deterministic validation or tool policy.
TokenBudget.MaxInputTokensPerRun and MaxCostUsdPerRun are soft per-pipeline limits checked before subsequent LLM calls. Usage is reported after a call, so one call can cross a limit. Unknown pricing or missing provider usage limits the cost estimate. The obsolete PerSession properties are aliases for these same per-run limits, not a shared conversation ledger. Prompt-cache support and cost depend on the provider and model; fixture tests do not prove cache hits or provider billing.
Attachments can be passed through AgentOrchestratorInput and the corresponding CreateAiAgent overload. Supported media and size limits depend on the selected provider/model and channel. Keep sensitive attachments out of ordinary logs.
Sessions and durable memory
Defaults are in-memory and process-local. Add PipelogiqSDK.Redis or PipelogiqSDK.Postgres version 0.5.0 to replace them. These store agent history/memory; they do not replace the server's pipeline database.
With using PipelogiqSDK.Redis;, register before Build():
agent.UseRedisStores(builder.Services, redisConnectionString,
sessionTtl: TimeSpan.FromHours(2),
memoryEntryTtl: TimeSpan.FromDays(7),
keyPrefix: "orders:tenant-a");With using PipelogiqSDK.Postgres;, the alternative is:
agent.UsePostgresStores(builder.Services, postgresConnectionString,
sessionTtl: TimeSpan.FromHours(2), keyPrefix: "orders:tenant-a");The connection-string variables come from your deployment configuration. For PostgreSQL, initialize both schemas after constructing the host and before accepting work:
await host.Services.GetRequiredService<PostgresAgentSessionStore>().EnsureSchemaAsync();
await host.Services.GetRequiredService<PostgresAgentMemoryStore>().EnsureSchemaAsync();Use a consistent, application-selected namespace across workers. Adding a prefix selects a new namespace; it does not migrate old data. A connection-string Redis registration owns its connection and disposes it with DI. The overload using an existing multiplexer follows that registration's ownership.
All stores replace whole session histories with last-write-wins behavior. Serialize concurrent runs for a session when lost updates are unacceptable; there is no built-in CAS or distributed lock. TTL begins at the last save, not at read time. Redis memory TTL applies to the whole session list. PostgreSQL filters expired sessions on read but requires explicit DeleteExpiredAsync cleanup; memory has no automatic TTL.
Built-in memory recall uses keyword matching. It is not vector retrieval or an implemented RAG subsystem. Redis/in-memory inspect the session list; PostgreSQL scores a bounded recent candidate set. Provide a custom IAgentMemoryStore when your retrieval or retention needs differ.
Observe outcomes
IAgentLifecycleObserver exposes tool, approval, completion, and budget events. Register observers through AddLifecycleObserver. The built-in metrics observer emits System.Diagnostics.Metrics instruments; your host must configure collection and export. Retried delivery can repeat callbacks, so persistence in observers should be idempotent.
Use explicit AgentReplyTarget routing and one matching IAgentNotificationChannel. Missing or ambiguous delivery targets are failures, not silent success. The notification interface supports application-defined channels; names such as signalr or webhook are extension examples, not bundled adapters.