CxAgent.Core
0.9.10
See the version list below for details.
dotnet add package CxAgent.Core --version 0.9.10
NuGet\Install-Package CxAgent.Core -Version 0.9.10
<PackageReference Include="CxAgent.Core" Version="0.9.10" />
<PackageVersion Include="CxAgent.Core" Version="0.9.10" />
<PackageReference Include="CxAgent.Core" />
paket add CxAgent.Core --version 0.9.10
#r "nuget: CxAgent.Core, 0.9.10"
#:package CxAgent.Core@0.9.10
#addin nuget:?package=CxAgent.Core&version=0.9.10
#tool nuget:?package=CxAgent.Core&version=0.9.10
CxAgent.Core
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.
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.
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
IAgentToolinterface, the two gates, and whatGateshould 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 | Versions 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. |
-
net10.0
- AngleSharp (>= 1.7.1)
- CxAgent.Plugins.Abstractions (>= 0.9.10)
- Microsoft.Data.Sqlite (>= 10.0.11)
-
net8.0
- AngleSharp (>= 1.7.1)
- CxAgent.Plugins.Abstractions (>= 0.9.10)
- Microsoft.Data.Sqlite (>= 10.0.11)
-
net9.0
- AngleSharp (>= 1.7.1)
- CxAgent.Plugins.Abstractions (>= 0.9.10)
- Microsoft.Data.Sqlite (>= 10.0.11)
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 |