Lyntai.Providers.ClaudeCli
2.0.0
dotnet add package Lyntai.Providers.ClaudeCli --version 2.0.0
NuGet\Install-Package Lyntai.Providers.ClaudeCli -Version 2.0.0
<PackageReference Include="Lyntai.Providers.ClaudeCli" Version="2.0.0" />
<PackageVersion Include="Lyntai.Providers.ClaudeCli" Version="2.0.0" />
<PackageReference Include="Lyntai.Providers.ClaudeCli" />
paket add Lyntai.Providers.ClaudeCli --version 2.0.0
#r "nuget: Lyntai.Providers.ClaudeCli, 2.0.0"
#:package Lyntai.Providers.ClaudeCli@2.0.0
#addin nuget:?package=Lyntai.Providers.ClaudeCli&version=2.0.0
#tool nuget:?package=Lyntai.Providers.ClaudeCli&version=2.0.0
Lyntai (灵台)
灵台 (língtái) — "the numinous platform," a classical Chinese name for the seat of the mind.
A reusable .NET 10 library: the shared cortex + persistence substrate for AI apps. Give a new
project an LLM provider abstraction with routing + fallback, pluggable storage, and an LLM-ops layer
(prompt registry, scoring, traces, task-scoped memory) — AddLyntai(...) and go, no rebuilding it per app.
Extracted from the good parts of four sibling projects: the storage/scoring/trace patterns of Gatherlight, the provider abstraction of Vidora, the verdict-classification + memory of Sonora, mastra's composable domain storage, and odysseus's streaming-aware fallback.
Status
v2.0.0 — a hardened, batteries-included cortex substrate. On the v0.1.0 base (all of TASKS.md) plus
a multi-agent review/research pass (v0.2): configurable routing (v0.3), LLM-ops depth (v0.4), public-API
baseline + a second storage backend (v0.5), a PostgreSQL backend + live-Ollama validation (v0.6), IoC
seams so the app owns its resource lifecycle — process execution, HttpClient, DB connection/schema,
provider presets (v0.7), a local GGUF provider via LLamaSharp (v0.8), an agentic tool-calling loop (v0.9),
native (structured) function-calling with a prompt fallback + MEAI-bridged tool-calls (v0.10–v0.11), MCP
tools (v0.12) and CLI tool-calling via an ephemeral MCP server (v0.13), durable multi-agent jobs (v0.14),
the §9 platform kit — guards + two-gate orchestration, secret vault, vision/multimodal (v0.15) — then the
post-kit expansion (v0.16–v0.28): OTel telemetry; cache/budget/rate-limit front-door governance; semantic
memory over a BYO embedder + vector store (incl. pgvector); durable-job priorities/DLQ/cron/cancellation +
admission control + live progress; a DPAPI + recovery-key envelope vault; per-request refusal screening;
curated memory; and the agentic self-driving-agent session primitive (v0.28.5). v0.29 adds a typed
multi-kind conversation event store (GUID ids + per-thread seq + metadata) with an IConversationEnricher
seam for app-owned extension, storage feature toggles (enable only the domains you use — a disabled
feature lands no table), actor/mailbox durable jobs (jobs sharing a partition key run one-at-a-time in
FIFO, parallel across keys), and a generic sustainability review pass across the surface — a typed
IRefusalMatcher seam, full reverse-MEAI-bridge parity, explicit trace-step timelines, and more (v0.29.0).
v0.30 delivers agent-adoption ergonomics — a headless SkipAllPermissions posture for the CLI agent
session, per-run ToolLoopResult.Usage, a live IToolLoop.StreamAsync event door, .ps1 launcher-shim
hosting, curated-memory dedup-on-add + scope filtering — and a whole-library foundation-hardening
pass: two adversarial review rounds (~95 findings triaged), correctness fixes across the router, guards,
orchestrator, prompts, storage, and DI wiring, structural dedup (a shared relational job-store state
machine, a DelegatingLlmClient decorator base, async connection opens throughout), and case-insensitive
consumer identity end-to-end (v0.30.0). v1.0.0 freezes the public API under SemVer 2.0 — following a
pre-freeze adversarial API + consumer-usage review — and collapses the 0.x migrations into clean
per-StorageFeature baselines.
Versioning. From 1.0, Lyntai follows SemVer 2.0: no breaking public-API change without a major bump (the
ApiSurfaceTestsbaseline gates it). Upgrading 0.31 → 1.0: the 0.x migrations were collapsed into per-domain 1.0 baselines — the net schema is identical but the migration ledger is renumbered, so drop yourlyntai_*tables (includinglyntai_version_info) or delete the dev database before the first 1.0 run; Lyntai recreates them. One-time; the ledger is append-only thereafter.
docs/2026-07-17-lyntai-design.md— the design contract (interfaces, fork decisions, semantics, scope).docs/ROADMAP.md— what's shipped, what's next, and the remaining path to 1.0.docs/AOT.md— per-package trimming/Native-AOT status.CHANGELOG.md— per-release detail, breaking changes called out.
Packages
| Package | What it gives you |
|---|---|
Lyntai.Core |
Interfaces + the fallback router + cortex (prompt/scoring/trace) + DI. No heavy deps. |
Lyntai.Storage.Sqlite |
SQLite implementation of every storage domain (Dapper + FluentMigrator + FTS5). |
Lyntai.Storage.InMemory |
Zero-dependency in-memory storage — tests, ephemeral use, or mixed per-domain with SQLite. |
Lyntai.Storage.Postgres |
PostgreSQL storage (Npgsql + pg_trgm memory recall) for a server-backed deployment. |
Lyntai.Providers.ClaudeCli |
The authenticated claude CLI as a provider (no API key). |
Lyntai.Providers.OpenAiCompatible |
OpenAI / Ollama / OpenRouter-style endpoints over HttpClient. |
Lyntai.Providers.ExtensionsAi |
Bridge: any Microsoft.Extensions.AI IChatClient → a Lyntai provider. |
Lyntai.Providers.Local |
In-process local GGUF inference via LLamaSharp (llama.cpp) — add an LLamaSharp.Backend.*. |
Lyntai.Tools.Mcp |
Expose a Model Context Protocol (MCP) server's tools as Lyntai ITools for the tool loop. |
Lyntai.Providers.ClaudeCli.Mcp |
Give the claude CLI real tool-calling — hosts your ITools over MCP so the CLI's agent calls them. |
Each src/* is an independent NuGet package depending only on Lyntai.Core — add just what you need.
Consuming Lyntai
Install Lyntai.Core plus the provider/storage packages you want, then compose in DI:
using Lyntai; // the builder + Add*/Use* extensions
using Lyntai.Cortex.Scorers;
using Microsoft.Extensions.DependencyInjection;
services.AddLyntai(cfg =>
{
cfg.AddClaudeCliProvider(); // spawns the authenticated `claude` CLI, no API key
cfg.AddOpenAiCompatibleProvider("ollama", o => o.BaseUrl = "http://localhost:11434");
cfg.AddExtensionsAiProvider("openai", myChatClient); // bridge any Microsoft.Extensions.AI IChatClient
cfg.UseSqliteStorage("app.db"); // all five storage domains, migrated on startup
cfg.AddScorer<OutcomeScorer>(); // eval dimensions are DI registrations
cfg.AddScorer<RelevancyScorer>(); // (this one is an LLM judge through the router)
cfg.UseDefaultCandidates("claude-cli", "ollama"); // router fallback order
});
Then inject the front door. To your app, Lyntai behaves like one LLM provider — ILlmClient has
ILlmProvider's shape, and candidate order, fallback, and dead-host handling happen invisibly behind it:
public sealed class MyFeature(
ILlmClient llm,
IPromptRegistry prompts, IPromptComposer composer,
IScoringService scoring, ITraceService traces, IMemoryStore memory)
{
public async Task<string> AskAsync(string question, CancellationToken ct)
{
var prompt = await prompts.RenderAsync("myfeature.ask",
"Answer briefly: {question}", new Dictionary<string, string> { ["question"] = question }, ct);
prompt = await composer.ComposeAsync(prompt, taskKey: "myfeature", ct: ct); // + learned facts
var reply = await llm.CompleteAsync(
new LlmRequest { Messages = [LlmMessage.User(prompt)], Consumer = "myfeature" }, ct);
return reply.Verdict == LlmVerdict.Ok ? reply.Text : throw new InvalidOperationException(reply.Detail);
}
}
(ILlmRouter stays available for call sites that genuinely need their own candidate list.)
And if your app already speaks Microsoft.Extensions.AI, consume Lyntai as an IChatClient —
routing, fallback, and the ops layer come along silently:
IChatClient chat = serviceProvider.GetRequiredService<ILlmClient>().AsChatClient();
The semantics you're getting (design §6)
- Fallback router: candidates are deduped and tried in order;
Failed/Timeoutadvances,RateLimitedputs that host on immediate cooldown and advances to the next candidate (a 429 is terminal for the host's window, not for the fleet),Refusedsurfaces with no fallback (content policy follows the prompt, not the host). - Streaming never falls back after the first token — pre-content failures move to the next candidate, mid-stream errors pass through unchanged (your consumer never sees duplicated output).
- Per-request refusal check — set
LlmRequest.RefusalPattern(a regex) and an otherwise-Okreply whose text matches surfaces asRefused(e.g. a per-language "I can't help with that"). Screened at the outermost front-door layer, so even a cached hit is re-checked. - Dead-host cooldown instead of exponential backoff; any success resets.
- Per-request timeout — set
LlmRequest.TimeoutSeconds(or a per-consumerTimeoutByConsumerdefault) when one call legitimately runs far longer than the globalProviderTimeout(e.g. a CLI-agent run), without inflating every short call. Precedence: request → consumer → global; clamped toMaxProviderTimeout. - All of the above is the default
RoutingPolicy— tune it without a fork. Retry a transient fault on the same candidate before failing over, override what each verdict does, cool by(provider, model)instead of whole-host, or keep the sole candidate always live:cfg.ConfigureRouting(r => { r.Retry(LlmVerdict.Failed, 1); // one retry before advancing r.CooldownScope = CooldownScope.ProviderAndModel; // per-model rate-limit cooldown r.On(LlmVerdict.RateLimited, FallbackAction.Surface); // e.g. don't fall back on 429 }); - Prompt overrides live in the key-value store under
lyntai.prompt.<name>; an override that drops a{placeholder}present in the default is rejected (falls back to the default, with a warning). - Memory recall is bounded and fail-open: FTS5 trigram match (works for CJK substrings), LIKE fallback, capped per (task, scope) — and it never throws into your prompt path.
- Curated memory catalog (
ICuratedMemoryStore) sits beside the recall log for hand-managed context: entries grouped byKind, each individually enable/disable-able and editable (UpdateAsync, incl. re-categorisingkindin place), with an arbitrary app-ownedstring→stringMetadatamap (title, source, author, …) that is both stored (as one opaque JSON field per backend) and queryable by exact key/value (metadataMatchonListAsync/SearchAsync, backed by a plain relational index — identical across backends), plus keywordSearchAsyncover content (same index machinery and fail-open semantics as memory recall), rendered into per-kind prompt sections byCuratedMemorySections.Compose— across all three backends. - Env overrides beat code config:
LYNTAI_TIMEOUT_SECONDS,LYNTAI_MAX_TIMEOUT_SECONDS,LYNTAI_DEADHOST_THRESHOLD,LYNTAI_DEADHOST_COOLDOWN_SECONDS,LYNTAI_DEFAULT_CANDIDATES(providerId[:model],…),LYNTAI_MODEL_<CONSUMER>(+LYNTAI_DEFAULT_MODELalias),LYNTAI_RETRY_FAILED/_TIMEOUT/_BACKOFF_SECONDS,LYNTAI_COOLDOWN_SCOPE,LYNTAI_TOOL_LOOP_MAX_ITERATIONS,LYNTAI_CACHE_TTL_SECONDS/_MAX_ENTRIES,LYNTAI_BUDGET_MAX_COST_USD/_MAX_TOKENS,LYNTAI_RATELIMIT_PERMITS_PER_SECOND/_BURST/_MAX_WAIT_SECONDS, the durable-jobs familyLYNTAI_JOBS_LEASE_SECONDS/_POLL_SECONDS/_MAX_ATTEMPTS/_BACKOFF_SECONDS/_DEFAULT_CONCURRENCY/_MAX_STEP_LOG, andLYNTAI_PROVIDER_CMD(point the CLI provider at a stub — how the tests/e2e spend zero tokens). - Shared-database safe: every SQLite object Lyntai creates is prefixed
lyntai_(including the migration version table), soUseSqliteStoragecan point at an existing app database. - Mix storage backends per domain: the domain interfaces are independent, so the DI container is
the registry —
UseSqliteStorage(path)for most domains, then override one (services.AddSingleton<IMemoryStore>(...), last registration wins).UseInMemoryStorage()stands alone or backfills gaps.UseSqliteStorage(path, SchemaMigration.OnFirstUse)defers migration I/O off DI composition.
Structured output
var reply = await llm.CompleteJsonAsync(new LlmRequest
{
Messages = [LlmMessage.User("Summarize as JSON.")],
JsonSchema = """{"type":"object","properties":{"summary":{"type":"string"}}}""",
});
// reply.Verdict == Ok guarantees reply.Text parses as a single JSON object
// (tolerant extraction from prose/fences, one retry, else Failed — design §6)
Response caching
Opt in and identical repeated completions come back from a cache instead of a provider — cutting cost and latency, and making repeated runs deterministic. It wraps the single front door, so the tool loop, orchestrator, and scorers all read through it once enabled.
services.AddLyntai(cfg => cfg
.AddOpenAiProvider(/* … */)
.AddResponseCache(c => c.Ttl = TimeSpan.FromHours(6))); // defaults: 1h TTL, 1000 entries
Keyed by a stable hash of the output-determining request fields (messages, model, max tokens, temperature,
JSON schema) — Consumer is excluded, so two consumers issuing the same request share a hit. Only clean
Ok, non-streaming completions are cached; streaming, requests carrying native tools (the tool
loop is stateful), and non-Ok replies never are. The in-memory cache is the default; call UseSqliteResponseCache() (or UsePostgresResponseCache()) to
persist it so it survives restarts, or register your own IResponseCache before AddResponseCache to back
it with Redis or another shared store.
Semantic memory
The lexical memory store (IMemoryStore) recalls by keyword (FTS-trigram). For meaning-based recall, bring
an embedding model and use ISemanticMemory — facts are remembered by their embedding and recalled by
cosine similarity, so a query finds relevant memories without sharing keywords.
services.AddLyntai(cfg => cfg
.AddOpenAiProvider(/* … */)
// built-in embedder over any OpenAI-compatible /v1/embeddings (OpenAI, LM Studio, Ollama, Azure)
.AddOpenAiCompatibleEmbedder("embeddings", o =>
{
o.BaseUrl = "http://localhost:11434"; // e.g. local Ollama
o.Model = "nomic-embed-text";
}));
// …or bring your own: .AddEmbeddings(myEmbedder) // any IEmbedder — a hosted endpoint or local model
var memory = sp.GetRequiredService<ISemanticMemory>();
await memory.RememberAsync(task: "support", scope: "faq", "You can cancel your subscription anytime.");
var hits = await memory.RecallAsync("support", "faq", query: "how do I stop paying?", k: 5);
// hits ranked by similarity, each with a Content + cosine Score
Vectors live in a swappable IVectorStore — the built-in InMemoryVectorStore (exact brute-force cosine)
is the default; call UseSqliteVectorStore() to persist them in SQLite, or UsePostgresVectorStore() for
pgvector (the cosine search runs in the database — SQL-side top-k, not brute-force in the app). Or
register your own before AddLyntai for another vector DB — the recall code is unchanged. Scoped by (task,
scope) like the lexical store; re-remembering identical content dedups.
Registering an embedder also upgrades the chat orchestration automatically: IChatOrchestrator's
memory injection becomes hybrid (semantic hits lead, then lexical entries fill in, deduped) and each
remembered exchange is written to both stores — so a later turn recalls earlier ones by meaning, not just
keywords. With no embedder, the chat path stays purely lexical.
Usage budgeting
Cap spend. The budget meters token/cost usage across the front door and refuses further calls once a cap is reached — without hitting a provider.
services.AddLyntai(cfg => cfg
.AddOpenAiProvider(/* … */)
.AddUsageBudget(b =>
{
b.MaxCostUsd = 20.00; // global ceiling
b.PerConsumer["scoring"] = new(MaxCostUsd: 2.00); // a tighter cap for one consumer
}));
// query or reset spend at runtime
var spent = sp.GetRequiredService<IUsageTracker>().Total().CostUsd;
Over a cap, a completion returns Verdict == Refused (a stream yields one Error chunk) and no provider is
called. The ceiling is soft: the call that crosses a cap still runs (its cost isn't known until it
returns), the next is refused. Compose with the cache and a cached hit is free — it never counts toward
the budget (the cache is the outermost decorator). Call UseSqliteUsageTracking() (or
UsePostgresUsageTracking()) to persist spend across restarts, or register your own IUsageTracker for
shared accounting.
Rate limiting
Throttle throughput with a token bucket. Over the configured rate a call waits briefly for a permit, then
is refused (Verdict == RateLimited) rather than hammering the provider.
services.AddLyntai(cfg => cfg
.AddOpenAiProvider(/* … */)
.AddRateLimit(r =>
{
r.PermitsPerSecond = 10;
r.Burst = 20; // allow a burst after idle
r.PerConsumer["scoring"] = new(PermitsPerSecond: 2);
}));
Together, caching, budgeting, and rate limiting are the front-door governance trio (cost/latency,
spend, throughput) and compose on one chain — cache outermost, rate-limit innermost — so a cached hit
spends nothing: no budget accounting and no rate-limit permit. Register your own IRateLimiter for a
limiter shared across processes.
Observability
Lyntai emits OpenTelemetry GenAI-convention telemetry from the router — the same schema
Microsoft.Extensions.AI's OpenTelemetryChatClient uses, so own-seam and bridged providers land
in one backend. Nothing is emitted unless you subscribe:
tracerProviderBuilder.AddSource(LyntaiDiagnostics.ActivitySourceName); // "Lyntai.Llm" spans
meterProviderBuilder.AddMeter(LyntaiDiagnostics.MeterName); // duration, token usage,
// time_to_first_chunk
// the agentic subsystems (tool loop, durable jobs, guards) emit on a second source/meter:
tracerProviderBuilder.AddSource(LyntaiDiagnostics.AgentActivitySourceName); // "Lyntai.Agents" spans
meterProviderBuilder.AddMeter(LyntaiDiagnostics.AgentMeterName); // tool/job/guard metrics
chat {model} client spans carry gen_ai.system (provider id), gen_ai.request.model, token
usage, and error.type (the verdict) on failure. time_to_first_chunk marks the streaming
fallback point of no return. On the Lyntai.Agents side, a tool_loop span nests one
execute_tool {name} span per call, run_job {type} spans carry the lane/outcome (with
processed/duration metrics), and a guard-decisions counter tags each block/replace by gate — so an
agent run traces end-to-end next to its LLM calls.
OpenTelemetry is the automatic observability path. ITraceService is a separate, app-driven
API for a durable, step-shaped run history you query later: call Begin(sessionId, mode) and
recorder.Record(step) yourself, and it persists a RunTrace to the wired ITraceStore
(SQLite/Postgres/InMemory). The batteries-included flows don't auto-populate it — reach for it when you
want your own queryable trace timeline; reach for OTel for live tracing/metrics.
Bring your own resources
Lyntai defines the interfaces; your app owns the resource lifecycle wherever that matters.
services.AddLyntai(cfg =>
{
// Provider presets (or the generic AddOpenAiCompatibleProvider, or your own ILlmProvider):
cfg.AddOpenAiProvider(apiKey, defaultModel: "gpt-4o-mini");
cfg.AddOllamaProvider(defaultModel: "llama3.2:3b");
cfg.AddProvider(_ => new MyCustomProvider()); // BYO ILlmProvider
// BYO HttpClient — your configured client (Polly, auth handlers, proxy, a named client):
cfg.AddOpenRouterProvider(apiKey,
httpClient: sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("resilient"));
// BYO DB connection + schema ownership:
cfg.UseSqliteStorage(myConnectionFactory); // you own connection lifecycle
cfg.UsePostgresStorage(connString, SchemaMigration.None); // you own the schema (no Lyntai migrations)
});
// BYO process execution — control how the claude CLI is spawned (sandbox, custom shell, remote):
services.AddSingleton<IProcessRunner>(new MySandboxedProcessRunner());
Anything you register wins over Lyntai's default (the defaults use TryAdd), and every storage domain
is itself an interface (IKeyValueStore, IMemoryStore, …) you can implement wholesale.
Local in-process inference (Lyntai.Providers.Local)
Run a GGUF model in-process via LLamaSharp — no network, no key, no subprocess. Reference the
LLamaSharp.Backend.* that matches your hardware alongside Lyntai.Providers.Local:
<PackageReference Include="Lyntai.Providers.Local" Version="0.30.0" />
<PackageReference Include="LLamaSharp.Backend.Cpu" Version="0.27.0" />
services.AddLyntai(cfg =>
{
cfg.AddLocalProvider("models/Phi-3-mini-4k-instruct-q4.gguf", o =>
{
o.GpuLayerCount = 0; // 0 = CPU; raise to offload layers to the GPU
o.ContextSize = 4096; // null = the model's own trained maximum
});
cfg.UseDefaultCandidates("local");
});
The model loads lazily on first use and generations are serialized (one local model, one at a time).
It's just another ILlmProvider, so it fits anywhere in a fallback candidate list — e.g. a hosted
model first, "local" as an offline backstop.
Tool-calling (Lyntai.Agents)
Give the model tools and let it work in a loop. IToolLoop runs over the ILlmClient front door, so
it works with any provider (CLI, HTTP, MEAI bridge, local) — no native tool-calling required.
services.AddLyntai(cfg =>
{
cfg.AddClaudeCliProvider().UseDefaultCandidates("claude-cli");
// a tool from a class (DI-injectable) or inline from a delegate:
cfg.AddTool(_ => new FunctionTool(
name: "get_weather",
invoke: (argsJson, ct) => Task.FromResult("""{"tempC":21,"sky":"clear"}"""),
description: "Current weather for a city",
parametersJsonSchema: """{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}"""));
});
// inject IToolLoop:
var result = await toolLoop.RunAsync(new LlmRequest
{
Messages = [LlmMessage.User("What should I wear in Paris today?")],
});
Console.WriteLine(result.Answer); // the model's final answer after any tool round-trips
foreach (var step in result.Steps) // every tool call it made, for tracing
Console.WriteLine($"{step.Tool}({step.ArgumentsJson}) -> {step.Result}");
The loop executes the tool the model chooses, feeds the result back, and repeats up to
ToolLoopMaxIterations (default 8). It uses native provider function-calling when available
(OpenAI-compatible / Ollama and any Microsoft.Extensions.AI IChatClient via the bridge — structured
tool_calls, parallel calls supported) and falls back to a prompt protocol over the text contract
for providers without it (CLI, basic local models) — same ITools either way, chosen transparently
behind the front door (ILlmClient.SupportsToolCalls). An
unknown or throwing tool becomes a recoverable error: … observation rather than a crash; a refusal or
all-providers-down verdict surfaces on result.Verdict.
MCP tools (Lyntai.Tools.Mcp) — point the loop at a Model Context Protocol server and its tools
become ITools. Your app owns the MCP connection; Lyntai adapts:
await using var mcp = await McpClient.CreateAsync(new StdioClientTransport(new()
{
Command = "npx", Arguments = ["-y", "@modelcontextprotocol/server-everything"], Name = "everything",
}));
var mcpTools = await McpToolset.FromClientAsync(mcp); // list + adapt the server's tools
services.AddLyntai(b => b.AddClaudeCliProvider().AddMcpTools(mcpTools).UseDefaultCandidates("claude-cli"));
Tools for the claude CLI (Lyntai.Providers.ClaudeCli.Mcp) — the CLI runs its own agent loop and
reaches custom tools only over MCP, so this add-on hosts your registered ITools as an ephemeral,
localhost-only HTTP MCP server (started/stopped per CLI call) and wires claude -p to it. Opt in and a
completion routed to the CLI lets its agent call your tools:
services.AddLyntai(b => b
.AddClaudeCliProvider()
.AddTool(_ => new FunctionTool("get_weather", (a, ct) => Task.FromResult("""{"tempC":21}"""), "Current weather"))
.AddClaudeCliMcpTools() // hosts the tools over MCP for the CLI
.UseDefaultCandidates("claude-cli"));
// var reply = await llm.CompleteAsync(...); → the CLI calls get_weather and answers
(This runs an ephemeral Kestrel listener on 127.0.0.1 only during each CLI call — a deliberate, scoped
exception to Lyntai's otherwise host-free design, isolated in this opt-in package.)
CLI-agent session vs IToolLoop (IAgentSession)
When the external agent drives its OWN tool loop out-of-process (e.g. the claude CLI running
autonomously), IAgentSession is the right primitive — not IToolLoop. You observe a streamed
transcript of what the agent did (AgentStreamEvent), gate it read-only (plan) vs write (execute) via
AgentToolPolicy, and resume it across a human confirmation gate using the session's ResumeToken.
Two consumption doors: StreamAsync (live event-by-event, for progress UI or structured logging) and
RunAsync(onEvent) (fold to a result for callers that only need the outcome).
The IAgentSession interface is neutral Core (Lyntai.Agents); all claude-specific flags
(--settings, --mcp-config, AllowedTools) live in the Lyntai.Providers.ClaudeCli adapter
(ClaudeAgentSession / ClaudeAgentOptions, registered via AddClaudeCliAgentSession()).
services.AddLyntai(b => b
.AddClaudeCliProvider()
.AddClaudeCliAgentSession() // registers IAgentSession → ClaudeAgentSession
.UseDefaultCandidates("claude-cli"));
var session = sp.GetRequiredService<IAgentSession>();
// Session 1 — read-only PLAN gate, streaming door (observe live tool calls):
string? resumeToken = null;
await foreach (var e in session.StreamAsync(new ClaudeAgentOptions
{ Prompt = "Plan the refactor.", ToolPolicy = AgentToolPolicy.ReadOnly, WorkingDirectory = cwd }))
{
if (e is SessionStarted s) resumeToken = s.SessionId;
else if (e is ToolCall tc) Console.WriteLine($"tool: {tc.Name} → {ClaudeToolCalls.FilePathOf(tc)}");
else if (e is SessionEnded se) Console.WriteLine($"plan verdict: {se.Verdict}");
}
// Human review / approval gate here …
// Session 2 — WRITE execute gate, resumed from session 1, result door:
var result = await session.RunAsync(new ClaudeAgentOptions
{ Prompt = "Apply the refactor.", ToolPolicy = AgentToolPolicy.Write, ResumeToken = resumeToken,
WorkingDirectory = cwd });
Console.WriteLine($"done: {result.Verdict} — {result.FinalText}");
IToolLoop (the other shape) — Lyntai drives the ReAct loop in-process over registered ITools.
Choose IToolLoop when you supply the tools and want Lyntai to call them; choose IAgentSession when
the external agent drives its own loop and you want to observe, gate, and resume it.
Durable jobs (Lyntai.Jobs)
Run long, multi-step work (e.g. many agents) that survives restarts, with lanes for concurrency control. Enqueue a job, a runner claims and runs it, your handler checkpoints — and a job whose worker crashed is reclaimed and resumed from its checkpoint. Your app owns the pump (no background threads are started for you):
sealed class SummarizeHandler : IJobHandler
{
public string Type => "summarize";
public async Task<JobOutcome> HandleAsync(JobContext ctx, CancellationToken ct)
{
if (ctx.Checkpoint is null) { /* step 1 … */ await ctx.SaveCheckpointAsync("fetched", ct); }
/* step 2 (skipped-ahead on resume) … */
return JobOutcome.Complete; // or JobOutcome.Retry(delay) / JobOutcome.Fail(reason)
}
}
services.AddLyntai(cfg => cfg
.UseSqliteStorage("jobs.db") // durable — Postgres/InMemory also supported
.AddJobHandler<SummarizeHandler>()
.Configure(o => { o.Jobs.LaneConcurrency["summarize"] = 4; o.Jobs.MaxConcurrency = 8; }));
await queue.EnqueueAsync("summarize", "summarize", payloadJson);
await runner.RunAsync(ct); // in your IHostedService — claims across lanes and runs them in parallel
Per-lane limits + a global MaxConcurrency cap are the control knobs; run several IJobRunner instances
(one process or many) and the atomic claim gives each job to exactly one. At-least-once semantics —
handlers must be idempotent from their checkpoint.
Priorities + dead-letter queue. Enqueue with a priority (higher runs first within a lane), and a job
that exhausts its retries lands in the dead-letter queue (JobStatus.Dead) — inspectable and replayable
rather than a silent failure:
await queue.EnqueueAsync("summarize", "summarize", payloadJson, priority: 10); // jumps the lane
foreach (var dead in await queue.ListDeadAsync()) // inspect what gave up
await queue.ReplayAsync(dead.Id); // requeue it (attempts reset)
await queue.CancelAsync(jobId); // cancels a Pending job; requests cancellation of a Running one
CancelAsync on a running job is cooperative — the runner cancels the handler's CancellationToken, so a
handler that honors it stops (and the job becomes Cancelled).
Recurring schedules. Register an interval schedule and IJobScheduler enqueues the job every interval;
the next-run time is persisted (in the key-value store) so the cadence survives restarts. The app owns the
scheduler pump too:
cfg.AddJobSchedule("nightly-report", lane: "reports", type: "report", payload: "{}", every: TimeSpan.FromHours(24));
cfg.AddCronSchedule("weekday-9am", lane: "reports", type: "report", payload: "{}", cron: "0 9 * * 1-5"); // or a cron (UTC)
await scheduler.RunAsync(ct); // in your IHostedService, alongside runner.RunAsync
Guards, orchestration, secrets, vision
- Guards (
Lyntai.Guards) —IGuards inspect requests/replies and Allow/Block/Replace;AddGuard<T>()registers them,GuardedLlmClientgates any completion, and the chat orchestrator applies them as gates. - Two-gate chat (
IChatOrchestrator) — one call runs: input gate → memory recall → model (via the tool loop) → output gate → remember. A batteries-included, guarded chat entry point. - Secret vault (
Lyntai.Secrets) —AddSecretVault(key)gives anISecretVaultencrypted at rest (AES-256-GCM, your key), persistent over your storage backend, with an optional read access policy. Prefer no key to manage?AddEnvelopeSecretVault(machineProtector)(Core) uses a Lyntai-generated key sealed to the host and backed by a one-time recovery key for off-machine recovery; on Windows,AddDpapiSecretVault()(Lyntai.Secrets.Dpapi) binds it with DPAPI. CallGenerateMasterKeyAsync()once (record the recovery key),RecoverAsync(key)on migration. - Vision —
LlmMessage.UserWithImage(text, bytes, "image/png")(orUserWithImageUrl); the OpenAI-compatible and MEAI-bridged providers send it as image content.
Dev loop
node devtools/dev.mjs build # build the solution
node devtools/dev.mjs test # xUnit tests (unit + integration, zero real tokens)
node devtools/dev.mjs e2e --build # Playground full-stack smoke against the provider-stub
node devtools/dev.mjs playground # run the sample console app yourself
node devtools/dev.mjs pack # dotnet pack → publish/packages/
node devtools/dev.mjs install-hooks # enable the pre-commit sensitive-info guard
See .claude/rules/dev-conventions.md for the load-bearing patterns.
License
MIT © Jiarong Gu — the same MIT SPDX expression every NuGet package carries.
| Product | Versions 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. |
-
net10.0
- Lyntai.Core (>= 2.0.0)
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 |
|---|