A reusable .NET 10 / C# library (Maxwell.Agents) plus a console app (maxwell-cli,
project Maxwell.Cli) for running local AI agents against a llama.cpp server, with
resumable, on-disk chat sessions.
maxwell/
Maxwell.sln
src/
Maxwell.Agents/ class library: config, storage, provider, session orchestration
Maxwell.Cli/ console app: arg parsing + chat loop
dotnet build
dotnet run --project src/Maxwell.Cli -- --agent MaxwellTo use it as the maxwell-cli command from any directory, per the spec:
dotnet pack src/Maxwell.Cli -c Release
dotnet tool install --global --add-source src/Maxwell.Cli/nupkg maxwell-cli
maxwell-cli
maxwell-cli --session 121313
maxwell-cli --session 121313 --agent MyAgent
maxwell-cli --agent MyAgent{HomeDirectory}/.maxwell/
connections.json # AI provider connections
agents.json # named agents -> {connection, model}
skills/{AgentName}/ # optional: AgentSkills folder for this agent
instructions/{AgentName}.md
plugins/{pluginFolder}/ # optional: plugin.json + entry assembly (see below)
projects/{ProjectId}/
sessions.json # index: id, title, agent, timestamps
{sessionId}.json # full transcript + serialized AgentSession
{WorkingDirectory}/.maxwell/
project.json # { "id": "<ProjectId>" }
skills/{AgentName}/ # optional additions to the Home skills
instructions/{AgentName}.md # optional override of the Home instructions
ProjectId is derived from the full, sanitized path of the working directory
(so two folders named e.g. app in different locations get separate session
histories). {HomeDirectory}/.maxwell is bootstrapped on first run with a
default llama-cpp connection (http://localhost:8080/v1) and three default
agents — Maxwell (general-purpose), SkillSmith, and AgentSmith (see
below) — edit connections.json/agents.json to point at your actual
llama.cpp server and model name.
An agent's skills/{AgentName}/ folder (Home and/or Working, see layout
above) holds subfolders in the Agent Skills format -
the same open standard used by Claude Code, GitHub Copilot, and others.
Maxwell doesn't invent its own skill format; SkillLoader hands each folder
straight to AgentSkillsDotNet's loader, so anything that's a valid Agent
Skill elsewhere is a valid Agent Skill here.
skills/{AgentName}/
{skill-name}/
SKILL.md # required — YAML frontmatter + Markdown instructions
scripts/... # optional — code the agent can read and run itself
references/... # optional — extra docs loaded on demand
assets/... # optional — templates, sample configs, ...
SKILL.md:
---
name: pdf-processing
description: Extract PDF text, fill forms, merge files. Use when handling PDFs.
---
# PDF processing
Detailed instructions for the agent to follow when this skill is active...name (max 64 chars, lowercase/numbers/hyphens, must match the folder name
exactly) and description (max 1024 chars, non-empty) are required;
license, compatibility, metadata, and allowed-tools are optional. See
the full specification for exact
constraints.
SkillSmith is a second default agent whose only job is writing new skills
for your other agents. It only has read/edit/write/validate_skill —
no bash — scoped to {HomeDirectory}/.maxwell/skills/ rather than your
working directory, and validate_skill re-parses whatever it just wrote
through the real Agent Skills loader so it gets an honest pass/fail instead of
guessing at YAML correctness:
maxwell-cli --agent SkillSmith
> I want Maxwell to be able to summarize git log output. Make it a skill.It will ask what it needs to (which agent, what triggers the skill), write
{HomeDirectory}/.maxwell/skills/Maxwell/{skill-name}/SKILL.md, validate it,
fix anything the validator flags, and tell you when it's ready — Maxwell
picks it up automatically next time it resolves its skills, no restart
needed. If you already had an agents.json before this update, add the
SkillSmith entry by hand (the bootstrapper only seeds agents.json when the
file doesn't exist yet):
{ "name": "SkillSmith", "connection": "llama-cpp", "model": "gemma", "clientType": "ChatCompletion", "toolProfile": "SkillAuthoring" }AgentSmith builds new specialist agents end to end from a plain-language
request - registering the entry in agents.json, writing its instructions,
and (if the task needs it) authoring a skill for it, all in one conversation.
It doesn't delegate to SkillSmith for the skill-authoring part; it has the
same skill-authoring tools itself (renamed read_skill_file/write_skill_file/
edit_skill_file to avoid colliding with its instructions tools), plus
read_instructions/write_instructions and list_connections/list_agents/
register_agent. Like SkillSmith, no bash — this is declarative authoring
(files + registry entries), not general-purpose computer use.
maxwell-cli --agent AgentSmith
> Necesito un agente especialista en Excel, que pueda crear y actualizar archivos de Excel.It will ask whatever it actually needs (a name, which connection/model to use
if more than one exists) rather than guessing, register the new agent with a
sensible default tool profile (Filesystem — read/bash/edit/write on your
working directory, so it can shell out to whatever it needs to manipulate
files), write its instructions, and write + validate a skill for it if the
task calls for one. The new agent is usable immediately:
maxwell-cli --agent <name>. AgentSmith itself defaults to AgentAuthoring
for its tool profile - add it by hand the same way as SkillSmith if you
already had an agents.json:
{ "name": "AgentSmith", "connection": "llama-cpp", "model": "gemma", "clientType": "ChatCompletion", "toolProfile": "AgentAuthoring" }Drop a folder under {HomeDirectory}/.maxwell/plugins/ containing a
plugin.json and a compiled entry assembly:
{
"id": "maxwell-anthropic",
"version": "0.1.0",
"entryAssembly": "Maxwell.Plugin.Anthropic.dll"
}The assembly should reference Maxwell.Agents and contain a public type
implementing Maxwell.Agents.Plugins.IMaxwellPlugin, with a public
parameterless constructor:
public sealed class AnthropicPlugin : IMaxwellPlugin
{
public string Id => "maxwell-anthropic";
public void ConfigureProviders(IPluginProviderRegistry providers) =>
providers.Register("Anthropic", new AnthropicAgentProvider());
public void ConfigureTools(IPluginToolRegistry tools) { } // no extra tools
}Each plugin loads into its own collectible AssemblyLoadContext, so its
private dependencies can't clash with Maxwell's or with another plugin's.
Registered providers become usable by setting clientType in
connections.json to whatever string the provider was registered under (e.g.
"Anthropic"); registered tools are available to every agent, in addition to
the built-in read/bash/edit/write tools and each agent's own skills. A plugin
that fails to load (bad manifest, missing assembly, exception in its
Configure* methods) is skipped rather than stopping Maxwell from starting.
A plugin can also observe or intervene at specific points of a chat turn by
implementing Maxwell.Agents.Hooks.IChatHook and registering it in
ConfigureHooks:
public sealed class AuditLogPlugin : IMaxwellPlugin
{
public string Id => "maxwell-audit-log";
public void ConfigureHooks(IPluginHookRegistry hooks) => hooks.Register(new AuditLogHook());
}
public sealed class AuditLogHook : IChatHook
{
public Task OnBeforeToolCallAsync(ToolCallContext context, CancellationToken cancellationToken)
{
if (context.ToolName == "bash" && context.Arguments.TryGetValue("command", out var cmd)
&& cmd?.ToString()?.Contains("rm -rf") == true)
{
context.Block("bash commands containing 'rm -rf' are not allowed by policy.");
}
return Task.CompletedTask;
}
public Task OnTurnCompletedAsync(TurnCompletedContext context, CancellationToken cancellationToken)
{
Console.Error.WriteLine($"[{context.Session.SessionId}] {context.Usage?.TotalTokenCount} tokens");
return Task.CompletedTask;
}
}Every method on IChatHook has a no-op default, so a hook only implements the
stage(s) it needs. The stages, in the order they fire for one user turn:
| Stage | When | Can it change the outcome? |
|---|---|---|
OnUserPromptAsync |
Before the message is sent to the model | Yes — rewrite context.Prompt |
OnBeforeToolCallAsync |
Immediately before a tool executes | Yes — mutate context.Arguments, or call context.Block(reason) to prevent execution |
OnAfterToolCallAsync |
Immediately after a tool executes (or throws) | Yes — set context.Result to redact/transform what the model sees, or recover from an exception |
OnResponseChunkAsync |
For each piece of streamed reply/reasoning text | No — observation only |
OnTurnCompletedAsync |
Once the full turn is assembled, before it's persisted | No — observation only |
OnBeforeToolCallAsync/OnAfterToolCallAsync are the only stages that can
actually stop a tool from running: they wrap the tool's real invocation
directly, whereas by the time a tool call is visible anywhere else it has
already executed. Multiple hooks run in registration order; for blocking, the
first hook to call Block wins.