Agents.KT is built for teams that need to know exactly what an AI system is allowed to do. Every agent is Agent<IN, OUT>: one input type, one output type, one job. Type mismatches and wrong compositions are caught by the compiler where composition is purely type-driven, and structural misuses fail fast at construction time.

The 0.6–0.7 line turns those boundaries into reviewable evidence: deterministic permission manifests, runtime manifestHash correlation, JSONL audit export, a tamper-evident ToolAuditLedger, OTel/LangSmith/Langfuse bridge adapters, before-interceptor policy hooks, declarative tool policy metadata, reasoning/thinking stream, vendor-neutral prompt caching with prefix-stability guard, snapshot/resume with manifest-hash restore guard, and typed audit events for hallucinated tool calls. It is not a compliance product, and it does not OS-sandbox arbitrary tool code — Security Model is precise about which boundaries are kernel-enforced versus deployer-owned. Agents.KT is the runtime behind agents-kt.dev.


First 10 Minutes

Requirements: JDK 21+, Kotlin 2.x, Gradle

// build.gradle.kts
dependencies {
    implementation("ai.deep-code:agents-kt:0.8.2")
}

Or clone and build from source:

git clone https://github.com/Deep-CodeAI/Agents.KT.git
cd Agents.KT
./gradlew test
# #2807 — static analysis. Baseline freezes existing violations;
# new code is held to the rules in `detekt.yml`.
./gradlew detekt

Building an agent

An Agent<IN, OUT> has one input type, one output type, one or more skills, and an optional model for the agentic path. Skills come in two shapes:

  • implementedBy { input -> output } — deterministic Kotlin lambda. No LLM. Fastest and fully testable.
  • tools(...) with a model { } block — agentic. The framework runs a multi-turn loop where the LLM picks tools from the skill's allowlist; the runtime refuses anything outside it (authorization, not just prompting).
val coder = agent<Spec, Code>("coder") {
    model { claude("claude-opus-4-7"); apiKey = System.getenv("ANTHROPIC_API_KEY") }
    lateinit var writeFile: Tool<Map<String, Any?>, Any?>
    lateinit var compile:   Tool<Map<String, Any?>, Any?>
    tools {
        writeFile = tool("write_file", "Write a source file") { args -> writeFile(args) }
        compile   = tool("compile",    "Compile the bundle")  { args -> compile(args)   }
    }
    skills {
        skill<Spec, Code>("write-code", "Implement endpoints") { tools(writeFile, compile) }
    }
}

When multiple skills can take the same input type, the LLM (or a manual skillSelection { }) routes between them.

Composing agents

Composition is purely type-driven — the compiler enforces that boundaries line up. Six primitives ship today:

Primitive Shape What it does
a then b Pipeline<IN, OUT> Sequential. a.OUT must equal b.IN, enforced at compile time.
a / b Parallel<IN, List<OUT>> Run both branches concurrently against the same input; collect results.
agent.branch { … } Branch<IN, OUT> Route per source-output shape (onClass<X> then …, onElse then …); sealed sources are exhaustiveness-checked.
teacher wrap student Pipeline<IN, OUT> Teacher-student: teacher.OUT (a String) becomes student's per-call system prompt.
forum { members(…); captain = … } Forum<IN, OUT> Council of members with a captain that emits the verdict.
triage handoff { … } Branch<IN, OUT> Named hand-off to specialists on the source's typed output (#3871). Same routing as branch + an audit signal (onHandoff / HandoffPerformed); the target never sees the source's history — typed input only, unlike Swarm-style handoff.

A single agent instance can only be placed in one composition — wiring it into two spots fails fast at construction. See docs/composition.md for the operator reference, docs/patterns.md for the Anthropic "Building Effective Agents" catalog mapped 1:1 onto these primitives, and docs/comparison.md for the release narrative.

One typed pipeline

val parse = agent<RawText, Specification>("parse") {
    skills {
        skill<RawText, Specification>("parse-spec", "Splits raw text into a structured specification") {
            implementedBy { input -> Specification(input.text.split(",").map { it.trim() }) }
        }
    }
}
val generate = agent<Specification, CodeBundle>("generate") {
    skills {
        skill<Specification, CodeBundle>("gen-code", "Generates stub functions for each endpoint") {
            implementedBy { spec -> CodeBundle(spec.endpoints.joinToString("\n") { "fun $it() {}" }) }
        }
    }
}
val review = agent<CodeBundle, ReviewResult>("review") {
    skills {
        skill<CodeBundle, ReviewResult>("review-code", "Approves code if it is non-empty") {
            implementedBy { code -> ReviewResult(approved = code.source.isNotBlank()) }
        }
    }
}

// Compiler checks every boundary
val pipeline = parse then generate then review
// Pipeline<RawText, ReviewResult>

val result = pipeline(RawText("getUsers, createUser, deleteUser"))
// ReviewResult(approved=true)

Testing details — task names, integration test setup, mutation testing, and how to write tests with a stub ModelClient — are in docs/testing.md. Build prerequisites are on the Building From Source wiki page. macOS contributors: the Linux sandbox tests (bwrap/firejail) need a Linux kernel, so they auto-skip on macOS — CI runs them on a native Ubuntu runner; see docs/testing.md → Linux sandbox tests.


What Agents.KT Owns

Agents.KT owns the runtime boundary model:

  • Typed Agent<IN, OUT> contracts and composition operators.
  • Per-skill tool authorization and typed tool handles.
  • MCP client/server surfaces that share the same tool/skill shape.
  • Permission manifests, declarative tool policies, and runtime audit correlation.
  • JSONL audit export plus OTel, LangSmith, and Langfuse adapters through ObservabilityBridge.
  • Local-first JVM execution with Ollama by default and cloud providers when you choose them.

These are the pieces the framework can make deterministic, testable, and reviewable in code. Start with permission manifests, the threat model, the regulated deployment guide, and the comparison page for the release narrative. The manifest is also reachable outside Gradle via the standalone agents-kt CLI (generate / inspect / verify) — a drop-in CI gate that fails when a change widens a capability boundary (#1923).

What Agents.KT Does Not Own

Agents.KT emits evidence and enforces in-runtime boundaries; it does not replace your deployment controls:

  • It is not a legal compliance product. It produces compliance-supporting artifacts and audit-ready evidence; your counsel and compliance team still classify the use case.
  • It does not yet OS-sandbox arbitrary Kotlin lambdas. A tool's declared ToolPolicy is now enforced in-JVM for filesystem-path arguments (Layer 1, #2890 — a declared write/read glob blocks out-of-policy absolute paths before the executor runs; see docs/tool-policy-enforcement.md), but a lambda can still touch paths it doesn't take as arguments, and network/environment isolation needs the Layer-2 OS/container sandbox (#1916), which remains a deployer responsibility for now.
  • It does not rate-limit public MCP ingress. Use McpServer auth/policy plus your gateway.
  • It does not ship a universal prompt-injection classifier. Wire your chosen detector through onBeforeTurn.
  • It does not try to be a vector-store, eval-suite, or hosted orchestration platform. It is the typed JVM runtime boundary underneath those integrations.

Why Agents.KT

Most agent frameworks let you wire anything to anything. Agents.KT says no.

Problem Agents.KT answer
God-agents with unlimited responsibilities Agent<IN, OUT> — one type contract, compiler-enforced SRP
Runtime type mismatches between agents then requires A.OUT == B.IN — compile error otherwise
The same agent instance wired into two places Single-placement rule — IllegalArgumentException at construction time
LLM doesn't know which skill to use Manual skillSelection {} routing or automatic LLM routing — descriptions sell each skill to the router
LLM doesn't know what context to load knowledge("key", "description") { } entries — LLM reads descriptions before deciding to call
Flat pipelines only Composition operators covering sequential, forum, parallel, iterative, and branching patterns
LLM output is an untyped string @Generable + @Guide — JSON Schema, provider constrained decoding, prompt fragments, lenient deserializer, and PartiallyGenerated<T>; KSP-generated metadata avoids runtime reflection when present
MCP tools are wrappers, not first-class McpClient.tools() returns first-class McpTool<*, *> handles, while toolSkills() keeps the prompt-style skill adapter; agents can also be exposed as MCP servers via McpServer.from(agent)
Permission model is stringly-typed grants { tools(writeFile, compile) } — actual Tool<*,*> references, compiler-validated (planned Phase 2)
No testing story AgentUnit — deterministic through semantic assertions (planned)
JVM frameworks require Java installed Native CLI binary via GraalVM (planned Phase 2 Priority)

What's Shipped

This section is the index — every claim below points to working code in main, with the issue number that established it. Topical detail lives in docs/.

Implemented today

These APIs work in main, are unit-tested, and are exercised by integration tests (./gradlew test for default suite, ./gradlew integrationTest for live-LLM):

  • Typed agentsAgent<IN, OUT> with at least one skill producing OUT, validated at construction. See docs/skills.md.
  • Skills with knowledgeskill { knowledge("key", "...") { } }, lazy-loaded per call. See docs/skills.md#shared-knowledge.
  • Agentic loop with tool calling — multi-turn chat ↔ tools driven by the model. See docs/model-and-tools.md.
  • Eight model providersmodel { ollama(...) } for local/cloud Ollama, model { claude("claude-opus-4-7"); apiKey = ... } for Anthropic's Messages API, model { openai("gpt-4o"); apiKey = ... } for OpenAI Chat Completions, model { deepseek("deepseek-v4-flash"); apiKey = ... } for DeepSeek's OpenAI-compatible API, model { kimi("moonshot-v1-8k"); apiKey = ... } for Kimi (Moonshot AI) — long-context (-32k / -128k) variants via the model string (#2697), model { openrouter("anthropic/claude-3.5-sonnet"); apiKey = ...; openRouterHttpReferer = "https://your.app"; openRouterXTitle = "Your App" } for OpenRouter — the OpenAI-compatible multi-provider aggregator that fronts hundreds of upstream models behind one API, with optional HTTP-Referer / X-Title attribution headers honored across the catalog (#2701), model { perplexity("sonar"); apiKey = ... } for Perplexity's Sonar web-grounded API (OpenAI-compatible; sonar / sonar-pro / sonar-reasoning-pro / sonar-deep-research, #3675), and model { gemini("gemini-2.5-flash"); apiKey = ... } for Google Gemini's Generative Language API — a full from-scratch adapter (not OpenAI-compatible): contents/parts with user/model roles, systemInstruction, functionDeclarations tool calling, native SSE streaming, responseJsonSchema constrained decoding, thought-summary reasoning, and inlineData vision (#1917). All eight go through one ModelClient interface — LlmMessage / LlmResponse are provider-agnostic, tools/system/role mapping is per-adapter (#1644, #1656).
  • Typed tools via @Generabletool<Args, Result>(...) with reflection-built JSON Schema; additionalProperties: false; sealed-discriminator validation (#658, #661, #699).
  • Provider-neutral tool handles — local typed tool handles and MCP-discovered tools share Tool<IN, OUT>; McpClient.tools() returns McpTool<Map<String, Any?>, String> for grants/manifests/policy work while toolSkills() remains available for primary-skill use (#1948).
  • Provider constrained decoding for @Generable outputs — agentic skills returning @Generable types pass their JSON Schema to supporting providers automatically: OpenAI response_format.json_schema, Ollama format, and Anthropic's forced structured-output tool pattern (#1949).
  • Reasoning/thinking stream — opt-in model { reasoning(budgetTokens = ..., effort = ...) } surfaces a model's reasoning as AgentEvent.Reasoning, a channel separate from the answer Token stream, so a production UI can render live reasoning instead of a spinner. Claude (extended thinking), DeepSeek (reasoning_content), Ollama (thinking), and Gemini (thought summaries via thinkingConfig.includeThoughts) emit reasoning text; OpenAI Chat Completions reports reasoning_effort + reasoning token counts only (no text). Off by default (#2406). See docs/streaming.md.
  • Typed tool refs in skill allowliststool(...) returns a Tool<Args, Result> handle; skill { tools(writeFile, compile) } accepts handles, the IDE catches typos (#1015–#1017). The legacy tools("name") string form remains for built-in tools and runtime-discovered MCP names but produces a deprecation warning.
  • Declarative tool policies + in-JVM filesystem enforcementtool { policy { risk = ToolRisk.Medium; filesystem { write("/uploads/**") }; network { denyAll() } } } records expected filesystem/network/environment scope for manifests and audit events, and the declared filesystem path-arguments are now enforced at runtime by default (Layer 1, #2890): an out-of-policy absolute path is denied before the executor runs, surfacing via onToolDenied / PipelineEvent.ToolDenied. Opt-in by declaration; enforceToolPolicies = false opts out. OS/container sandboxing of executors (network/env, subprocess isolation) is the separate Layer-2 work (#1916). See docs/tool-policy-enforcement.md.
  • Permission manifestsagent.permissionManifest() and pipeline.permissionManifest() emit deterministic JSON/YAML capability graphs with agents, skills, tools, memory, MCP, providers, budgets, guardrails, composition structure, masked secrets, and a SHA-256 hash that is attached to runtime events (#1912). See docs/permission-manifest.md.
  • Per-skill tool authorization — runtime allowlist; the prompt's "Available tools" listing is descriptive, the security boundary is the runtime check (#630). See docs/model-and-tools.md#tool-authorization-model.
  • Before interceptorsonBeforeSkill, onBeforeTurn, and onBeforeToolCall return Decision (Proceed, ProceedWith, Deny, Substitute) for dynamic policy, prompt filtering, argument mutation, and synthetic results (#1907). See docs/interceptors.md.
  • Inline tool-call fallback — auto-recovery when an Ollama model rejects native tools (e.g. gemma3:4b) — strips the field, injects inline JSON format prompt, retries (#702, #706). See docs/model-and-tools.md#inline-tool-call-fallback-ollama-models-without-native-tool-support.
  • Composition operatorsthen, / (parallel), * and forum { } (multi-agent), .loop {}, .branch {} on sealed types. See docs/composition.md.
  • Single-placement rule — each Agent instance participates in at most one structure; second placement throws at construction. See docs/composition.md#single-placement-rule.
  • Memory bankmemory(MemoryBank()) auto-injects memory_read / memory_write / memory_search tools. See docs/memory.md.
  • LLM skill routing — manual skillSelection { } or LLM router with skillSelectionConfidenceThreshold; SkillRoute(name, confidence, rationale) is structured (#641). See docs/model-and-tools.md#skill-selection.
  • Tool error recovery — per-tool onError, per-skill default, agent default; built-in escalate and throwException agents. See docs/error-recovery.md.
  • Budget controlsbudget { maxTurns; maxToolCalls; maxDuration; perToolTimeout; maxTokens; maxConsecutiveSameTool } (perToolTimeout covers regular and session-aware tools; token counts cumulative across turns when the provider reports usage; maxConsecutiveSameTool catches LLM retry loops on a broken tool) (#637, #963, #969, #1903). onBudgetExceeded { reason, currentLimit -> BudgetDecision.Extend(newLimit) } raises a cap and continues instead of throwing — a long-running agent can grant itself more tool calls mid-run rather than failing (#2412). BudgetDecision.Checkpoint (#2749) is the third variant — pause at the cap, deliver a SessionSnapshot via the registered onTurnCheckpoint hook, throw a recoverable BudgetCheckpointException, and resume later via agent.invokeSuspendResuming(input, resumeFrom = snapshot) once the human approves a raise (no history replay).
  • Public snapshot / resumeagent.invokeSuspendResuming(input, resumeFrom = null, onTurnCheckpoint = null) (#2749) is the public seam over the internal executeAgentic(resumeFrom, onTurnCheckpoint) primitives from #2416. With defaults it matches invokeSuspend(input) byte-for-byte; with onTurnCheckpoint set it captures a SessionSnapshot at every turn boundary; with resumeFrom = snapshot it continues an in-flight invocation without replaying history. On resume the loop honors max(snapshot.toolCallLimit, agent.budget.maxToolCalls) so a rebuilt agent with a raised cap actually picks it up.
  • Eval harnessDeterministicModelClient(LlmResponse.Text("..."), LlmResponse.ToolCalls(...)) (#2492) scripts model responses for reproducible eval without a live provider; the streaming flow folds into the same Started → ArgsDelta → Finished → End chunk sequence a native streaming provider would emit. Typed assertion DSL eval<IN, OUT>("name") { input(...); expect { ... }; expectSnapshot(...) } (#2493) runs against the parsed OUT — not regex on the wire. Snapshot mode pins toLlmInput(output) JSON for structural diffs; evalSuite { + case; + case } bundles cases. Optional judge("tone", rubric) (#2494) runs an advisory LLM-as-judge scorer with a typed @Generable JudgeVerdict — explicitly separate from the deterministic pass/fail contract (judges never gate). See docs/eval.md.
  • Multimodal foundationsealed Content { Text, Image(ref, ImageMime), Audio, Video, Document(ref, DocMime) } (#2466) with closed mime types per modality (no String mime). Content-addressed ContentRef(hash, sizeBytes, wireMime) + BlobStore interface, InMemoryBlobStore / FileBlobStore impls — SHA-256 keys match the manifest-hash family, atomic tmp+rename, process-restart-safe, idempotent put (#2467). Tools can return ToolResult(parts: List<Content>) for mixed text + image + document outputs; JSONL audit exporter records outputParts per-part summary (<modality>:<hash-prefix>:<size>:<mime>) with no blob bytes in the audit row (#2469). Stage 1 wires Image + Document end-to-end. One-line file loading via Files.load(path, store) — auto-detects modality + mime from extension, returns typed Content. See docs/multimodal.md.
  • Vision input to modelsLlmMessage(role = "user", content = "...", images = listOf(ImagePart(base64, ImagePart.WireMime.Png))) (#2470 slice a) reaches all four built-in adapters: Ollama emits images: [<b64>...], Claude emits {type:"image", source:{type:"base64",...}} content blocks, OpenAI emits {type:"image_url", image_url:{url:"data:..."}} content blocks, DeepSeek inherits OpenAI (silently ignored on non-vision models). Closed ImagePart.WireMime { Png, Jpeg, Gif, Webp } — no String mime. Programmatic VisionFixtures.threeSquaresPng() / housePng() (256×256, BufferedImage-rendered, ~5KB) + per-provider live tests (qwen3-vl:8b / Haiku 4.5 / gpt-4o-mini) with cost discipline. See docs/multimodal.md.
  • Typed Content.Image at the agent surfaceagent.invokeWithAttachments("describe", attachments = listOf(Content.Image(ref, ImageMime.Png))) (#2470 slice b). Inject a BlobStore via blobStore(store) in the agent DSL; the runtime dereferences each Content.Image against the store, base64-encodes once, and attaches ImagePart to the first user message. Closed ImageMime → ImagePart.WireMime mapping covers all four variants. Misconfiguration errors fast (no blobStore configured, missing blob for a ref). Composes with snapshot/resume — refs travel in the snapshot; the same store dereferences on resume. Suspending sibling invokeSuspendWithAttachments. Live tests across all three vision providers via the agent surface. See docs/multimodal.md.
  • Web-grounded search tool (perplexitySearch)tools { +perplexitySearchTool(perplexityKey) } lets an agent reasoning on its own model (Claude/OpenAI/Ollama/…) fetch live, cited facts from Perplexity's Sonar API. The tool is untrustedOutput = true, so results are auto-wrapped in the {"trusted":false} envelope and the model is warned to treat them as data, not instructions (#642) — web search is the canonical prompt-injection vector. The result renders the answer plus a numbered source list parsed from search_results[] (citations land in both the model context and the JSONL audit row). Controls via perplexitySearchOptions { mode = SearchMode.ACADEMIC; recency = SearchRecency.WEEK; allowDomains("arxiv.org"); contextSize = SearchContextSize.HIGH; structuredOutput(MyType::class) } map to search_mode / search_recency_filter / search_domain_filter / web_search_options / response_format json_schema (#3674). Key from .secrets/perplexity-key. See docs/providers.md.
  • NLWeb endpoint tool (nlwebSearch)tools { +nlwebSearchTool(baseUrl = "https://example.com") } lets an agent query an NLWeb endpoint — a website's natural-language interface over its schema.org-structured content — and fold the ranked, typed results into context (#4541, PRD §12.9). Like perplexitySearch it is untrustedOutput = true (fetched web content is treated as data, not instructions). nlwebSearchOptions-style args via NlWebSearchOptions(site = "podcasts", mode = NlWebMode.GENERATE). NLWeb endpoints need no API key. (Every NLWeb endpoint is also an MCP server, so an NLWeb /mcp URL is equally consumable through the existing MCP client — this tool is the zero-wiring /ask-over-HTTP path.)
  • Serve an NLWeb endpoint (NlWebServer)NlWebServer.from(agent).start() exposes the NLWeb POST /ask contract ({query, site?, mode} → ranked schema.org results[]), so agents.kt is consumable by NLWeb clients — the serve side to nlwebSearch's consume side (#4542). Same from(agent) shape, loopback-only JDK-HttpServer posture, and threat model as McpServer.from(agent) / A2AServer.from(agent) (127.0.0.1, optional bearer, front with a gateway). The query is the agent's input; an NlWebSearchResult output is served verbatim (ranked schema.org results), any other output becomes the summary answer — back the agent's retrieval with the RAG EmbeddingStore seam (:agents-kt-rag) or whatever you like.
  • Serve a frontend over AG-UI (AgUiServer)AgUiServer.from(agent).start() exposes an agent over the AG-UI protocol — the agent↔user/frontend layer (e.g. a CopilotKit React chat), the only interop surface that reaches an end-user UI without us building a frontend (#4523). Not a descriptor exporter — a runtime streaming surface: POST a RunAgentInput and get an SSE stream of typed AG-UI events, bridged live from the agent's AgentSession (TokenTEXT_MESSAGE_*, ReasoningREASONING_* (live model thinking), ToolCall*TOOL_CALL_* (including TOOL_CALL_RESULT — the executor return), Skill*STEP_*, wrapped in RUN_STARTED … RUN_FINISHED). Same from(agent) shape, loopback-only posture, and threat model as the others; hand-rolled SSE, no AG-UI SDK. agents.kt now serves the agentic web four ways: MCP, A2A, NLWeb, and AG-UI.
  • Charge for an agent endpoint over x402 (X402PaymentGate, experimental)X402PaymentGate(requirements, facilitator).gate(handler) wraps any JDK HttpHandler so a resource is served only after a settled stablecoin (USDC) payment over the x402 protocol (402 Payment Required). Pass it straight to the serve surfaces — NlWebServer.from(agent, payment = gate) / AgUiServer.from(agent, payment = gate) / A2AServer.from(agent, payment = gate) — to let an agent monetize itself (#4527/#4557; A2A's agent-card discovery stays free). The safe, seller-side half: we hold no key and take no custody — the buyer signs an EIP-3009 authorization and a hosted FacilitatorClient verifies + settles on-chain; we only configure a public payTo, and the LLM never touches money (gating is at the HTTP layer). Fails closed (any failure → 402, never served unpaid). New agents_engine.x402 package.
  • Pay for a resource over x402 (X402Client, experimental) — the buyer half: X402Client(account).get(url) drives the request → 402 → pay → retry handshake so an agent can autonomously pay for what it wants (#4528). On a 402 it parses the seller's terms, signs a real EIP-712/EIP-3009 transferWithAuthorization (secp256k1 + Keccak-256, pinned byte-for-byte against ethers.js vectors), and replays the request with an X-PAYMENT header. This is where irreversible money moves, so it is guardrails-first: the signing key lives in X402Account below the model layer (never in a prompt — the LLM cannot read it or widen the policy), and every payment must clear an X402SpendPolicymaxValuePerPayment cap, allowedPayTo allowlist, allowedNetworks, and an optional confirm human-in-the-loop gate — before any signature; a rejected payment raises X402PaymentDeniedException rather than overpay. EXPERIMENTAL — real USDC moves against a live seller. (Scoped ERC-4337 session keys, the upto scheme, and Solana remain deferred.)
  • AGNTCY interop (OASF record + DIR directory + Identity badge)agent.toOasfRecord(version, authors, locators) exports an AGNTCY OASF 1.0.0 discovery record (the third exporter beside agent.json and the A2A AgentCard; skills carry taxonomy uids via the opt-in .oasf("agent_orchestration/multi_agent_planning") annotation against a vendored, drift-checked taxonomy), and fromOasfRecord(json) imports + fail-closed-validates it back (#4518/#4519, PRD §12.6). The :agents-kt-dir module publishes/discovers records in the AGNTCY DIR content-addressed directory over generated grpc-kotlin stubs for three services — StoreService (CRUD: dir.push(agent.toOasfRecord(...)) → CID, dir.pull(cid)), SearchService (local content search by typed DirQuery facet — skill/domain/author/…), and RoutingService (publish/routeSearch for cross-peer network discovery) (#4520). The trust side ships in :agents-kt-identity: IdentityVerifier.verify(compactJws, jwks) validates an AGNTCY Identity badge (a JOSE/JWS-secured W3C Verifiable Credential) against an issuer's /.well-known/jwks.json, fail-closed via nimbus-jose-jwt (rejects alg: none, HS* algorithm-confusion, expiry, tamper, wrong/unknown key — #4521). Verify-only; issuance deferred. DIR Routing/Search + OCI referrers are follow-ups under epic #4517.
  • Prompt caching across providersagent { caching { enabled = true; cacheSystemPrompt = true; cacheToolDefs = true; cacheConversation = Rolling; ttl = 1.hours; cacheable("doc-id") { ... } } }. Vendor-neutral DSL drives Anthropic's explicit cache_control breakpoints (#2658), OpenAI / DeepSeek automatic prefix caching with a stable prompt_cache_key routing hint (#2659 / #2661), Ollama / vLLM / SGLang engine-level KV-cache reuse (no-op hints, #2662), and surfaces cache reads + writes + hit-rate on TokenUsage (#2663). A prefix-stability guard (#2657) detects silent cache-busters — timestamps, UUIDs, non-deterministic ordering inside cacheable segments — and warns before you pay for a single non-cached run. Off by default; non-breaking. See docs/caching.md.
  • JSONL audit exporter:agents-kt-observability writes append-only, one-line-per-event audit rows with requestId, sessionId, manifestHash, agent/skill/tool ids, event type, provider, and model; raw arguments/results are omitted by default (#1914). See docs/observability.md.
  • ObservabilityBridge adapters.observe(OtelBridge(tracer)) maps runtime events to OTel spans (#1908), .observe(LangSmithBridge(apiKey, project)) maps the same events to LangSmith run trees (#1909), and .observe(LangfuseBridge(publicKey, secretKey)) maps them to Langfuse traces, generations, spans, and events (#1910), while keeping core vendor-free. See docs/observability.md.
  • MCP clientmcp { server() } over HTTP / stdio / TCP; Bearer auth; namespaced tools (server.tool). See docs/mcp.md.
  • MCP serverMcpServer.from(agent) exposes an agent as an MCP-conformant HTTP server with explicit tools/listChanged: false capability (#619), inbound bearer auth, Host/Origin allowlists, and per-principal tool policy (#1902); McpStdioServer.from(agent) serves the same tools/prompts/resources over line-delimited stdio (#2045).
  • McpRunner standalone — picocli-style one-liner main for shipping agents as MCP services over HTTP or --stdio.
  • LiveShow / LiveRunner — REPL deployment with string-concatenated conversation history. Six factory overloads (Agent, Pipeline, Forum, Parallel, Loop, Branch) for any String-input structure; --once "<prompt>" for non-interactive use; built-in /quit, /clear, /help slash commands; user-extensible; JLine-backed cursor movement and in-memory arrow-key history for interactive terminals (#981, #985).
  • Swarm + absorb — drop sibling agent JARs into a folder, the captain ServiceLoader-discovers them and absorbs each as a tool with full agent personality preserved (prompt, skills, knowledge, memory). In-JVM, no IPC, no static-typing-across-JARs limitation MCP-stdio would impose (#984).
  • Frozen-after-construction agents — structural mutators (skills, tools, memory, model, budget, prompt, error handlers, routing) reject post-construction calls (#697, #708).
  • Encapsulated tool/skill mapsAgent.toolMap and Agent.skills are read-only Map views; mutation only via DSL or framework-internal escape hatches (#659, #667).
  • LlmProviderException — provider-boundary errors (auth, model-not-found, capability mismatch) surface distinctly from output-parse errors (#702).
  • Untrusted tool-output wrapping — tool results carry an envelope so the model can't impersonate framework messages (#642).
  • loadResource(path) — read agent system prompts (or any other context) from src/main/resources/... instead of inline string literals; fail-fast at construction if the path is wrong. loadResourceOrNull for the optional case (#980).

Experimental

APIs that exist in main and have tests, but haven't been exercised in production and may evolve based on real-world usage:

  • Forum with transcriptCaptain — captain receives the full ForumTranscript<IN> (all participant outputs) instead of only the original input (#639). Useful for synthesis patterns; semantics may sharpen with usage.
  • Branch on sealed hierarchiesBranchRoute sealed type with onNull / onElse markers and construction-time completeness validation (#640). Stable surface, limited real-world coverage.

What's Not Shipped

The release is intentionally explicit about what the framework does not enforce yet.

Security Model

What the framework enforces today:

Boundary Enforcement Established by
Tool authorization Runtime per-skill allowlist; unknown calls rejected — prompt is descriptive only #630
Tool policy declarations ToolPolicy captures declared risk and filesystem/network/environment scope for review and audit #1915
Dynamic policy onBefore* interceptors can deny, mutate, or substitute before skills, turns, and allowed tool calls run #1907
Tool name typos Fail-fast at agent construction #631
Reserved memory names memory_read / memory_write / memory_search cannot be shadowed by user tools #659
Agent contract Skills, tools, memory, model, budget, prompt frozen after agent { } returns #697, #708
Typed args additionalProperties: false; sealed type discriminator must match constructed variant #661, #699
Repaired args Re-validated through the typed schema before reaching the executor #658
Tool output trust Tool results wrapped in untrusted envelope so the model can't forge framework messages #642
Provider errors Surface as LlmProviderException — never confused with model output #702
Budget caps maxTurns, maxToolCalls, maxDuration, perToolTimeout, maxTokens, maxConsecutiveSameTool (perToolTimeout covers regular tools via worker interrupt and session-aware tools via coroutine cancellation; token cap cumulative across turns when provider reports usage; maxConsecutiveSameTool catches retry loops on a broken tool) #637, #963, #969, #1903

What the framework does not enforce — your responsibility:

  • Built-in prompt-injection classifier — wire your chosen classifier through onBeforeTurn; the framework provides the hook, not the detector.
  • In-JVM side effects of arbitrary tool lambdas — a Kotlin lambda body runs with full JVM permissions. What IS enforced since 0.7.0: declared ToolPolicy filesystem globs gate path arguments in-JVM (Layer 1, #2890), and subprocess tools built with processTool run inside a fail-closed OS sandbox (Layer 2, #1916/#2914 — Seatbelt / bubblewrap / firejail, default-deny network). The canonical status of every boundary lives in the threat model's what's-enforced-where table.
  • Resource limits beyond budgets — no automatic memory, file-descriptor, or network quotas; selective hostname egress is 0.8 (#2893).
  • MCP request rate limitsMcpServer authenticates and filters tools, but per-client throttling still belongs in your gateway for now.

Known Limitations

  • Eight LLM providers shipped — Ollama, Anthropic, OpenAI, DeepSeek, Kimi (Moonshot AI, #2697), OpenRouter (#2701), Perplexity (Sonar, #3675) — the last with a perplexitySearch web-grounded search tool (#3676 / #3677) — and Google Gemini (#1917, a full from-scratch adapter with native SSE, function calling, and responseJsonSchema decoding). The injectable ModelClient covers test stubs and your own adapters.
  • Synchronous agentic looprunBlocking inside the loop until the suspend refactor lands (#638). Calling agents from existing coroutine scopes works but doesn't propagate cancellation cleanly.
  • No built-in MCP rate limiter — use McpServer auth/policy plus a gateway for thro