IronHive.Host.Protocol 0.55.0

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

ironhive-host

NuGet: IronHive.Host NuGet: IronHive.Cli NuGet: IronHive.Host.Protocol CI License: MIT

Universal Agent Host — CLI · server · embedded

A foundation tool for AI-powered automation—not just coding, but any task that benefits from intelligent command execution. One agent core (plan → execute → tool-call), exposed as an installable CLI, an embeddable .NET SDK, or a long-running server (stdio/JSON-Lines or HTTP/SSE) — pick the surface that fits, without re-implementing the agent loop.

The agent loop, context/compaction, mode system, MCP plugins, and permission engine itself live in IronHive.Agent (from ironhive-agent), which this package consumes as a PackageReference. IronHive.Host adds the hosting surface on top: CLI/server/embed entry points, the IronHive.Host.Protocol turn-stream contract, layered config, provider adapters, and session/execution-log integration (long-term memory comes from an MCP server). The agent loop runs on any injected IChatClient (UseChatClient / UseChatClientFactory), and the server surfaces expose a generic turn-stream rather than an application-specific wire format.

Features

  • Three surfaces, one core — CLI (ironhive), embeddable SDK (IronHive.Host), and server runners (stdio or HTTP/SSE) all drive the same IronHive.Agent loop.
  • Approvals on every surface — one tool-call policy (IToolCallPolicy) judges each call; an Ask is answered on the terminal, or — in run --server and by any host that passes a HitlBridge to its runner — by the client over the wire (hitl_request → hitl_response). See Human approval over the wire.
  • MCP-native tooling — plugs into MCP servers (memory, code execution, custom tools) instead of hardcoding a tool set.
  • Multi-provider out of the box — OpenAI, Anthropic, GoogleAI, xAI, Ollama, LM Studio, GPUStack, and local inference via LMSUPPLY_ENABLED.
  • Context-window safe by default — automatic history compaction (ContextManager, wired on every surface including AddIronHive) prevents silent context overflows, including on small quantized models. The CLI and run --server additionally install a hard-backstop TokenBudgetChatClient; library embedders wrap their own IChatClient with it (see TokenBudgetChatClient).
  • Resilient tool-calling — ResilientArgumentsMiddleware (a tool invocation pipeline step) and ResilientFunctionInvoker (the same behaviour as a plain FunctionInvoker) turn tool-call arguments that do not fit the tool into model-actionable recovery hints instead of aborting the stream. Installed by the CLI and run --server (after the loop guards and the permission gate); AddIronHive/AddIronHiveWithOpenAI do not decorate the client, so embedders install it themselves (see ResilientFunctionInvoker).
  • Advisor — set advisor.model and every CLI / run --server session gets an advisor tool (the library AddIronHive path has no advisor option): the working model can send the conversation so far to a stronger model and read its review (before committing to an approach, when stuck, before declaring done). It appears on the wire as an ordinary tool_start/tool_end.
  • Layered configuration — global → project → environment → .env, with automatic migration from legacy settings.json.

<details> <summary><strong>⚠️ 0.16.0 breaking repackage</strong> (upgrading from an older version? read this)</summary>

SDK 라이브러리 IronHive.Host.Core → IronHive.Host(SDK가 top-level 이름을 소유, 네임스페이스 IronHive.Host.Core.* → IronHive.Host.*). CLI tool 패키지 IronHive.Host → IronHive.Cli (실행 명령은 ironhive 그대로). turn-stream 프로토콜 계약은 별도 thin 패키지 IronHive.Host.Protocol(무의존)로 분리 유지. 옛 IronHive.Host tool 패키지는 배포 중단(unlist 아님 — 기존 복원 계속 동작); 신규 설치는 dotnet tool install -g IronHive.Cli. 릴리스는 이제 iyulab/ironhive-host에서 self-host — 옛 ironhive-cli-releases는 archive(read-only, 기존 v0.11-0.15 다운로드 URL 유지).

</details>

Contents

Philosophy

Do one thing well.

Receive a command. Plan. Execute. Return.

┌─────────────────────────────────────────┐
│          External Systems               │
│   CI/CD · Schedulers · Orchestrators    │
└────────────────────┬────────────────────┘
                     │ invoke
                     ▼
┌─────────────────────────────────────────┐
│             ironhive-host                │
│   Command → Plan → Execute → Done       │
└────────────────────┬────────────────────┘
                     │ MCP
                     ▼
┌─────────────────────────────────────────┐
│           Plugins (MCP Servers)         │
│   code-beaker · memory-indexer · ...    │
└─────────────────────────────────────────┘

As an SDK (IronHive.Host)

dotnet add package IronHive.Host

Build reusable agent hosts (agent loop, tools, session, providers). The CLI below is one consumption surface of this SDK.

As a CLI (IronHive.Cli)

dotnet tool install -g IronHive.Cli
ironhive

Or build from source:

git clone https://github.com/iyulab/ironhive-host
cd ironhive-host
dotnet build

Quick Start

# Interactive mode
ironhive

# Single command
ironhive -p "Write a README for this project"

# JSON output (for programmatic use)
ironhive -p "Hello" --output json

# Streaming JSON Lines
ironhive -p "Hello" --output jsonl

# Plain text (no ANSI)
ironhive -p "Hello" --plain

Without an API key

ironhive does not need a cloud provider to be useful. Local inference is on by default (lmsupply.enabled: true; LMSUPPLY_ENABLED=false turns it off): with no other provider configured, the first run downloads a GGUF model (cached under the LMSupply model cache) and runs it in-process through LMSupply — no key, no server:

ironhive -p "Summarize this directory"   # works with an empty config
ironhive doctor                          # shows which providers are configured and reachable

doctor reports Using local inference only (lmsupply) on such a setup and suggests a remote provider only as a performance option — that is the expected state, not a warning to fix. The first run is slower (model download + load); later runs start from the cache.

JSON Output Schema

--output json/jsonl is meant for programmatic consumption — piping into jq, another process, or a script. Fields that would otherwise serialize as null (sessionId, usage, thinking, toolCalls) are omitted from the object entirely rather than written as null.

--output json (single response, -p/--prompt):

{
  "content": "string",                 // always present — the assistant's text reply
  "sessionId": "string",               // omitted if no session is active
  "usage": {                           // omitted if usage wasn't reported
    "inputTokens": 0,
    "outputTokens": 0,
    "totalTokens": 0
  },
  "thinking": {                        // present only with --show-thinking AND the model returned thinking content
    "content": "string",
    "tokenCount": 0
  },
  "toolCalls": [                       // omitted if no tools were called
    {
      "name": "string",
      "arguments": "string",           // JSON-encoded arguments, as a string
      "result": "string",
      "success": true                  // true | false | null — null means the outcome is unknown
                                        // (unset unless the underlying IChatClient has
                                        // Microsoft.Extensions.AI function-invocation middleware)
    }
  ]
}

On cancellation (Ctrl+C) or an unhandled error, the object is replaced with an error shape instead: { "error": "cancelled", "code": 130 } or { "error": "<message>", "code": 1 }.

--output jsonl (streaming JSON Lines — one object per line, discriminated by type):

type Fields When
start sessionId First line, always
thinking content Only with --show-thinking, once per thinking delta
text content Once per text delta
tool_call id, name, arguments Once per tool-call delta (arguments is JSON-encoded)
tool_result id, name, success, result When a tool finishes (id pairs it with its tool_call; success is null when the outcome is unknown). Only when the chat client invokes tools
done sessionId Last line on success
error error Instead of done, on cancellation or an unhandled exception

sessions list --output json — an array ([] if empty), one object per session:

[
  {
    "id": "string",
    "status": "string",        // lowercased, e.g. "active", "completed"
    "model": "string",
    "created": "2026-08-31T00:00:00.0000000Z",  // ISO 8601 (round-trip "o" format)
    "messageCount": 0,
    "firstMessage": "string"
  }
]

sessions delete <id> --output json:

  • Success: { "deleted": "<id>", "success": true }
  • Missing <id> or session not found: { "error": "string", "code": 1 }

Session Management

# Continue most recent session
ironhive -c

# Resume specific session
ironhive -r <session-id>

# List sessions
ironhive sessions list
ironhive sessions list --output json

CLI Reference

Command Purpose
ironhive Interactive mode (or one prompt with -p)
ironhive run [PROMPT] Run a single prompt and exit (--server for JSON Lines server mode)
ironhive set <key> <value> Set a configuration value, e.g. ironhive set openai.apiKey <key>
ironhive get [key] Get a configuration value (all values without a key)
ironhive unset <key> Remove a configuration value
ironhive config [show\|path] Show all configuration, or the config file path
ironhive models List available models from configured providers (-p/--provider, --json)
ironhive update Check for and install updates (--check only checks, --force)
ironhive doctor Diagnose configuration and connectivity (--verbose, --fix)
ironhive sessions [list\|delete <id>] List and manage sessions (-n/--limit, default 10; -o/--output json)

Options of the default command (ironhive / ironhive -p):

Flag Purpose
-m, --model <MODEL> Model to use
--provider <PROVIDER> Provider to use
--show-tokens Show token usage statistics
--show-thinking Show the model's thinking/reasoning content
--no-stream Wait for the complete response instead of streaming
--plan Start in planning mode (read-only exploration)
--dry-run Show what would be done without executing
-c, --continue / -r, --resume <ID> Continue the most recent session / resume a specific one
--fork Fork the resumed session into a new session
-o, --output <FORMAT> / --plain text, json, jsonl / no ANSI formatting

run takes -p/--prompt, -m/--model, --provider, --show-tokens, --show-thinking, plus:

Flag Purpose
--json Output the result as one JSON document (below); stdout carries nothing else
--max-iterations <N> Model-call rounds allowed in this turn (overrides chatBehavior.maximumIterationsPerRequest)
--max-output-tokens <N> Output tokens per model call (overrides chatBehavior.maxOutputTokens)
--reasoning-effort <LEVEL> How much the model reasons per call — none, low, medium, high, extra_high (overrides chatBehavior.reasoningEffort)
--timeout <SECONDS> Stop the run after this many seconds
--server Server mode (JSON Lines on stdin/stdout — see AgentServerRunner)
--session-id <ID> Session ID for server mode
--auto-commit Commit changes after a successful run
--commit-message <MESSAGE> Commit message for --auto-commit (default: generated from the prompt)

For an unattended caller (a script, a scheduler, a benchmark harness) run says how the turn ended without anyone reading the answer:

Exit code stop_reason Meaning
0 completed The model finished its answer
2 step_limit · output_limit · tool_terminated · timeout Stopped short: the iteration cap, the output limit, a loop guard that ended the turn, or --timeout
3 content_filter The provider's content filter stopped the answer
1 error The run failed (including a provider that could not be created)
{ "content": "…", "stop_reason": "step_limit", "duration_ms": 41230,
  "tool_calls": 12, "refused_tool_calls": 2, "failed_tool_calls": 1,
  "calls": [ { "tool": "read_file", "outcome": "ok", "refusal_kind": null, "arguments": "{\"path\":\"a.txt\"}", "refusal": null },
             { "tool": "read_file", "outcome": "refused", "refusal_kind": "repeated_call", "arguments": "…", "refusal": "…" } ],
  "thinking": null, "usage": { "input_tokens": 18342, "output_tokens": 1210, "total_tokens": 19552 } }

refused_tool_calls counts calls the permission rules or a guard refused (the tool did not run, or its result was withheld); failed_tool_calls counts tools that ran and failed. calls lists every call in order — outcome is ok, failed, refused or unknown, a refusal carries its refusal_kind and message, and arguments is cut to 300 characters (a tool's own result is not included). A failure or timeout prints { "error": "…", "stop_reason": "error" | "timeout", "duration_ms": … }. Log output goes to stderr. A non-interactive run cannot ask for approval, so calls the rules mark Ask are refused — allow them in .ironhive/permissions.yaml (default_action: allow) when the run is meant to act unattended.

Model Configuration

# Environment variables
export GPUSTACK_ENDPOINT=http://localhost:8080/v1
export GPUSTACK_API_KEY=your-key
export GPUSTACK_MODEL=gpt-4o-mini

# Or OpenAI
export OPENAI_API_KEY=sk-xxx

Configuration

Configuration is merged in order (later overrides earlier):

  1. Global: ~/.ironhive/config.yaml
  2. Project: .ironhive/config.yaml
  3. Environment: exactly these variables are read (there is no IRONHIVE_* variable):
    • GPUSTACK_ENDPOINT, GPUSTACK_API_KEY, GPUSTACK_MODEL
    • OPENAI_API_KEY, OPENAI_MODEL, OPENAI_ENDPOINT
    • ANTHROPIC_API_KEY, ANTHROPIC_MODEL
    • GOOGLEAI_API_KEY (or GOOGLE_API_KEY), GOOGLEAI_MODEL
    • XAI_API_KEY, XAI_MODEL, XAI_ENDPOINT
    • OLLAMA_ENDPOINT, OLLAMA_MODEL, OLLAMA_ENABLED
    • LMSTUDIO_ENDPOINT, LMSTUDIO_MODEL, LMSTUDIO_ENABLED
    • LMSUPPLY_ENABLED
  4. .env file: Project root .env

On first run, a legacy ~/.ironhive/settings.json (from earlier versions) is automatically migrated to config.yaml.

Config keys

The loader accepts these top-level keys in config.yaml. Acronym provider sections use lowercase keys; unknown top-level keys are ignored with a logged warning.

Every provider block the CLI wires (gpuStack, openai, anthropic, googleai, xai, ollama, lmstudio) also takes streamIdleTimeoutSeconds — how long a streaming answer may stay silent, before its first token and between tokens, before the request ends with a «stream idle timeout». Unset = no limit (default); zero or negative is refused, naming the key, when that provider is set up. Unlike a whole-request deadline it tells a slow answer that is still flowing from a dead stream: for a local server on slow hardware, set it above the prompt-evaluation time before the first token (e.g. ironhive set lmstudio.streamIdleTimeoutSeconds 300).

Key Notes
gpuStack camelCase; carryToolImages (default off) — see lmstudio
openai lowercase (acronym)
anthropic
googleai lowercase (acronym)
xai
ollama enabled, endpoint (default http://localhost:11434, with or without /v1), model; carryToolImages (default off) — see lmstudio. Served through Ollama's OpenAI-compatible /v1 surface; no key
lmstudio lowercase (acronym); carryToolImages (default off) — an image a tool returns (an MCP tool drawing a chart) is sent to the model in a user message after the tool results. These endpoints speak Chat Completions, whose tool messages hold text only, so without it the image is replaced by a note; turn it on for a vision model (llama-server with --mmproj, LM Studio, vLLM)
lmsupply lowercase (acronym)
permissions
compaction
webSearch camelCase
deepResearch camelCase
chatBehavior camelCase — CLI / run --server loops only
advisor provider, model, maxCalls (per session, default 5) — off until model is set; CLI / run --server loops only
approval timeoutSeconds (default 300) — how long run --server waits for the client's hitl_response before closing the request as a denial
budget maxSessionTokens, maxSessionCost (USD, TokenMeter catalog prices), warningThreshold (default 0.8), stopOnLimit (default true) — per session (one CLI session, one run --server session), checked before every model call including each tool round; 0 = no limit (default); a call past it ends the turn with UsageLimitExceededException
agentsMd enabled (default on), maxCharacters (default 32,000) — the AGENTS.md files from the repository root (the directory with .git) down to the session's working directory join the system instructions, root first so the nearest wins; nothing above the repository is read; CLI / run --server loops only
toolRetrieval enabled (default off), maxTools (default 10), minRelevanceScore (default 0.3), minScoredSlots, alwaysInclude, stickyToolLimit (0 = off; above 0 sends the tools a conversation already sent again unchanged until a request needs one they lack — keeps a prefix-cached server's prompt cache), stickyChangeScore (default 0.7 — the score a scored tool the held set lacks must reach to change it; pins, exact names and aliases always do) — when on, each request sends the model only the tools most relevant to the user's request (aliases and companion tools a tool declares count) instead of every built-in and MCP tool; CLI / run --server loops only
delegation agentsDirectory (default agents, relative to the working directory), agents (list of name · provider · model · description · maxToolTurns; empty = off), maxDepth (default 2), maxConcurrent (default 3) — each listed Ironbees agent (agents/<name>/agent.yaml + system-prompt.md) becomes a tool the working model can hand a task to. The agent.yaml needs a model: section (Ironbees refuses to load one without it); leave deployment out to run on the session's model. A delegated run calls its tools through the session's own approval path (permission rules, planning mode, the approval prompt) and spends the session budget; delegation tools are left out in planning mode; CLI / run --server loops only
skills roots, enabled, exclude, maxMetadataCharacters (default 12,000), acceptUnknownFields — off until roots names a directory (see Skills)
# ~/.ironhive/config.yaml
openai:
  apiKey: sk-...
  model: gpt-4o-mini
gpuStack:
  endpoint: http://localhost:8080
  apiKey: ...
  model: gpt-4o-mini
# .ironhive/config.yaml

# Context compaction is active by default — long sessions compact history
# instead of overflowing. Tune via the compaction section:
compaction:
  useTokenBasedCompaction: true
  protectRecentTokens: 40000     # most-recent tokens always kept
  minimumPruneTokens: 20000      # only compact when at least this much is prunable
  targetRatio: 0.70              # compact down to ~70% of the context window
  maxContextTokens: 65536        # the model's window, when the catalog does not know the model
  compactOnOverflow: true        # window unknown: compact when the server reports an overflow, not against a guess

# A stronger model the working model can consult through the `advisor` tool:
advisor:
  provider: anthropic
  model: claude-opus-5
  maxCalls: 5

# A usage budget per session (tokens and/or USD):
budget:
  maxSessionTokens: 500000
  maxSessionCost: 2.00

Core Library Integration

Use IronHive.Host for direct .NET integration:

// Add to your project
<PackageReference Include="IronHive.Host" />

// Configure with DI
services.AddIronHiveWithOpenAI(apiKey, "gpt-4o-mini");
// Or
services.AddIronHiveWithOllama("llama3.2");

// Use
var agentLoop = serviceProvider.GetRequiredService<IAgentLoop>();
var response = await agentLoop.RunAsync("Hello");

// Streaming
await foreach (var chunk in agentLoop.RunStreamingAsync("Hello"))
{
    Console.Write(chunk.TextDelta);
}

See samples/console-chat for a complete example.

ChatBehaviorConfig

Controls how FunctionInvokingChatClient orchestrates the tool-call iteration loop. Exposed in IronHiveConfig.ChatBehavior so you can tune per-model without forking the source. It applies to the loops the CLI and run --server build; the AddIronHive DI path has no ChatBehavior option — embedders set these caps on their own UseFunctionInvocation client.

Property Default Notes
MaximumIterationsPerRequest 10 Lower (5–7) for small 4K-window models; raise (15–20) for large-context models
MaximumConsecutiveErrorsPerRequest 3 Backstop on back-to-back tool errors; rarely hit in the CLI, where argument errors become recovery hints and the same error three times in a row ends the turn first
ToolCallTimeoutSeconds none Longest one tool call may run. Past it the call is stopped and the model reads which tool ran out of time; the same slow call three times in a row ends the turn. An approval prompt in front of the call is not timed. run --tool-timeout <SECONDS> overrides it for one run
# .ironhive/config.yaml
chatBehavior:
  maximumIterationsPerRequest: 7      # tune down for small/quantized models
  maxOutputTokens: 16384              # per model call (default 4096); a reasoning model spends part of it thinking
  reasoningEffort: low                # none | low | medium | high | extra_high; unset = the model's default.
                                      # none turns thinking off; the other levels are requests a server may hold loosely
  maximumConsecutiveErrorsPerRequest: 3
  toolCallTimeoutSeconds: 60          # stop a tool call (a huge folder walk, a hung request) after a minute; unset = no limit

Context Compaction

Long sessions are kept within the model's context window automatically. When the agent loop is built (via the CLI, the server runners, or IronHive.Host DI), a ContextManager is wired from the compaction config so older history is compacted (token-based, protecting the most recent turns and important tool outputs) instead of silently overflowing.

  • Enabled by default; tune via the compaction: config section (see Configuration above)
  • Model-aware — the context window is sized from the active model
  • Embedded consumers can set options.Compaction on AddIronHive(...); manual loop builders can wire it via HostContextManagerFactory.Create(compactionConfig, modelName) and pass the result to AgentLoop/ThinkingAgentLoop
  • Tool results: enableToolResultCompaction (default on, results over maxToolResultChars = 30,000 keep head and tail) and enableObservationMasking (default on, results older than observationMaskingProtectedTurns = 3 user turns become placeholders; observationMaskingProtectedTokens also masks, inside one turn, the results past a token budget measured from the newest back — the newest round always stays whole, and a placeholder names the call so the model can call it again). Only what is sent is reduced; history keeps full results
  • Complements (does not replace) TokenBudgetChatClient, which remains the hard backstop against per-request overflow — installed by the CLI and run --server; AddIronHive embedders add it by wrapping their client (below)

TokenBudgetChatClient

IChatClient decorator that short-circuits streaming calls when the accumulated message-history size would exceed a configurable fraction of the model's context window. Prevents context-overflow silent failures on small quantized models (e.g. 4K-window Gemma E4B).

The CLI and run --server wrap every provider client in it (under a tool invocation pipeline with the loop guards, the permission gate and ResilientArgumentsMiddleware). AddIronHive(...) / AddIronHiveWithOpenAI(...) register the provider client as-is — to get the same backstop in an embedded host, wrap the client yourself (constructor below, then function invocation as shown under ResilientFunctionInvoker) and pass the result to AddIronHive(chatClient) / options.UseChatClient(...).

  • Sits between FunctionInvokingChatClient and the underlying provider
  • Estimates tokens as total-chars ÷ 4 (conservative upper bound)
  • When the estimate exceeds maxContextTokens × threshold, emits a graceful ChatFinishReason.Length response instead of letting the model error silently
  • Context window auto-detected via IContextSizeProvider if the inner client exposes it; otherwise falls back to defaultMaxContextTokens
var client = new TokenBudgetChatClient(
    inner: innerClient,
    defaultMaxContextTokens: 4096,
    threshold: 0.8);   // trigger at 80 % of context window

ResilientFunctionInvoker

Converts the marshaller-level ArgumentException (missing/malformed tool arguments) and JsonException a tool invocation raises into model-actionable procedural error strings, enabling small quantized models to self-correct without aborting the stream. It comes in two forms with one implementation.

With IronHive.Agent's tool invocation pipeline, add ResilientArgumentsMiddleware as the last step, so it sits directly around the tool (this is what the CLI does):

var pipeline = new ToolInvocationPipeline(
[
    new ArgumentParseFailureMiddleware(),          // unparseable arguments: refused before the tool runs
    new RepeatedCallGuardMiddleware(),
    new RepeatedErrorGuardMiddleware(),
    new ApprovalGateMiddleware(modeToolFilter, approvalService),
    new ResilientArgumentsMiddleware(),            // parsed arguments that do not fit the tool: recovery directive
]);
var client = new TokenBudgetChatClient(innerClient)
    .AsBuilder()
    .UseToolInvocationPipeline(pipeline)
    .Build();

With DI, services.AddToolInvocationMiddleware<ResilientArgumentsMiddleware>() registered after the other steps does the same for UseToolInvocationPipeline().

With a plain UseFunctionInvocation client, set it as the FunctionInvoker:

var client = innerClient
    .AsBuilder()
    .UseFunctionInvocation(configure: c => c.FunctionInvoker = ResilientFunctionInvoker.Create())
    .Build();

When the model sends a tool call with a missing required parameter, instead of throwing and aborting, the step returns a numbered recovery directive telling the model exactly what is missing, where to find the value, and explicitly forbidding the empty-args retry pattern. It does not overlap with ArgumentParseFailureMiddleware: that step refuses a call whose arguments text could not be parsed at all, before the tool runs; this one answers arguments that were parsed but do not fit the tool's parameters, which only shows when the tool is invoked. Behind a RepeatedCallGuardMiddleware, a model that keeps sending the same empty arguments is refused after three directives.

AgentServerRunner / AgentHttpRunner

Two runner implementations share the same processor delegate signature, enabling a single agent pipeline to serve either transport:

async IAsyncEnumerable<ServerEvent> ProcessMessage(
    UserMessageRequest msg,
    CancellationToken token)
{
    // msg.Model carries the per-message model override (nullable)
    await foreach (var evt in agentLoop.RunStreamingAsync(msg.Content, token)
        .ToServerEvents(executionLog, token))
    {
        yield return evt;
    }
}

// stdin/stdout JSON Lines (used by `ironhive run --server`)
var runner = new AgentServerRunner(ProcessMessage, logger, hitlBridge: hitlBridge);
await runner.RunAsync(ct);

// HTTP/SSE (host spawns the agent and communicates via REST)
var httpRunner = new AgentHttpRunner("http://localhost:5100", sessionId, ProcessMessage, logger, hitlBridge: hitlBridge);
await httpRunner.RunAsync(ct);

AgentHttpRunner expects these host endpoints:

Endpoint Method Purpose
/api/agent/{id}/ready POST Signal agent is up
/api/agent/{id}/inbox GET (SSE) Receive ServerRequest commands
/api/agent/{id}/events POST Deliver ServerEvent batches

Both runners support typeInfoModifiers to extend polymorphic type registrations without mutating the base JsonSerializerOptions:

var runner = new AgentServerRunner(ProcessMessage, logger,
    typeInfoModifiers: [ApplyCustomPolymorphismOverrides]);

AgentHttpRunner additionally exposes PublishEvent for out-of-band event delivery (e.g. provider fallback notices).

Both runners are meant to stay up unattended. A request line (stdio) or inbox message (HTTP) that is not a request — malformed JSON, an unknown type — is answered with an error event and skipped; blank lines are ignored. The stdio runner ends only on a shutdown request or the end of its input. When the HTTP inbox stream ends without a shutdown or the host is briefly unreachable (5xx, 408, 429, connection errors), AgentHttpRunner reconnects with exponential backoff (InitialReconnectDelay 1 s doubling up to MaxReconnectDelay 30 s), sends Last-Event-ID when the host numbers its events with id: lines, and keeps a turn in progress running. After MaxReconnectAttempts consecutive failures (default 10, about three minutes; null = never give up) RunAsync throws HttpRequestException, so an agent whose host is gone exits; a host that refuses the session (404, 401, ...) fails at once. A host that wants the agent to stop sends a shutdown request rather than closing the stream. Pass httpHandler to route the connection through your own HttpMessageHandler.

Human approval over the wire

HitlBridge is an IHumanApprovalService (the approver IronHive.Agent's approval gate asks on an Ask verdict) whose human is the client. Give the same instance to the gate and to the runner:

var hitlBridge = new HitlBridge();                    // timeout: 5 minutes by default
var pipeline = new ToolInvocationPipeline([new ApprovalGateMiddleware(policy, hitlBridge)]);
// ... build the loop's chat client with UseToolInvocationPipeline(pipeline) ...
var runner = new AgentServerRunner(ProcessMessage, logger, hitlBridge: hitlBridge);

While the runner runs, each asked call is sent as a hitl_request (between the call's tool_start and tool_end; its call_id matches the tool_start) and the call waits. The client answers with a hitl_response carrying the request's id: approved: true runs the call (with modified_arguments when the person edited them), approved: false returns reason to the model as the tool's result. Several requests can wait at once; an answer without an id is accepted only while exactly one waits. No answer within the timeout, a cancelled turn, a runner that stops, or no runner at all — each is a rejection, never a pass. Session rules of your own (trust lists, "already denied this session") fit as an IHumanApprovalService that decorates the bridge. ironhive run --server does this for you; in an interactive CLI session the terminal answers.

A wait outlives the process. A human may answer hours later, after the server restarted. With a recorded session (run --server --session-id <ID> records every turn), each asked call is written to the session before its request is sent — the call and the request (approval_wait in the transcript) — and settled when answered (approval_closed). A process that stops while a call waits leaves the turn suspended, not interrupted. The next run --server with the same --session-id sends the same hitl_request again, with the same id, before it reads any message (messages that arrive meanwhile queue behind it); the answer runs the call through the session's tool pipeline — the same gate, guards and tool as in a live turn — and the model finishes the turn. The timeout counts from when the request was first sent: a wait older than approval.timeoutSeconds resumes as a denial without asking. A host of your own gets the same with hitlBridge.WaitLog = recorder (a SessionTurnRecorder), ISessionManager.GetPendingApprovalsAsync at start, and runner.ResumeTurn = token => SuspendedTurnResumer.ResumeAsync(loop, pending, hitlBridge, recorder, tools, pipeline, token).ToServerEvents(ct: token) — on AgentServerRunner or AgentHttpRunner; it runs once.

Skills. skills: in config.yaml (roots: [~/.ironhive/skills, .ironhive/skills], optional enabled, exclude, maxMetadataCharacters, acceptUnknownFields) loads Agent Skills (SKILL.md bundles per the specification): every skill's name and description is in the system instructions, and the model calls load_skill for a body — only from inside the skill's directory. Skills the specification's validator rejects are not loaded (acceptUnknownFields: true admits bundles carrying another client's frontmatter keys). The load_skill tool is added by the CLI / run --server loop factory, and by the library AddIronHive(...) path when skills are registered: services.AddAgentSkills(new SkillsConfig { Roots = [...] }) registers the SkillsLoader, puts the skills' metadata in the instructions, and — when at least one skill was found — AddIronHive adds load_skill to options.Tools (not twice, if you already put SkillsLoader.LoadTool there).

Permissions. Every tool call runs through the permission rules (IronHive.Agent's ApprovalGateMiddleware, a step of the tool invocation pipeline installed on the chat client): Allow runs the tool, Deny returns the reason to the model, Ask prompts on the console — or, in run --server, is sent to the client as a hitl_request (see Human approval over the wire). Planning mode is enforced at the same gate: while the session is planning, only read-only tools run. A console prompt needs a terminal — a piped one-shot (stdin or stdout redirected, no server client) rejects an Ask verdict with a reason instead, so a prompt never lands in an output stream. Rules come from the project's .ironhive/permissions.yaml (or .yml / .json; keys read, edit, bash, external_directory, mcp_tools, tools, read_only_tools, ask_before_delete, default_action) when it exists; otherwise from the permissions section of config.yaml — global ~/.ironhive/config.yaml, then the project's, camelCase keys like the rest of the file (externalDirectory, mcpTools, readOnlyTools, askBeforeDelete, defaultAction); otherwise the built-in defaults (before 0.29.4 the config.yaml section was read and then discarded). tools matches by tool name any tool with no dedicated category, and an unmatched tool falls to the default action (ask by default — so an unknown tool is asked about, not run). ask_before_delete (true by default) asks before deleting a file even where the edit rules allow it; false lets the edit rules decide deletes.

Protocol types (ServerRequest → agent, ServerEvent → host):

Type Discriminator Key fields
UserMessageRequest user_message Content, Model?, Options? (TurnOptions: tool_names, tool_mode, reasoning_effort, temperature, max_output_tokens, instructions — see below)
ContextUpdateRequest context_update WorkingPath?, SelectedItems?
HitlResponseRequest hitl_response Approved, Reason?, Id? (the request being answered), ModifiedArguments?, AlwaysApprove?
CancelRequest cancel —
ShutdownRequest shutdown —
SessionStartedEvent session_started SessionId — first line run --server writes, before any request is read (--session-id value, or a generated id)
ToolStartEvent tool_start Tool, Input?, CallId?
HitlRequestEvent hitl_request Id, Action, Target, Description, ToolName?, Arguments?, CallId? (matches the tool_start), Level? — a call waiting for approval; answer with hitl_response
ToolEndEvent tool_end Tool, Success, Output? (≤ 8 KB), CallId? — one per tool call once its outcome is known, before turn_end; CallId matches the tool_start. A call the permission gate refused arrives with Success: false and the refusal as Output (Permission denied: … / Approval rejected: …). Refusal? says why without the text (ToolRefusalCodes: denied · approval_unavailable · rejected · result_withheld · invalid_arguments · repeated_call · repeated_error · repeated_result · timed_out); null when the tool ran
ThinkingDeltaEvent thinking_delta Content — extended-thinking text, its own stream, never folded into text_delta
FallbackServerEvent fallback Kind (retry|fallback|exhausted), Category, Message, ProviderIndex, TotalProviders, Attempt, MaxAttempts
TextDeltaEvent text_delta Content
AddendumEvent addendum Content — a turn observer's note, at most once per turn, after the last text_delta and before turn_end; not the model's words, never in history
TurnEndEvent turn_end InputTokens?, OutputTokens?, TotalTokens? (summed across every model round-trip in the turn; null when the provider reported no usage), CachedInputTokens?, StopReason? (StopReasons: completed · output_limit · content_filter · tool_terminated · awaiting_host_tools · step_limit · cancelled when the client cancelled it; null when the turn failed — a timeout is a failure, with an error before it), DurationMs?
ErrorEvent error Message (for people; may be reworded), Code? (for code to branch on — ErrorCodes: context_limit · rate_limit · billing · auth · network · timeout · request_rejected · tool_execution · file_system · internal · unknown, and unreadable_request for a request line that was skipped). The runners classify a failed turn with the agent's IErrorRecoveryService (ErrorClassifier on AgentServerRunner/AgentHttpRunner)

IronHive.Host.Protocol also declares agent_selected (AgentSelectedEvent), plan_created, plan_step_started, plan_step_completed and plan_completed (Plan*ServerEvent). No runner emits them today — they are reserved; a client may accept them but should not wait for them.

Per-turn options. UserMessageRequest.Options narrows or tunes one turn: tool_names (a subset of the agent's registered tools; [] = no tools this turn), tool_mode (auto | none | require_any | require:<tool>), reasoning_effort (none | low | medium | high | extra_high), temperature, max_output_tokens. The merge is field by field: a set field replaces the agent's configured value for this turn only, an unset field keeps it, and the next request without options is back on the agent's configuration — nothing is remembered across turns. A tool name that is not registered, or an unknown mode/effort, is answered with an error event, never silently dropped. A request without options is byte-identical to the pre-0.21.0 wire form.

instructions is a second text channel for the turn, apart from content: the client's own directives (an operator's standing instructions, rules it compiled) beside user text that may come from someone untrusted. They reach the model as system text alongside the agent's system prompt (Microsoft.Extensions.AI ChatOptions.Instructions) and are never added to the conversation history — a guard that reads the user's text does not read them, and the next turn does not carry them. Send them with every turn that should have them; keeping them identical from turn to turn keeps the prompt prefix stable for providers that cache it.

Samples

Sample Path Description
Console Chat samples/console-chat/ Core library direct integration
Web Chat samples/web-ai-chat/ Next.js + CLI subprocess
# Console Chat
cd samples/console-chat
dotnet run

# Web Chat
cd samples/web-ai-chat
npm install && npm run dev

Development

Requirements

  • .NET 10 SDK
  • Git

Build & Test

dotnet build
dotnet test

Project Structure

ironhive-host/
├── src/
│   ├── IronHive.Host.Protocol/  # Thin turn-stream contracts (zero-dep NuGet)
│   ├── IronHive.Host/           # SDK / core library (NuGet)
│   │   ├── Config/              # Configuration classes
│   │   ├── Extensions/          # DI helpers
│   │   ├── Providers/           # LMSupply, IronHive chat client providers
│   │   ├── Server/              # AgentServerRunner, AgentHttpRunner (references Host.Protocol)
│   │   ├── Session/             # Session management
│   │   └── Tools/               # Built-in tools, ResilientArgumentsMiddleware/ResilientFunctionInvoker, TokenBudgetChatClient
│   └── IronHive.Cli/            # CLI application (tool command: ironhive)
├── samples/
│   ├── console-chat/            # .NET Core integration
│   └── web-ai-chat/             # Next.js + subprocess
└── tests/

Contributing

Issues and pull requests are welcome. This project is pre-1.0 (0.x) — breaking changes land freely for structural correctness. The scope is the hosting surface described in the introduction above; please open an issue to discuss a feature before sending a PR. dotnet build && dotnet test (see Development) should pass before opening a PR.

License

MIT

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.
  • net10.0

    • No dependencies.

NuGet packages (2)

Showing the top 2 NuGet packages that depend on IronHive.Host.Protocol:

Package Downloads
IronHive.Host

IronHive Host - reusable AI agent host SDK (agent loop, tools, session, provider integrations) for CLI, server, and embedded surfaces

IronHive.Host.Core

IronHive Host Core - Agent loop, tools, session management, and provider integrations for building reusable AI agent hosts (CLI, server, embedded)

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.60.1 56 10/13/2026
0.60.0 71 10/13/2026
0.59.0 95 10/12/2026
0.58.0 98 10/11/2026
0.57.0 108 10/10/2026
0.56.0 96 10/10/2026
0.55.0 94 10/9/2026
0.54.0 89 10/9/2026
0.53.0 106 10/9/2026
0.52.0 97 10/8/2026
0.51.2 101 10/7/2026
0.51.1 99 10/7/2026
0.51.0 99 10/7/2026
0.50.0 99 10/7/2026
0.49.0 93 10/7/2026
0.48.11 105 10/7/2026
0.48.10 97 10/7/2026
0.48.9 97 10/7/2026
0.48.8 94 10/7/2026
0.48.7 101 10/6/2026
Loading failed