---
title: "Build .NET AI agents"
description: "Run tool-using agents as observable stages with approvals, routing, and durable sessions."
category: ".NET SDK"
---

`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](/docs/net/getting-started). 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`:

```csharp
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](/docs/net/testing).

## 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:

```csharp
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:

```text
/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()`:

```csharp
agent.UseRedisStores(builder.Services, redisConnectionString,
    sessionTtl: TimeSpan.FromHours(2),
    memoryEntryTtl: TimeSpan.FromDays(7),
    keyPrefix: "orders:tenant-a");
```

With `using PipelogiqSDK.Postgres;`, the alternative is:

```csharp
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:

```csharp
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.
