CxAgent.Core 0.9.5

There is a newer version of this package available.
See the version list below for details.
dotnet add package CxAgent.Core --version 0.9.5
                    
NuGet\Install-Package CxAgent.Core -Version 0.9.5
                    
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="CxAgent.Core" Version="0.9.5" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="CxAgent.Core" Version="0.9.5" />
                    
Directory.Packages.props
<PackageReference Include="CxAgent.Core" />
                    
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 CxAgent.Core --version 0.9.5
                    
#r "nuget: CxAgent.Core, 0.9.5"
                    
#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 CxAgent.Core@0.9.5
                    
#: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=CxAgent.Core&version=0.9.5
                    
Install as a Cake Addin
#tool nuget:?package=CxAgent.Core&version=0.9.5
                    
Install as a Cake Tool

CxAgent.Core

NuGet Downloads .NET License: MIT

The sessions, agents and turn loop behind cxagent — usable without a terminal.

A conversation, the agent running it, tool execution, sub-agent delegation, permissions, MCP, plugins and resumable stores. You supply where the text goes; there is no UI dependency.

dotnet add package CxAgent.Core

Targets net8.0, net9.0 and net10.0.

What is in it

Turn loop tool calls, retries, and compaction when the context fills
Twelve tools read, write, edit, glob, grep, shell, http, web fetch, todo, ask, delegate, skills
Sub-agents typed workers, each with its own context and briefing
Permissions every call passes a gate you answer; decisions persist and scope to a folder
Tool selection narrow what an agent is offered — per session, turn, or type
MCP stdio and HTTP servers
Plugins tools from a DLL loaded at run time — approved once, against a hash of what you load
Providers Anthropic, OpenAI-compatible, Ollama — several at once
Stores resume a session; a usage archive you can query

The documentation

api.md every public type, configuration, and tool selection
tools.md injecting tools of your own
plugins.md tools loaded from a DLL at run time
examples/ToolAgent a runnable console app with an injected tool
examples/SpectreAgent the same, with a rendered UI
examples/ReadOnlyAgent taking tools away — a selection you can watch hold
examples/CalculatorPlugin a plugin end to end, gating something absurd so the prompt is visible

Below: the mental model, then the four calls an app makes.


The mental model

A session is a conversation. An agent is the loop that runs one turn of it. You supply where words go. Everything else follows from those three sentences.

        your app
           │  supplies an observer, a working directory, a model
           ▼
    SessionManager ──── owns what sessions SHARE:
           │             the resume database, the usage archive, the log directory,
           │             the permission rules store, the command registry
           │
           ├── opens ──► Session ──── one conversation
           │                │          · Submit(text) starts a turn
           │                │          · a queue for text typed while one runs
           │                │          · says everything through YOUR observer
           │                ▼
           │            AgentHost ──── owns the agent, its executors, its MCP binding
           │                ▼
           │              Agent ────── the turn loop:
           │                             send → tool calls? → run them → send again
           │                             ▼
           │                        tools, each wrapped in a permission gate
           │                             ▼
           └────────────────────► sub-agents: an Agent with no session of its own

One turn at a time per session. Submit while a turn runs does not start a second one — it queues the text, and the running turn picks it up at its next tool call.

Nothing here writes to a console, opens a window, or ends a process. Every word the session produces goes through the ISessionObserver you hand it.

What is actually yours to write is small, and it is all at the edges:

ISessionObserver where words go — the one thing you must implement
IToolObserver tool activity; a no-op is fine
a prompt hook + a PermissionPolicy how a human is asked, and which folder to judge against — see Permissions
a model see Configuration

The conversation, the turn loop, the tools, delegation, permissions, compaction, MCP, plugins and the stores are all in here.


Who creates what

The thing that surprises people is how little you construct. SessionManager.Create builds the shared machinery; Open builds everything per-session.

You create SessionManager.Create creates Open creates, per session
AppPaths (where config and data live) the resume database (SQLite) the Session
an ISessionObserver the usage archive (a different database) its AgentHost and Agent
an IToolObserver the log directory manager the job registry, gated
a model — see Configuration the permission rules store the sub-agent spawner
optionally a permission gate the command registry the agent-type catalog

You never construct an Agent, an AgentHost, or a tool. AgentHost is not even public.

Logs. One directory per session, named by its id, under your config directory. A sub-agent gets its own directory nested inside its parent's — so a finished child is inspectable, and ls -t never shows a child above the session that spawned it.


An app is four calls

using CxAgent.Core.Sessions;
using CxAgent.Core.Llm;
using CxAgent.Core.Storage;

using var manager = SessionManager.Create(new AppPaths(configDir));

var session = manager.Open(
    workingDirectory,                                        // the permission boundary
    resolution,                                              // which model — see below
    new SessionPorts { Observer = sink, ToolObserver = jobs });   // where words and tool activity go

if (session.Submit("summarise this folder") is Session.SubmitOutcome.Started started)
    await started.Turn;

Submit is synchronous. It returns a receipt, not a task, because there are three outcomes and they are three different things for a caller to do:

Outcome What happened What you do
Started(Task Turn) a turn began await it, or attach a continuation and keep rendering
Queued a turn was already running; this went to the queue nothing — the Pending event already fired
NoAgent no model is wired leave the text in your input box

Where words go

ISessionObserver is the only thing you must implement. Seven methods:

internal sealed class ConsoleSink : ISessionObserver
{
    public void UserTurnAdded(ChatMessageId id, string text) { }   // the prompt went in
    public void AssistantTurnBegan(ChatMessageId id) { }

    // Streaming body, token by token. Written raw: this is model output, and a bracket in it
    // is not a colour tag.
    public void AssistantTextAppended(ChatMessageId id, string token) => Console.Write(token);

    public void AssistantReasoningAppended(ChatMessageId id, string text) { }   // show it, or don't
    public void AssistantTurnEnded(ChatMessageId id) => Console.WriteLine();
    public void AssistantLabelled(ChatMessageId id, string header) { }

    // The SESSION's own words — "Stopped.", a mode change, a model switch, a failure. Markdown,
    // with the tone beside it: one method, and the severity says how to show it.
    public void Said(Message message) =>
        (message.Severity == Severity.Error ? Console.Error : Console.Out).WriteLine(message.Text);
}

Message is (string Text, Severity Severity), and a plain string converts to one implicitly — so Said receives Severity.Info unless Core said otherwise. The three severities are Info, Warning and Error; a fault is a severity rather than a channel, so there is no second method to implement. If you collect failures, filter on Severity.Error — which is exactly what BufferedChatSink.Errors does.

The text is markdown, and it is yours to render however your surface renders. Core escapes what it interpolates (Md.Escape for inline values, Md.EscapeCell inside a table, Md.Fence around a block), so a path like my_test_file.cs arrives with its underscores intact rather than as my test file. A console front end can print message.Text as-is; a Spectre one should call EscapeMarkup on it, since a literal bracket in a path is not a colour tag; a TUI renders it as markdown. Nothing needs stripping — Core writes no colour tags for you to remove.

ChatMessageId identifies a turn so you can stream into the right row. Ids are minted by the session, so a parent and its children never collide.

IToolObserver is optional — pass a no-op if you do not want to show tool activity:

internal sealed class ToolSink : IToolObserver
{
    private readonly HashSet<string> _announced = [];

    // ANNOUNCE FROM HERE. This fires while jobs RUN; ToolUpdated fires when one FINISHES, so
    // announcing starts from there prints nothing — a finished job is never Running.
    public void ToolsChanged(IReadOnlyList<Job> jobs)
    {
        foreach (var job in jobs)
            if (job.State is JobState.Running && _announced.Add(job.Id))
                Console.WriteLine($"  · {job.JobType}");
    }

    public void ToolUpdated(Job job) { }
    public void ToolProgressed(Job job) { }
    public void ToolResourcesSampled(string jobId, ResourceSnapshot snapshot) { }
    public void ToolOutputAppended(string jobId, string delta) { }
}

BufferedChatSink and BufferedJobPanel ship with the package if you only need to collect output — they are what the tests use.


Text typed while a turn runs

Submit handles this. Called mid-turn it queues the text and returns Queued; you do not call Steer yourself. What is queued arrives at the turn's next tool call, where the model can still act on it — and several lines typed in a burst become one message rather than several.

session.Pending   += (whole, added) => ShowQueuedBlock(whole);
session.Drained   += text           => RemoveQueuedBlock();      // the turn took it
session.Cancelled += text           => PutItBackInTheInputBox(text);

session.CancelTurn() stops the turn, says "Stopped." through your observer, and hands anything queued back through Cancelled — it does not decide where that text goes.


Tools

The agent gets these without you registering anything: read, write, edit, glob, grep, shell, http, web fetch, plus todowrite (a plan it keeps across turns), ask_user (a question back to you), agent (delegation) and skill. Each is wrapped in a permission gate at construction, so there is no path that runs a tool ungated.

Whether the model uses a tool is the model's business, not the library's.

Offering fewer of them is yours. A ToolSelection narrows what an agent is handed — at construction, per session, or for one request:

ToolSelection = new ToolSelection([Tool.Inherited, Tool.Not.RunShell]),

Tool names every built-in, so the terms are checked by the compiler rather than spelled: Tool.Glob, Tool.Not.RunShell, Tool.Also.Grep, Tool.Inherited, Tool.All. A withheld tool is refused if called by name, not merely hidden. The terms, the levels, and what selection does not reach →

Tools of your own

An embedder can add tools this library could not have anticipated — a deploy(env), a queryOurWarehouse(sql), something that draws into your own UI. One interface, one line of wiring:

Tools = [new DeployTool(), new QueryWarehouseTool()],

They are offered to the model beside the built-ins, dispatched behind them (so a consumer cannot shadow read_file), and gated on the way through — a tool you inject cannot run ungated, because the wrapping happens where the gate and the session's policy both are rather than being left to you to remember.

Your tool answers one question the engine cannot: does this call need a human. Everything else — asking, persisting the answer, scoping it to the folder — is already here.

Injecting your own tools →

Tools that arrive at run time

Injected tools are compiled into your app. A plugin is a DLL loaded from disk at run time — the same tools, from code you did not build:

await session.LoadPlugin(instance, manifest, loadSetDirectory, ct);

ManagedPluginLoader turns a path into that instance; PluginRegistry holds it, refuses a tool name already taken, and unwires it cleanly at the end. The plugin declares what it offers in a sidecar file read before its assembly is loaded, so a host can show a user what a plugin claims without running it.

The load gate is the boundary you own. Wire a permission gate and every load asks, against a content hash of the plugin's whole load set. Wire none and nothing asks — a headless host loading a plugin has already decided to run it, and Core does not pretend otherwise.

A plugin is not sandboxed: it runs in your process and can do what your process can. Core enforces the decision to load it, and the plugin declares which of its own tools need a prompt.

Writing a plugin →


Permissions

The engine is in here. The only thing left to you is asking a human.

Trust per folder, the working-directory boundary, edit modes, stored rules, the read-only command list, the Auto classifier, and the wrapper that gates every tool — all of it is Core, and none of it is yours to build. What Core cannot do is put a question on a screen, so you supply one delegate: given this request, what does the human say?

buildGate: store => PermissionDecider.WithPrompt(store, notice, promptHook)
//                                                ▲       ▲       ▲
//                                    Core hands   │       │       └── YOURS: ask, somehow
//                                    you this ────┘       └── optional: one-line notices

Two halves, and one without the other is silent. buildGate gives the decider a way to ask; SessionPorts.Policy gives it the folder and the edit mode to judge against:

var policy = new PermissionPolicy(workingDirectory, manager.Rules!, EditMode.AlwaysAsk);

var session = manager.Open(workingDirectory, resolution,
    new SessionPorts { Observer = sink, ToolObserver = jobs, Policy = policy });

Omit the policy and every request is refused — the decider has no directory to judge a path against and no edit mode to read, so it declines rather than guessing.

If you pass no buildGate at all, nothing is gated — every tool runs. That is a legitimate headless arrangement (a batch job in a container), but it is a choice, not something to inherit by forgetting.

How a request reaches you

  the model calls a tool
        ▼
  PermissionGatedExecutor wraps every built-in tool
        ▼
  PermissionPolicy.RequestsFor(...) turns the call into one or more requests
        │    a shell command → one Shell request
        │    a file copy     → a FileRead AND a FileWrite, judged separately
        ▼
  the policy answers silently if it can  ──── yes ──► the tool runs, you are never asked
        │
        no
        ▼
  YOUR promptHook            ──── you ask a human, however you like
        ▼
  Once / Always / Deny / TrustFolder

Supplying one

SessionManager.Create takes a delegate that builds a gate, not a gate — the gate needs the rules store, and the store is created inside:

var manager = SessionManager.Create(paths, buildGate: store =>
    PermissionDecider.WithPrompt(
        store,
        notice: line => Console.Error.WriteLine(line),   // "auto-refused" and rule notices; may be null
        promptHook: async (request, offerTrust, ct) =>
        {
            Console.Error.WriteLine($"{request.Kind}: {request.Display}");

            // Null means this cannot be honestly generalised — do NOT offer an "always" button.
            if (request.AlwaysRule is { } rule)
                Console.Error.WriteLine($"  \"always\" would cover: {rule}");

            return await AskAHuman(ct);
        }));
Your hook returns
Once allow this, ask again next time
Always allow and persist request.AlwaysRule for this folder
Deny refuse — the model sees the refusal and can adapt
TrustFolder trust the working directory, unlocking the silent class inside it

Cancellation must resolve your prompt, not abandon it. The token exists so a cancelled turn does not leave a dialog waiting for a click nobody will make; Core treats a cancelled request as a refusal.

What gets asked, and what does not

A request is judged by layers that only ever narrow, before your hook is reached:

Layer Effect
Trust per folder, identified by path and birth time — delete and recreate a folder and it inherits nothing
Edit mode AlwaysAsk (every write asks), AcceptEdits (in-boundary writes are silent), Auto
Stored rules what a human answered "always" to, confined to the boundary
The boundary your working directory, symlinks resolved — a link pointing out is out
Read-only verbs ls, cat, grep and friends run silently in a trusted folder, but only when every path they name is inside the boundary

Auto adds a model that reviews what would otherwise ask, and its verdict can now go either way. A ReviewEffect bounds what a verdict may change, per request kind: MayApprove (file writes, and shell within its confinement — see below) lets an allow silence the prompt and a deny refuse the action outright, with a reason the caller is told; MayAnnotate (http_request, MCP calls, and injected tools) lets the verdict shape the prompt but never decides the outcome — an allow here changes nothing; None means the classifier isn't consulted at all. Trust is still the floor under every case — an untrusted folder gets ReviewEffect.None regardless of kind, so an approval can never widen past the trust decision your host already made.

For shell, MayApprove only applies to a command that is already confined: every path it names, including a cd target, has to resolve inside the working directory; the whole command has to be parseable with no unresolved token; every segment has to name a program that appears in the command text — no $(...), no backticks, no eval, sh -c, xargs, or sudo; no recursive delete (rm -rf and friends, which the boundary cannot speak to — it bounds where a command reaches, not what it destroys there); and no egress verb (curl, wget, scp, rsync, and the rest) is ever eligible, for the same reason http_request is MayAnnotate rather than MayApprove — there is no in-boundary version of sending data off the machine. A command failing any one of those checks is never shown to the classifier, so a verdict is never asked to overrule a check it can't see.

A verdict decides exactly one action. It is never stored as a rule and never widens a trust or boundary decision made elsewhere. A timeout, a transport error, or a verdict it cannot parse all mean ask, same as if Auto weren't configured.

Everything fails toward asking. An unresolvable path is outside the boundary; a command carrying a token nothing could classify is refused rather than allowed.


Commands

Submit dispatches. Text starting with / runs a command when one matches; anything else, and plain text always, goes to the model:

switch (session.Submit(input))
{
    case Session.SubmitOutcome.Started started: await started.Turn; break;
    case Session.SubmitOutcome.Handled:         break;   // it already said its result
    case Session.SubmitOutcome.Queued:          break;   // joined a running turn
    case Session.SubmitOutcome.NoAgent:         break;   // nothing wired
}

Handle Handled, or a working command reads as busy. No turn starts and nothing reaches the model — that half is safe to ignore — but a loop written as if (outcome is Started) … else ShowBusy() will call every successful command "busy", because a command that ran is also is not Started. Switch on all four cases, as above, rather than testing for one and assuming the rest.

SubmitRaw is the escape hatch: it sends text verbatim, queue-or-start, with no dispatch check — for a caller that wants the model to see a leading slash, or that already decided this input is not a command.

Core services every command it ships. A session run without a front end at all still answers /clear, /model, /stats and the rest — a consumer wires nothing to get them:

session.SetMode("edits auto");        // or SetMode(WorkingMode)
session.UseFromInput("openrouter");   // /model — parses what a user typed
session.ListSessions("all");
session.SayUsage("30");               // /stats
session.ClearContext();
session.CompressNow(ct);

Each does the work, says its own result through your observer, and returns a CommandStatus: Reported (it said something, nothing moved), Changed (the session moved), Refused (it could not run now, and said why), Unknown (nothing services this). .Handled() collapses that to a bool if you are routing input.

A command exists where a handler is registered. manager.Commands is the registry Submit reads. Core's own table holds only what Core itself can run — ending a process is not in it, because a library cannot call Environment.Exit on your behalf. Register the ones only your front end can service:

// Only your front end can end your process — Core does not declare /exit at all.
manager.Commands.Register(
    new SessionCommand("/exit", "quit cxagent"),
    (_, _) => { myApp.Shutdown(); return true; });

Registering over a name already in Core's table replaces it — last registration wins. Delegate back to keep Core's half and add your own:

// Extend a Core command rather than replace it: call Core's handler and add to what it said.
manager.Commands.Register(
    SessionCommands.All.First(c => c.Name == "/help"),
    (session, args) =>
    {
        myApp.ShowKeyBindings();
        return session.ShowHelp().Handled();
    });

Session.ShowHelp() is the public way to render the commands table — Say itself is private, so this is how a handler that wants Core's listing gets it.

RegisterVerb adds an argument, not a command — for a case Core's table declares but cannot finish servicing, because the reply is not synchronous. /stats clear deletes a usage archive and has to be confirmed first; a CommandHandler returns bool and cannot ask a question and wait for the click, so the verb belongs to whichever front end can ask:

manager.Commands.RegisterVerb("/stats",
    new CommandArgument("clear", "delete all usage history, after confirming"),
    (session, _) => { ConfirmThenClear(session); return true; });

A verb appears in /help and the palette only where it was registered — a consumer that registers nothing sees /stats without clear, because offering an argument nobody can service is worse than not listing it.


Sub-agents

A session in fan-out mode (the default) can delegate. A child is an Agent with no session: its own context, its own log directory nested under the parent's, its own token budget. It reports back as a tool result, and no child outlives the turn that started it.

Pass AgentMode.Single if your front end has nowhere to show a child's progress. Delegation is capability rather than permission — a child runs under the same gate, in the same folder.


Configuration

Three ways in, depending on where your settings live. See the API reference for every field.

// From code — no config.json, no file at all.
var resolution = new AgentConfig
{
    Models =
    {
        ["local"] = new(ProviderKind.OpenAiCompatible, "qwen3.6-35b")
                    { BaseUrl = "http://localhost:8771/v1", ContextWindow = 212_992 },
    },
    DefaultModel = "local",
}.Resolve();

// From the same config.json cxagent reads — useful for sharing a setup.
ConfigResolver.Resolve(paths, env, useMock: false);

// For a test.
ResolvedConfig.ForTesting(provider, instanceName);

Mistakes come back in resolution.Errors with HasProvider false — never as exceptions, because a caller assembling this from its own settings wants to say what went wrong.

Providers: anthropic, openai-compatible, ollama. Several at once, and a sub-agent type can name a different one.


Watching a session work

session.TokensUpdated      += (_, total) => …;   // after each provider call
session.ContextUsedUpdated += (_, used)  => …;   // how full the window is
session.ContextCompressed  += (_, e)     => …;   // (Before, After)
session.TurnCompleted      += (_, turns) => …;
session.Changed            += kind       => …;   // Mode, Model, Resumed, TurnCancelled, ContextCleared

session.Ledger      // spend by model and by agent, cache rates, cost
session.IsBusy      // a turn is running
session.Mode        // delegation and edit mode

Subscribe after opening the session — these attach to the host that exists at subscription time.


Reference and examples

  • API reference → — every public member, with parameters and who calls it
  • Injecting your own tools → — the IAgentTool interface, the two gates, and what Gate should return
  • Writing a plugin → — tools loaded from a DLL at run time: the lifecycle, the sidecar, permission and settings
  • SpectreAgent → — a second front end in about a hundred lines: a prompt, streamed text, one line per tool
  • ToolAgent → — injecting your own tools, and the two gates each one passes through
  • ReadOnlyAgent → — the other direction: a selection that leaves an agent unable to write, shell out or delegate, printing every tool call so you can watch it hold
  • CalculatorPlugin → — a plugin end to end in about a hundred lines, with a permission prompt for adding two numbers so the gate is visible
  • CalculatorAbiPlugin → — the same calculator in one file of C, for a plugin your language cannot write managed

All five, with what each one is for →

License

MIT. See LICENSE.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  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.24 8 10/14/2026
0.9.23 36 10/14/2026
0.9.22 71 10/9/2026
0.9.21 127 9/13/2026
0.9.19 109 9/13/2026
0.9.18 107 9/12/2026
0.9.17 119 9/10/2026
0.9.16 113 9/8/2026
0.9.15 116 9/5/2026
0.9.14 112 9/4/2026
0.9.13 109 9/4/2026
0.9.12 105 9/3/2026
0.9.11 113 9/3/2026
0.9.10 103 9/1/2026
0.9.9 101 9/1/2026
0.9.8 120 8/29/2026
0.9.7 113 8/28/2026
0.9.6 113 8/28/2026
0.9.5 110 8/25/2026
0.9.4 119 8/25/2026
Loading failed