A lightweight .NET AI SDK for building chat applications with provider routing, streaming responses, tool calling, local skills, and session persistence.
Current status: MVP. The project includes multiple provider integrations, a runtime/session layer, local file-based skills, runnable samples, and unit tests.
- Provider-based chat abstraction through
IChatClientandIChatModelProvider - Supported providers:
- OpenAI Chat Completions
- OpenAI-compatible Chat Completions
- Azure OpenAI Chat Completions
- OpenAI Responses API
- Gemini
- Claude
- Response modes:
- non-streaming responses
- streaming responses
- tool calling / function calling
- approval-aware tool execution with host-managed pause/resume
- Conversation management:
- multi-turn
ChatSession IAgentRuntimewith session continuation bySessionId- in-memory or file-based
ISessionStore
- multi-turn
- Local skill support:
- local file-based skill loading
- prompt-based skill execution
- active skill continuation policy
- explicit skill exit phrases and manifest-driven continuation/exit hints
- built-in prompt skill demos for indirect JS/Python command workflows through host tools
- Shared content parts:
- text parts
- image URL parts with predictable text fallback on unsupported providers
- binary parts with Gemini inline-data support and text fallback elsewhere
- Dependency injection helpers for core services and providers
- Unit tests covering request mapping, response mapping, streaming edge cases, skills, and session persistence
AgileAI.slnx
├── src/
│ ├── AgileAI.Abstractions/ # Core contracts and shared models
│ ├── AgileAI.Core/ # Chat client, runtime, sessions, registries, stores
│ ├── AgileAI.Extensions.FileSystem/ # Reusable root-scoped filesystem tools
│ ├── AgileAI.Providers.OpenAI/ # OpenAI Chat Completions provider
│ ├── AgileAI.Providers.OpenAICompatible/# Generic OpenAI-compatible provider
│ ├── AgileAI.Providers.AzureOpenAI/ # Azure OpenAI Chat Completions provider
│ ├── AgileAI.Providers.OpenAIResponses/# OpenAI Responses API provider
│ ├── AgileAI.Providers.Gemini/ # Gemini provider
│ ├── AgileAI.Providers.Claude/ # Claude provider
│ └── AgileAI.Studio.Api/ # ASP.NET Core backend for AgileAI.Studio
├── studio-web/ # Vue 3 + Naive UI frontend for AgileAI.Studio
├── samples/
│ ├── ConsoleChat/ # Minimal OpenAI chat sample
│ ├── FileSystemToolsSample/ # Filesystem tools sample with ChatSessionBuilder
│ ├── OpenAICompatibleChat/ # Generic OpenAI-compatible sample
│ ├── ToolCallingSample/ # OpenAI tool calling sample
│ ├── AzureOpenAIChat/ # Azure OpenAI sample
│ ├── GeminiChat/ # Gemini sample
│ ├── ClaudeChat/ # Claude sample
│ └── OpenAIResponsesChat/ # OpenAI Responses API sample
└── tests/
└── AgileAI.Tests/ # Unit tests
AgileAI.Abstractions defines the core interfaces and models:
IChatClientIChatModelProviderIChatSessionIAgentRuntimeITool/IToolRegistryISkill,ISkillPlanner,ISkillContinuationPolicyISessionStore,ConversationStateChatRequest,ChatResponse,ChatMessage- streaming update models such as
TextDeltaUpdate,ToolCallDeltaUpdate,CompletedUpdate,UsageUpdate
AgileAI.Core provides:
ChatClientfor provider routingChatSessionfor multi-turn chat and tool loop handling- approval-capable tool execution gates and resumable chat turns via
SendTurnAsync(...)/ContinueAsync(...) DefaultAgentRuntimefor runtime execution, session continuation, and skill selection- in-memory registries for tools and skills
InMemorySessionStoreandFileSessionStore- dependency injection helpers
AgileAI.Providers.OpenAIimplements OpenAI Chat Completions supportAgileAI.Providers.OpenAICompatibleimplements generic OpenAI-compatible chat completions supportAgileAI.Providers.AzureOpenAIimplements Azure OpenAI deployment-based chat completionsAgileAI.Providers.OpenAIResponsesimplements the OpenAI Responses APIAgileAI.Providers.Geminiimplements Gemini content generation supportAgileAI.Providers.Claudeimplements Claude messages API support
AgileAI.Extensions.FileSystemprovides reusable root-scoped file tools:list_directoryread_filewrite_file- DI registration with
AddFileSystemTools(...)
AgileAI.Studio is the product layer built on top of the SDK in this repository.
AgileAI.Studio turns the SDK in this repo into a full local-first AI workspace.
- design and validate model connections across OpenAI, Azure OpenAI, and OpenAI-compatible providers
- create reusable agents with prompts, temperature, token limits, and pinned defaults
- keep conversations persisted locally and chat with streaming responses in a polished desktop UI
- execute unrestricted local commands through
run_local_command, with a required user approval step for every execution - fetch webpage content through the built-in
web_fetchStudio tool - verify the product with Playwright screenshots and e2e coverage
Studio Stack
- backend:
src/AgileAI.Studio.Apiwith ASP.NET Core, EF Core, SQLite, and SSE streaming - frontend:
studio-webwith Vue 3, Naive UI, Pinia, and Vite - runtime: existing
AgileAI.Core,AgileAI.Abstractions, and provider packages from this repo
dotnet run --project "src/AgileAI.Studio.Api/AgileAI.Studio.Api.csproj"The API starts on http://localhost:5117 in development and creates studio.dev.db automatically.
cd studio-web
npm install
npm run devThe Vite app reads VITE_API_BASE_URL when provided; otherwise it defaults to http://localhost:5117/api.
- create provider connections for
OpenAI,OpenAI Compatible, andAzure OpenAI - add and validate models with a real minimal completion check
- create, edit, pin, and delete agents
- start conversations and stream replies in the chat workspace
- approve or reject agent-requested local command execution directly in chat
- run desktop e2e checks with Playwright
- switch between light and dark UI themes inside the Studio shell
AgileAI now includes a reusable approval-aware tool execution model in core packages, and AgileAI.Studio uses it to power run_local_command.
- the tool itself is unrestricted in capability
- every execution requires explicit human approval before the command runs
- the approval flow pauses the current tool loop and resumes it after approve/reject
- Studio shows a chat-scoped approval card with the exact command preview, shell, and result status
The reusable part lives in core abstractions and session orchestration. Studio adds the product-specific pieces: SQLite persistence, HTTP endpoints, SSE events, and the browser UI for approving or rejecting a pending command.
The repository can include built-in local skills that demonstrate JavaScript or Python execution workflows, but the current runtime still loads skills from SKILL.md and executes them as prompt skills.
In practice, that means a built-in skill such as skills/script-demo/SKILL.md can:
- explain the current architecture limit clearly;
- generate exact
python -c ...ornode -e ...commands; - and, in AgileAI.Studio, request execution indirectly through the approval-gated
run_local_commandtool.
It does not mean AgileAI currently supports native local skill entrypoints such as entry: python or entry: js. Native script-backed skills would require additional runtime changes beyond the current prompt-skill executor.
AgileAI.Studio now includes a built-in web_fetch tool in the default Studio registry.
- accepts absolute
httpandhttpsURLs - performs a simple
GETrequest and returns response content through the tool channel - truncates returned content to keep tool output bounded
- is available to Studio agents through the same allowlist/filtering path as the existing built-in tools
Current limitations:
- no host allowlist is enforced yet
- response bodies are fully read before returned content is truncated
- network failures currently surface as tool exceptions rather than normalized failed tool results
AgileAI.Studio can validate and chat against real OpenAI-compatible services.
For an OpenAI-compatible provider, configure:
- provider type:
OpenAI Compatible - base URL: the provider's OpenAI-compatible root URL
- runtime provider name: a stable provider key such as
openapi,openrouter, or your internal alias - model key: the exact model/deployment name exposed by that provider
- API key: the real bearer token or compatible credential
If you have an OpenAI-compatible endpoint for a model such as gpt-5.4, add it through the Models page and use the built-in Test action. The test sends a live minimal completion request and reports the model response.
- add a provider connection
- publish one or more models
- create an agent with a clear system prompt
- start a conversation and iterate in chat
- keep the screenshots and e2e suite green as the UI evolves
Playwright-generated screenshots are stored under studio-web/screenshots/ after running the dedicated screenshot suite (npm run test:screenshots).
cd studio-web
npm run build
npm run test:e2eThe default Playwright suite uses the local Studio backend/frontend harness and covers the approval-modal command flow plus inline tool history rendering.
The real provider browser scenario is intentionally opt-in because it depends on an external OpenAI-compatible endpoint. To enable it, set:
export PW_REAL_ENDPOINT="http://your-openai-compatible-endpoint"
export PW_REAL_API_KEY="your-real-api-key"
export PW_REAL_MODEL_KEY="your-model-name"
cd studio-web
npm run test:e2eYou can add reusable filesystem tools to any AgileAI host without depending on Studio. The extension currently includes list_directory, search_files, read_file, read_files_batch, and write_file.
using AgileAI.Abstractions;
using AgileAI.Core;
using AgileAI.DependencyInjection;
using AgileAI.Extensions.FileSystem;
using AgileAI.Providers.OpenAICompatible.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddAgileAI();
services.AddOpenAICompatibleProvider(options =>
{
options.ProviderName = "openapi";
options.ApiKey = Environment.GetEnvironmentVariable("OPENAI_COMPATIBLE_API_KEY")!;
options.BaseUrl = Environment.GetEnvironmentVariable("OPENAI_COMPATIBLE_BASE_URL")!;
options.RelativePath = "chat/completions";
});
var serviceProvider = services.BuildServiceProvider();
var chatClient = serviceProvider.GetRequiredService<IChatClient>();
var toolRegistry = new InMemoryToolRegistry()
.RegisterFileSystemTools(options =>
{
options.RootPath = @"D:\workspace\MyProject";
options.MaxReadCharacters = 12000;
});
var session = new ChatSessionBuilder(chatClient, "openapi:gpt-5.4")
.WithToolRegistry(toolRegistry)
.Build();
var response = await session.SendAsync("Use search_files to find mentions of AgileAI.Studio, then use read_files_batch to inspect the best matching files and summarize them.");
Console.WriteLine(response.Message?.TextContent);See samples/FileSystemToolsSample/Program.cs for a runnable example.
If you prefer DI-based registration, AgileAI.Extensions.FileSystem also exposes:
services.AddAgileAIFileSystemTools(...)services.AddFileSystemTools(...)
AgileAI core now supports middleware-style interception around runtime execution, chat turns, streaming turns, and tool execution.
Register middleware globally through DI:
using AgileAI.Abstractions;
using AgileAI.Core;
using AgileAI.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddAgileAI();
services.AddChatTurnMiddleware<LoggingChatTurnMiddleware>();
services.AddToolExecutionMiddleware<ToolAuditMiddleware>();
var serviceProvider = services.BuildServiceProvider();
var chatClient = serviceProvider.GetRequiredService<IChatClient>();
var session = new ChatSessionBuilder(chatClient, "openai:gpt-4o")
.UseServiceProvider(serviceProvider)
.Build();AgileAI.Core also includes built-in middleware for common cases:
services.AddAgileAI();
services.AddLoggingChatTurnMiddleware();
services.AddLoggingToolExecutionMiddleware(options => options.LogToolArguments = true);
services.AddToolPolicyMiddleware(options => options.DeniedToolNames = ["run_local_command"]);
var serviceProvider = services.BuildServiceProvider();
var chatClient = serviceProvider.GetRequiredService<IChatClient>();
var session = new ChatSessionBuilder(chatClient, "openai:gpt-4o")
.UseServiceProvider(serviceProvider)
.Build();Built-in middleware defaults are intentionally conservative:
AddLoggingChatTurnMiddleware()logs turn lifecycle and success state, but not full prompt text unlessLogInputs = trueAddLoggingStreamingChatTurnMiddleware()logs streaming start/completion/error boundaries instead of every delta tokenAddLoggingToolExecutionMiddleware()logs tool lifecycle, and only includes arguments/results when explicitly enabledAddToolPolicyMiddleware()can allow or deny tool execution by tool name before the real tool runs
Middleware execution follows registration order. With DI registration, the first registered middleware becomes the outermost wrapper. With ChatSessionBuilder.WithChatTurnMiddleware(...), WithStreamingChatTurnMiddleware(...), and WithToolExecutionMiddleware(...), the explicitly supplied middleware list is used for that session. If you want DI-registered middleware to apply, build the session with .UseServiceProvider(serviceProvider).
If you want custom behavior, you can still implement your own middleware classes:
public sealed class LoggingChatTurnMiddleware : IChatTurnMiddleware
{
public async Task<ChatTurnResult> InvokeAsync(
ChatTurnExecutionContext context,
Func<Task<ChatTurnResult>> next,
CancellationToken cancellationToken = default)
{
Console.WriteLine($"[chat-turn] kind={context.Kind}, input={context.Input}");
var result = await next();
Console.WriteLine($"[chat-turn] success={result.Response.IsSuccess}");
return result;
}
}
public sealed class ToolAuditMiddleware : IToolExecutionMiddleware
{
public async Task<ToolExecutionOutcome> InvokeAsync(
ToolExecutionMiddlewareContext context,
Func<Task<ToolExecutionOutcome>> next,
CancellationToken cancellationToken = default)
{
Console.WriteLine($"[tool] {context.Tool.Name} args={context.ExecutionContext.ToolCall.Arguments}");
return await next();
}
}For per-session customization, you can also attach middleware directly with ChatSessionBuilder.WithChatTurnMiddleware(...), WithStreamingChatTurnMiddleware(...), and WithToolExecutionMiddleware(...).
using AgileAI.Abstractions;
using AgileAI.Core;
using AgileAI.Providers.OpenAI;
var httpClient = new HttpClient();
var provider = new OpenAIChatModelProvider(
httpClient,
new OpenAIOptions { ApiKey = "your-api-key" });
var chatClient = new ChatClient();
chatClient.RegisterProvider(provider);
var response = await chatClient.CompleteAsync(new ChatRequest
{
ModelId = "openai:gpt-4o",
Messages = [ChatMessage.User("Hello")]
});
Console.WriteLine(response.Message?.TextContent);using AgileAI.Abstractions;
using AgileAI.DependencyInjection;
using AgileAI.Providers.OpenAI.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddAgileAI();
services.AddOpenAIProvider(options =>
{
options.ApiKey = "your-api-key";
});
var serviceProvider = services.BuildServiceProvider();
var chatClient = serviceProvider.GetRequiredService<IChatClient>();If you use IAgentRuntime, pass a stable SessionId to reuse persisted conversation state, including history and active skill.
using AgileAI.Abstractions;
var runtime = serviceProvider.GetRequiredService<IAgentRuntime>();
var turn1 = await runtime.ExecuteAsync(new AgentRequest
{
SessionId = "demo-session-001",
ModelId = "openai:gpt-4o",
Input = "Plan my trip to Tokyo",
EnableSkills = true
});
var turn2 = await runtime.ExecuteAsync(new AgentRequest
{
SessionId = "demo-session-001",
ModelId = "openai:gpt-4o",
Input = "Now switch to budget options only",
EnableSkills = true
});By default, AddAgileAI() registers:
InMemorySessionStoreDefaultSkillContinuationPolicy
DefaultSkillContinuationPolicy now supports a few practical continuation controls:
- generic exit phrases such as
stop,exit,cancel,plain chat, andno skill - stronger competing-skill detection when the new turn clearly targets another registered skill
- optional per-skill manifest metadata:
continueOnfor phrases that should strongly keep the active skillexitOnfor phrases that should explicitly end the active skill
To persist sessions across process restarts, replace the default session store with the file-based implementation:
using AgileAI.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddAgileAI();
services.AddFileSessionStore(options =>
{
options.RootDirectory = Path.Combine(AppContext.BaseDirectory, "sessions");
});await foreach (var update in chatClient.StreamAsync(new ChatRequest
{
ModelId = "openai:gpt-4o",
Messages = [ChatMessage.User("Tell me a joke")]
}))
{
switch (update)
{
case TextDeltaUpdate text:
Console.Write(text.Delta);
break;
case CompletedUpdate completed:
Console.WriteLine($"\n[finish_reason={completed.FinishReason}]");
break;
case ErrorUpdate error:
Console.WriteLine($"\nError: {error.ErrorMessage}");
break;
}
}var toolRegistry = new InMemoryToolRegistry();
toolRegistry.Register(new WeatherTool());
var session = new ChatSession(chatClient, "openai:gpt-4o", toolRegistry);
var result = await session.SendAsync("What's the weather in San Francisco?");See samples/ToolCallingSample for a fuller example.
services.AddOpenAIProvider(options =>
{
options.ApiKey = "your-api-key";
});Use model ids like openai:gpt-4o.
OpenAIOptions supports:
ApiKeyBaseUrlRequestTimeoutMaxRetryCountInitialRetryDelay
Azure OpenAI differs from standard OpenAI in a few important ways:
- you route by deployment name, not raw model name
- requests go to
/openai/deployments/{deployment}/chat/completions - authentication uses the
api-keyheader - requests require an
api-versionquery parameter
services.AddAzureOpenAIProvider(options =>
{
options.Endpoint = "https://your-resource.openai.azure.com/";
options.ApiKey = "your-api-key";
options.ApiVersion = "2024-02-01";
});Use model ids like azure-openai:your-deployment-name.
AzureOpenAIOptions supports:
EndpointApiKeyApiVersionRequestTimeoutMaxRetryCountInitialRetryDelay
Use this provider for third-party endpoints that expose an OpenAI-style chat/completions API.
services.AddOpenAICompatibleProvider(options =>
{
options.ProviderName = "deepseek";
options.ApiKey = "your-api-key";
options.BaseUrl = "https://api.deepseek.com/v1/";
});Use model ids like deepseek:deepseek-chat.
OpenAICompatibleOptions supports:
ProviderNameApiKeyBaseUrlRelativePathAuthModeApiKeyHeaderNameRequestTimeoutMaxRetryCountInitialRetryDelay
For providers that require a custom API key header instead of Authorization: Bearer, configure it like this:
services.AddOpenAICompatibleProvider(options =>
{
options.ProviderName = "custom-gateway";
options.ApiKey = "your-api-key";
options.BaseUrl = "https://gateway.example.com/v1/";
options.AuthMode = OpenAICompatibleAuthMode.ApiKeyHeader;
options.ApiKeyHeaderName = "x-api-key";
});services.AddOpenAIResponsesProvider(options =>
{
options.ApiKey = "your-api-key";
});Use model ids like openai-responses:gpt-4.1-mini.
OpenAIResponsesOptions supports:
ApiKeyBaseUrlRequestTimeoutMaxRetryCountInitialRetryDelay
services.AddGeminiProvider(options =>
{
options.ApiKey = "your-api-key";
});Use model ids like gemini:gemini-2.5-flash.
GeminiOptions supports:
ApiKeyBaseUrlRequestTimeoutMaxRetryCountInitialRetryDelay
services.AddClaudeProvider(options =>
{
options.ApiKey = "your-api-key";
options.Version = "2023-06-01";
});Use model ids like claude:claude-3-5-sonnet-latest.
ClaudeOptions supports:
ApiKeyBaseUrlVersionRequestTimeoutMaxRetryCountInitialRetryDelay
The current streaming implementation includes some intentional semantics worth noting:
ToolCallDeltaUpdate.ToolCallIdis always non-null for OpenAI-compatible providers once an id has been observedNameDeltais only populated when the current delta carries a tool name fragmentArgumentsDeltais only populated when the current delta carries an arguments fragment- tool arguments are emitted incrementally, so consumers should accumulate them if they need the final full JSON payload
- provider-specific streaming event shapes are normalized into shared
StreamingChatUpdatemodels where possible
ChatMessage can now carry shared ContentPart values through ContentParts or the helper ChatMessage.User(params ContentPart[] parts).
Currently supported behavior:
- Gemini maps
BinaryPartto inline binary data and preservesTextPart - Claude preserves
TextPartblocks and falls back non-text parts to readable markers - OpenAI Chat Completions and OpenAI Responses currently degrade non-text parts to readable text markers
Examples of current fallback markers:
ImageUrlPart("https://example.com/cat.png")->[image: https://example.com/cat.png]BinaryPart(data, "application/pdf")->[binary: application/pdf, N bytes]
This keeps shared message construction stable even when a provider does not yet expose full multimodal request mapping.
The test suite currently covers areas such as:
- provider routing and default provider behavior
- request/response mapping for all implemented providers
- tool definition mapping and tool call response mapping
- streaming text, usage, completion, and tool call edge cases
- invalid streaming payload handling
- local skill prompt injection and deduplication
- session store create/load/update/delete flows
- active skill continuation behavior in the runtime
- Studio backend service flows including model catalog, agent management, tool approval, command execution, and prompt-skill execution
- built-in Studio tool coverage for
run_local_commandintegration paths and the newweb_fetchtool
Run the full suite with:
dotnet test AgileAI.slnxLatest measured backend coverage snapshot:
220passing tests intests/AgileAI.Tests69.41%total line coverage fromtests/AgileAI.Tests/TestResults/80a0b6f3-45ea-4e4c-8321-f279ac720964/coverage.cobertura.xml57.61%line coverage forAgileAI.Studio.Api
The remaining biggest coverage gaps are in provider streaming/retry paths and Studio streaming orchestration, not in the newly added web_fetch tool path.
dotnet build AgileAI.slnxexport OPENAI_API_KEY="your-api-key"
cd samples/ConsoleChat
dotnet runexport OPENAI_API_KEY="your-api-key"
cd samples/ToolCallingSample
dotnet runexport OPENAI_COMPATIBLE_PROVIDER="deepseek"
export OPENAI_COMPATIBLE_API_KEY="your-api-key"
export OPENAI_COMPATIBLE_BASE_URL="https://api.deepseek.com/v1/"
export OPENAI_COMPATIBLE_MODEL="deepseek-chat"
export OPENAI_COMPATIBLE_AUTH_MODE="bearer"
cd samples/OpenAICompatibleChat
dotnet runFor gateways that require a custom API key header instead of Authorization: Bearer, also set:
export OPENAI_COMPATIBLE_AUTH_MODE="header"
export OPENAI_COMPATIBLE_API_KEY_HEADER="x-api-key"export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
export AZURE_OPENAI_API_KEY="your-api-key"
export AZURE_OPENAI_DEPLOYMENT="your-deployment-name"
export AZURE_OPENAI_API_VERSION="2024-02-01"
cd samples/AzureOpenAIChat
dotnet runexport GEMINI_API_KEY="your-api-key"
export GEMINI_MODEL="gemini-2.5-flash"
cd samples/GeminiChat
dotnet runexport CLAUDE_API_KEY="your-api-key"
export CLAUDE_MODEL="claude-3-5-sonnet-latest"
export CLAUDE_API_VERSION="2023-06-01"
cd samples/ClaudeChat
dotnet runexport OPENAI_API_KEY="your-api-key"
export OPENAI_RESPONSES_MODEL="gpt-4.1-mini"
cd samples/OpenAIResponsesChat
dotnet run- richer content parts such as image or audio inputs
- more advanced tool orchestration and structured outputs
- additional session store implementations
- package publishing to NuGet
MIT


