Toolnexus 0.9.3

dotnet add package Toolnexus --version 0.9.3
                    
NuGet\Install-Package Toolnexus -Version 0.9.3
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Toolnexus" Version="0.9.3" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Toolnexus" Version="0.9.3" />
                    
Directory.Packages.props
<PackageReference Include="Toolnexus" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Toolnexus --version 0.9.3
                    
#r "nuget: Toolnexus, 0.9.3"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Toolnexus@0.9.3
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Toolnexus&version=0.9.3
                    
Install as a Cake Addin
#tool nuget:?package=Toolnexus&version=0.9.3
                    
Install as a Cake Tool

toolnexus

NuGet license

Build an agent in a few lines. Give a toolkit some tools — built-ins, an mcp.json, a skills/ folder, your own C# methods, HTTP endpoints, other agents — point a client at any LLM, call RunAsync(), and the tool-calling loop runs to an answer. Every source is unified behind one ITool interface and emitted in OpenAI / Anthropic / Gemini schema.

Right-sized. Not a framework (no builders to learn, no config to wade through), not a toy that falls over the moment you need streaming or a retry. Everything a real agent needs — the loop, hooks, streaming, retries, conversation memory — and nothing it doesn't.

The C#/.NET port of toolnexus — the same library, byte-identical, also in JavaScript, Python, Go, and Java. Built on the official ModelContextProtocol SDK. Targets .NET 10.

Install

dotnet add package Toolnexus

Quick start — an agent in 3 steps

using Toolnexus;

// 1. a toolkit — the 10 built-in tools (bash/read/write/edit/grep/glob/…) are on by default
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options());

// 2. a client — point at any OpenAI- or Anthropic-style endpoint
var agent = LlmClient.Create(new LlmClient.Options
{
    BaseUrl = "https://openrouter.ai/api/v1",
    Style   = "openai",                       // or "anthropic"
    Model   = "anthropic/claude-3.5-sonnet",
    ApiKey  = Environment.GetEnvironmentVariable("OPENROUTER_API_KEY"),
});

// 3. run — tools called for you, looped to a final answer
var res = await agent.RunAsync("List the files in the current folder, then summarise the README.", tk);
Console.WriteLine(res.Text);

The loop runs call → execute tools → feed results back → repeat, with hooks, streaming, and retries/backoff available. res is a RunResult carrying Text, Messages, ToolCalls (+ ToolCallCount), Turns, Usage, and Model.

API key. ApiKey is optional — when unset the client reads OPENROUTER_API_KEY, then OPENAI_API_KEY / ANTHROPIC_API_KEY from the environment. Keys are use-only; never bake one into code.

With MCP servers + agent skills

Point the toolkit at an mcp.json (local stdio and remote streamable-HTTP servers) and a folder of <name>/SKILL.md skills. Every MCP tool and the skill tool join the same tool set.

await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()
    .WithMcpConfig("./mcp.json")     // path, raw JSON string, or a parsed config dict
    .WithSkillsDir("./skills"));     // one or more skill roots

var res = await agent.RunAsync("Refund order 1234 for the customer.", tk);

Skills are injected into the system prompt as a catalog; the skill tool loads a skill's full instructions + resources on demand (progressive disclosure). A failing MCP server is isolated — it contributes no tools and never breaks the toolkit.

Conversations / memory

RunAsync is stateless — each call starts fresh. For a remembered, multi-turn conversation use AskAsync with a stable id: the client loads that id's transcript from its conversation store, runs, saves the updated transcript, and returns the answer — so the next AskAsync with the same id continues where it left off.

var user = "user-42";

var a = await agent.AskAsync("My name is Muthu and I love Go.", tk, user);
var b = await agent.AskAsync("What's my name and favourite language?", tk, user);
Console.WriteLine(b.Text);   // "Your name is Muthu and you love Go."

// same overload, no id  →  stateless one-shot (identical to RunAsync)
var once = await agent.AskAsync("Unrelated question.", tk);
public async Task<RunResult> AskAsync(
    string prompt, Toolkit toolkit, string? id = null,
    Action<string>? onText = null, CancellationToken cancellationToken = default);
  • Non-null id — remembers via the store: store.GetAsync(id)RunAsync(prompt, toolkit, history)store.SaveAsync(id, result.Messages).
  • Null id — stateless, exactly equivalent to RunAsync.
  • onText — a block-style streaming sink: when set, the streaming loop runs and each assistant text delta is forwarded as it arrives; AskAsync still returns the final RunResult.

Where transcripts live

The store comes from the Store option on LlmClient.Options, defaulting to InMemoryConversationStore (kept for the client's lifetime). Implement IConversationStore to persist across processes (file, database, Redis):

public interface IConversationStore
{
    Task<List<object?>?> GetAsync(string id);        // stored transcript, or null
    Task SaveAsync(string id, List<object?> messages); // persist the updated transcript
}

var agent = LlmClient.Create(new LlmClient.Options
{
    BaseUrl = "https://openrouter.ai/api/v1",
    Model   = "anthropic/claude-3.5-sonnet",
    Store   = new MyFileConversationStore("./conversations"),  // your IConversationStore
});

The same store powers inbound A2A (below): when a peer calls your served agent, its A2A contextId becomes the conversation id, so a peer's turns are remembered through this store.

Streaming with memory

The id also works while streaming. Pass onText to AskAsync to stream text deltas as they arrive — AskAsync still returns the final RunResult — or use StreamAsync(prompt, tk, onEvent, id) for the full event stream. With an id, the thread is loaded before the stream and saved once the run terminates.

// block-style: stream deltas to the console, still get the RunResult — remembered under "user-42"
var r = await agent.AskAsync("Draft a reply.", tk, "user-42",
    onText: delta => Console.Write(delta));

// event stream with memory: id ⇒ load before, save at the end
await agent.StreamAsync("And summarise it.", tk, ev =>
{
    if (ev.Type == LlmClient.StreamKind.Text) Console.Write(ev.Delta);
    else if (ev.Type == LlmClient.StreamKind.Done) Console.WriteLine($"\n{ev.Result!.Usage}");
}, "user-42");

Observability / metrics

Zero-dependency, two outputs from one internal instrumentation — both opt-in, no cost when unused.

OnMetric — a semantic event feed. Set OnMetric on LlmClient.Options and it receives a readable MetricEvent (a record) at each significant point: Event == "llm" (Model, Status, Ms, PromptTokens, CompletionTokens) per model call, Event == "tool" (Tool, Source, IsError, Ms) per tool call, and a terminal Event == "run" (Model, Turns, ToolCalls, TotalTokens, Ms, Error) per run/ask. Forward it anywhere (statsd, logs, OpenTelemetry).

var agent = LlmClient.Create(new LlmClient.Options
{
    BaseUrl = baseUrl, Style = "openai", Model = model,
    OnMetric = ev => Console.WriteLine($"[metric] {ev.Event} {ev}"),
});

agent.Metrics() — built-in Prometheus text. The same events feed a tiny in-memory registry that renders the Prometheus text exposition format (no third-party dep). Mount it at GET /metrics:

// ASP.NET Core minimal API
app.MapGet("/metrics", () =>
    Results.Text(agent.Metrics(), "text/plain; version=0.0.4"));

Series: toolnexus_llm_requests_total{model,status}, toolnexus_llm_tokens_total{type}, toolnexus_tool_calls_total{tool,source,is_error}, toolnexus_run_errors_total{model}, plus the toolnexus_llm_request_duration_seconds and toolnexus_tool_duration_seconds histograms. The rendered text is byte-identical across all five ports; OTLP push is a planned future companion.

Add your own tools

using Toolnexus;

// a C# method → a tool (attribute-based)
public sealed class MathTools
{
    [ToolMethod("add", "Add two numbers")]
    public string Add([Param("a")] double a, [Param("b")] double b) => (a + b).ToString();
}

tk.Register(Tools.FromObject(new MathTools()).ToArray());

// a plain function → a tool (no class needed)
tk.Register(NativeTool.Of("upper", "Uppercase a string",
    new Dictionary<string, object?>
    {
        ["type"] = "object",
        ["properties"] = new Dictionary<string, object?> { ["s"] = new Dictionary<string, object?> { ["type"] = "string" } },
        ["required"] = new List<object?> { "s" },
    },
    args => (args.TryGetValue("s", out var s) ? s?.ToString() ?? "" : "").ToUpperInvariant()));

// a REST endpoint → a tool
tk.Register(HttpTool.Of(new HttpTool.Options
{
    Name = "create_ticket", Description = "Create a ticket", Method = "POST",
    Url = "https://api.example.com/tickets",
    Headers = new Dictionary<string, string> { ["Authorization"] = "Bearer ${API_TOKEN}" }, // ${ENV} expands, never logged
    InputSchema = new Dictionary<string, object?>
    {
        ["type"] = "object",
        ["properties"] = new Dictionary<string, object?> { ["title"] = new Dictionary<string, object?> { ["type"] = "string" } },
        ["required"] = new List<object?> { "title" },
    },
}));

Register(params ITool[]) is chainable and first-name-wins (a duplicate name is warned and dropped). You can also pass ExtraTools / AnnotatedObjects straight into Toolkit.Options.

The tool sources at a glance

Source How
Built-in 10 tools, on by default — see below
MCP servers an mcp.json (mcpServers/servers/mcp); local stdio + remote streamable-HTTP, Headers for auth
Agent skills a folder of <name>/SKILL.md; a skill tool loads each on demand + a system-prompt catalog
Native tools [ToolMethod]/[Param] on a class (Tools.FromObject), or NativeTool.Of(...)
HTTP / REST HttpTool.Of(...) — an endpoint becomes a tool, ${ENV} headers
A2A agents remote agents whose skills become tools (below)

All of them appear as one uniform ITool in tk.Tools().

Built-in tools

toolnexus ships 10 built-in toolsbash, read, write, edit, grep, glob, webfetch, question, apply_patch, todowrite (names + input schemas match opencode) — so an agent can act with zero wiring. They appear in the tool schema (ToOpenAI()/ToAnthropic()/ToGemini()), like MCP tools — not the system prompt.

On by default. WithBuiltins(false) turns the whole source off; a per-tool map disables individual builtins on the all-on baseline:

// whole source off (for a locked-down host)
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()
    .WithBuiltins(false));   // also accepts { ["disabled"] = true } / { ["enabled"] = false }

// per-tool: drop bash, keep the other nine (unknown names ignored; whole-source-off still wins)
await using var tk2 = await Toolkit.CreateAsync(new Toolkit.Options()
    .WithBuiltins(new Dictionary<string, object?>
    {
        ["tools"] = new Dictionary<string, object?> { ["bash"] = false },
    }));

bash/write/edit/apply_patch run commands and mutate the filesystem — these switches are the off-switch for locked-down hosts.

A2A agents (agent-to-agent)

Call remote A2A agents (each of their skills becomes a tool) and serve your own toolkit as an agent other A2A peers can call. A genuine, minimal subset of real A2A (JSON-RPC 2.0; Agent Card at /.well-known/agent-card.json; SendMessage → poll GetTask). No streaming / push / auth in v1.

Outbound — call a remote agent. Each advertised skill becomes a tool named <agent>_<skill> (source "a2a"):

await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()
    .WithAgents(new Agent { Card = "https://researcher.example.com/.well-known/agent-card.json" }));

// or add one at runtime (an Agent, or a bare card URL):
await tk.AddAgentAsync("https://writer.example.com/.well-known/agent-card.json");

new Agent { Card, Headers?, Timeout?, PollEvery? }Headers support ${ENV} expansion (never logged); Timeout / PollEvery are milliseconds (300000 / 1000 defaults). A config file can also carry an agents block (mirrors mcpServers). A failing agent is isolated — contributes no tools, never fatal.

Inbound — serve your toolkit as an agent. The Agent Card is built from your SKILL.md skills (never raw tools):

var agent = LlmClient.Create(new LlmClient.Options
{
    BaseUrl = "https://openrouter.ai/api/v1", Style = "openai", Model = "anthropic/claude-3.5-sonnet",
});

var handle = await tk.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions
{
    Client = agent,
    A2A = new A2AConfig
    {
        Name = "research-agent",
        Description = "Answers research questions.",
        // Skills = new List<string> { "hello-world" }, // subset of skills to advertise; null ⇒ all
        Store = "memory",                                // task store: "memory" | "file:<dir>" | a custom ITaskStore
    },
});
Console.WriteLine(handle.Url);   // GET /.well-known/agent-card.json ; POST / (SendMessage / GetTask)
await handle.StopAsync();

Each inbound SendMessage is fulfilled by the client. A message's A2A contextId keys the conversation via client.AskAsync, so a peer's turns are remembered through the client's IConversationStore (a message with no contextId is a stateless run). Task lifecycle persistence is a separate, pluggable ITaskStore — in-memory default, "file:<dir>", or your own.

Serve as an MCP server (be a gateway)

The inbound mirror of A2A: expose your whole toolkit as an MCP server so any MCP client — an IDE, another agent, a remote host — can call its tools. Point toolnexus at N MCP servers + skills + your own functions, then re-expose the union as one MCP server. Unlike A2A, the MCP client is the LLM host, so each tools/call dispatches straight to the tool's ExecuteAsync — no client, no tasks, no store.

// streamable-HTTP — an embeddable MCP server mounted at POST /mcp, beside any A2A routes:
var srv = await tk.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions
{
    Mcp = new MCPServeConfig { Name = "my-gateway" },   // optional Tools = ["echo"] subset; null ⇒ all
    OnCall = ev => { Console.Error.WriteLine($"{ev.Name} {ev.Ms} {ev.IsError}"); return Task.CompletedTask; },
});
Console.WriteLine(srv.Url + "/mcp");   // connect any MCP client here
await srv.StopAsync();

tools/list advertises every toolkit tool (name verbatim, inputSchema = the tool's parameters); Mcp.Tools narrows the surface. The profile can also live in the config file as a top-level mcpServer block (singular — distinct from the client-side mcpServers). (Transport is streamable-HTTP; a stdio transport for local clients like Claude Desktop is a planned follow-up.)

Bring your own loop

Don't want the built-in loop? Emit the schema for your provider, run your own calls, and let the toolkit execute the tool the model picks:

var tools  = tk.ToOpenAI();      // or tk.ToAnthropic() / tk.ToGemini()
var system = tk.SkillsPrompt();  // skills catalog for your system prompt

// when the model returns a tool call (name, arguments):
var res = await tk.ExecuteAsync(name, args);   // -> ToolResult(Output, IsError, Metadata)

API

Member Description
Toolkit.CreateAsync(opts) async factory → Toolkit (await using)
LlmClient.Create(opts) the unified host loop
agent.RunAsync(prompt, tk, history?, ct?) stateless run → RunResult
agent.AskAsync(prompt, tk, id?, onText?, ct?) remembered run when id is set (via IConversationStore); stateless when id is null; onText streams text deltas
agent.StreamAsync(prompt, tk, onEvent, id?, ct?) the loop with streaming events; id ⇒ stateful (load before, save at end)
agent.Metrics() Prometheus text exposition of cumulative metrics — mount at GET /metrics
tk.ServeAsync(addr, serveOpts) serve the toolkit as an A2A agent → ServeHandle
tk.Register(params ITool[]) add native/http/custom tools (chainable)
tk.AddAgentAsync(agent \| cardUrl) register a remote A2A agent's skills at runtime
tk.Tools() / tk.Get(name) the uniform tools
tk.ExecuteAsync(name, args, ctx?) run a tool → ToolResult
tk.SkillsPrompt() system-prompt skill catalog
tk.ToOpenAI() / ToAnthropic() / ToGemini() provider tool schemas
IConversationStore GetAsync(id) / SaveAsync(id, messages) — implement for file/db memory

More

Full docs, the other four language ports, the shared behavior spec, and runnable examples: https://github.com/muthuishere/toolnexus

MIT licensed. </content> </invoke>

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.9.3 0 7/14/2026
0.9.0 0 7/13/2026
0.8.0 0 7/13/2026
0.7.0 39 7/12/2026
0.6.0 52 7/9/2026
0.5.0 91 7/3/2026
0.4.0 96 7/2/2026
0.3.1 96 7/1/2026
0.3.0 97 7/1/2026
0.2.1 97 7/1/2026
0.2.0 98 7/1/2026
0.1.2 103 6/29/2026
0.1.1 100 6/29/2026