diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..77ed307
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,67 @@
+# Publish workflow for kotlin-sdk.
+#
+# Triggers on any semver tag (e.g. v1.1.0). The job:
+# 1. Builds, signs, and stages artifacts to OSSRH.
+# 2. Closes and releases the staging repository to Maven Central.
+#
+# Required repository secrets (Settings → Secrets → Actions):
+# OSSRH_USERNAME — Sonatype account username (s01.oss.sonatype.org)
+# OSSRH_PASSWORD — Sonatype account password / token
+# SIGNING_KEY_ID — Last 8 hex digits of the GPG fingerprint
+# SIGNING_KEY — ASCII-armored GPG private key (full block)
+# SIGNING_PASSWORD — Passphrase for the GPG key
+#
+# Action pinning policy:
+# - First-party actions (actions/*) are pinned to a major tag.
+# - Third-party actions are pinned to a full commit SHA with a version
+# comment alongside.
+name: publish
+
+on:
+ push:
+ tags:
+ - 'v[0-9]+.[0-9]+.[0-9]+'
+
+concurrency:
+ group: publish-${{ github.ref }}
+ cancel-in-progress: false
+
+permissions:
+ contents: read
+
+jobs:
+ publish:
+ name: Publish to Maven Central
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '21'
+ cache: gradle
+
+ - name: Validate Gradle wrapper
+ # gradle/actions/wrapper-validation v4.4.4
+ uses: gradle/actions/wrapper-validation@ac638b010cf58a27ee6c972d7336334ccaf61c96
+
+ - name: Build and test
+ run: ./gradlew --no-daemon assemble test
+
+ - name: Publish to OSSRH and release to Central
+ env:
+ OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }}
+ OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }}
+ SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }}
+ SIGNING_KEY: ${{ secrets.SIGNING_KEY }}
+ SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }}
+ run: >
+ ./gradlew --no-daemon
+ publishToSonatype
+ closeAndReleaseSonatypeStagingRepository
diff --git a/KOTLIN_STYLE.md b/KOTLIN_STYLE.md
new file mode 100644
index 0000000..897e158
--- /dev/null
+++ b/KOTLIN_STYLE.md
@@ -0,0 +1,332 @@
+# Kotlin Public SDK Style Guide
+
+Authoritative rules for writing and refactoring this codebase. Rules are
+prescriptive, not suggestive. When a rule says "must" or "never," there is
+no judgment call.
+
+---
+
+## 0. Core Principles
+
+- Optimize for the **caller**, not the author. This is a public SDK.
+- **Smaller is better**: smaller files, smaller functions, smaller surface.
+- **Immutable by default**: mutation is opt-in, never opt-out.
+- **Explicit over implicit**: visibility, nullability, types, dispatchers.
+- **Binary compatibility is a contract**. Breaking it requires a major bump.
+- If a rule and "clever" conflict, the rule wins.
+
+---
+
+## 1. API Visibility & Binary Stability
+
+- Enable `explicitApi()` strict mode in every module. No exceptions.
+- Every public symbol carries an explicit `public` modifier. Never implicit.
+- Default new code to `internal`. Promote to `public` only with intent.
+- `@PublishedApi internal` for symbols referenced from `inline` functions.
+- Never expose `data class` in the public API. Use a regular class with
+ explicit `equals`/`hashCode`/`toString` and a `copy` method if needed.
+ Adding a property to a public `data class` breaks `componentN()`.
+- Never expose `MutableList`, `MutableMap`, `MutableSet` publicly. Return
+ `List`/`Map`/`Set`. Accept the read-only type at parameter positions.
+- Never expose implementation types (`okhttp3.*`, `kotlinx.serialization`
+ internals, framework types). Wrap them.
+- Prefer `interface` over `abstract class` for extension points.
+- Mark classes `final` (the default). Add `open` only with a stated reason
+ in KDoc.
+- Sealed hierarchies are closed: adding a subtype is a breaking change.
+ Document this on the sealed declaration.
+- Use `@RequiresOptIn` for experimental APIs. Never ship an unmarked one.
+- Run the Kotlin Binary Compatibility Validator (`kotlinx-binary-compatibility-validator`)
+ in CI. A diff to `.api` files fails the build.
+
+### §1 amendment — `@Serializable` wire-protocol types
+
+`lib/src/main/kotlin/dev/arcp/messages/*.kt` and adjacent envelope/runtime
+value types are `@Serializable` data classes pinned to the ARCP RFC field
+names via `@SerialName`. The §1 prohibition on public `data class` does not
+apply because:
+
+1. The catalog is versioned by the ARCP RFC; field additions are intentional
+ protocol changes that bump the spec version, not free-form evolution.
+2. Destructuring is not part of the SDK's published consumer surface.
+3. Replacing `data class` with hand-rolled `equals`/`hashCode`/`toString`/
+ `copy` per record would lose kotlinx-serialization compiler-generated
+ serializers or require parallel `@Serializer(forClass=...)` plumbing.
+
+**Rule:** `@Serializable` value types pinned to an external schema (wire
+protocol, IPC catalog) are exempt from the `data class` prohibition.
+Every such class must carry KDoc referencing the RFC section, and every
+field must carry `@SerialName`.
+
+---
+
+## 2. Java Interop (when SDK targets JVM consumers)
+
+- `@JvmStatic` on every public companion function consumers may call.
+- `@JvmOverloads` on every public function with default parameters.
+- `@JvmName` to disambiguate when JVM signatures collide.
+- `@JvmField` only for true public constants exposed to Java.
+- `@Throws(IOException::class, ...)` on suspend or regular functions that
+ cross the JVM boundary and throw checked exceptions Java cares about.
+
+---
+
+## 3. Null Safety
+
+- `!!` is **forbidden** in production code. The single allowed exception is
+ a line preceded by a `// !!: ` comment proving impossibility,
+ and even then prefer `requireNotNull` / `checkNotNull` with a message.
+- `lateinit` only for DI-injected or framework-initialized fields. Never
+ for lazy logic — use `by lazy`.
+- Public API never returns platform types. Annotate Java boundaries with
+ `@Nullable` / `@NotNull` (or wrap them) before exposing.
+- Prefer the Elvis operator with a meaningful default or `error(...)` over
+ nested null checks.
+- `requireNotNull(x) { "x must be set before calling foo()" }` over
+ `x ?: throw IllegalArgumentException(...)`.
+
+---
+
+## 4. Immutability
+
+- `val` is the default. Reach for `var` only with a comment justifying it.
+- Public class properties are `val` unless mutation is the documented contract.
+- Prefer `copy()` returning a new instance over in-place mutation.
+- Collections in public API are read-only types (`List`, `Set`, `Map`).
+- Internal collections may be mutable but never leak. Defensive `.toList()`
+ at the boundary if needed.
+- `@Immutable` / `@Stable` annotations (where the consumer framework
+ defines them) are part of the contract and must be honored.
+
+---
+
+## 5. Functions
+
+- Hard cap: **30 lines** per function body (excluding signature, braces).
+ If over, decompose. No exceptions for "it reads fine."
+- Hard cap: **5 parameters**. At 6, introduce a parameter object.
+- Hard cap: **3 levels of nesting**. Use early returns, guard clauses,
+ `when` extraction, or helper functions to flatten.
+- Prefer top-level functions for stateless helpers over `object` wrappers.
+- Prefer extension functions for utility that reads as a method on a
+ type the SDK does not own.
+- Default parameters over overload chains.
+- Single-expression functions (`fun foo() = bar()`) when the body fits
+ one expression cleanly. Do not force it past readability.
+- Pure functions where possible. Side effects are named and contained.
+- Boolean parameters are a smell. Prefer two functions or an enum.
+
+---
+
+## 6. Classes & Inheritance
+
+- Composition over inheritance. Always.
+- `data class` for internal value types only. Never in public API (see §1).
+- `object` for singletons. Never a class with a private constructor and
+ a `companion object getInstance()`.
+- `sealed interface` over `sealed class` unless state is shared.
+- Constructor-inject all dependencies. No service locators, no globals.
+- `companion object` only for factory functions, constants tied to the type,
+ or JVM static interop. Not a dumping ground.
+- A class doing two things is two classes. Name them both.
+- Hard cap: **300 lines** per class. Over that, the class has multiple
+ responsibilities — split it.
+
+---
+
+## 7. Coroutines & Concurrency
+
+- `suspend` functions are the default async primitive. Never callback APIs
+ in the public surface.
+- `GlobalScope` is **forbidden**. Inject a `CoroutineScope` or use
+ `coroutineScope { }` for structured concurrency.
+- `Dispatchers` are injected, never hard-referenced in business logic.
+ Provide a `CoroutineDispatcher` parameter or a `DispatcherProvider`
+ interface. Hardcoded `Dispatchers.IO` is a refactor target.
+- Wrap blocking IO with `withContext(io)` at the lowest level that owns
+ the blocking call. Never bubble blocking up.
+- `Flow` for streams of values. `StateFlow` for state with a current
+ value. `SharedFlow` for events without conflation. Never `Channel` in
+ public API.
+- Cold flows are the default. Document explicitly if you ship a hot flow.
+- Cancellation is cooperative: never catch `CancellationException` without
+ rethrowing. Use `currentCoroutineContext().ensureActive()` in long loops.
+- No `runBlocking` outside of `main`, tests, or JVM-only sync bridges.
+
+---
+
+## 8. Error Handling
+
+- Expected, recoverable outcomes: sealed `Result`-style type the SDK owns.
+ Do **not** expose `kotlin.Result` in public API (it is restricted).
+- Programmer errors: `require(...)`, `check(...)`, `error(...)` with a
+ message that names the failing precondition.
+- Unexpected, unrecoverable: throw a typed, SDK-owned exception that
+ extends a single public root (e.g. `ARCPException`).
+- Every public throwing function lists exceptions in KDoc with `@throws`.
+- Never `catch (e: Exception)` without rethrowing, logging *and* handling,
+ or narrowing the type. Bare swallows fail review.
+- Never `catch (e: Throwable)`. Period.
+
+---
+
+## 9. Naming
+
+- Packages: all lowercase, no underscores, no camelCase. Reverse-domain.
+- Classes/Interfaces/Objects: `PascalCase`, noun phrases.
+- Functions: `camelCase`, verb phrases. `fetchUser`, not `userFetch`.
+- Properties: `camelCase`, noun phrases.
+- Constants (`const val`, top-level immutable): `SCREAMING_SNAKE_CASE`.
+- Booleans: `is`/`has`/`should`/`can` prefix. `isActive`, `hasPayload`.
+- No Hungarian, no type suffixes (`UserManager` is usually a smell —
+ what does it *do*?).
+- Test functions: `` `does X when Y given Z`() `` backticked sentences.
+- No abbreviations unless they are domain-standard (`url`, `id`, `uuid`).
+ `mgr`, `svc`, `repo` are forbidden; `Manager`, `Service`, `Repository`
+ are at least honest (but see the smell note above).
+
+---
+
+## 10. Idioms & Scope Functions
+
+Use the right scope function. One purpose each:
+
+- `let`: null-safe transform. `value?.let { transform(it) }`.
+- `also`: side effect, return the same receiver. Logging, debugging.
+- `apply`: configure the receiver, return it. Builders.
+- `run`: compute a result from the receiver. Replaces `with` on nullable.
+- `with`: compute a result from a non-null argument.
+
+Rules:
+
+- Never nest scope functions. One level only.
+- Never use a scope function purely for `it` aliasing. Name the variable.
+- Prefer `when` over `if/else if/else` chains of 3+ branches.
+- Prefer `when` over a sequence of `is` checks in `if`.
+- Always cover `when` exhaustively on sealed types. No `else` branch on
+ a sealed `when` — let the compiler enforce.
+- Destructuring on `data class` and `Pair`/`Triple` is fine internally,
+ forbidden across module boundaries.
+- String templates (`"$x"`, `"${obj.field}"`) over concatenation. Always.
+- Ranges and sequences over manual indexed loops.
+- `buildList { }` / `buildMap { }` over manual `mutableListOf().also { }`.
+
+---
+
+## 11. Complexity Limits (hard, enforced by Detekt)
+
+| Metric | Limit |
+|------------------------------|-------|
+| Cyclomatic complexity / fn | 10 |
+| Cognitive complexity / fn | 15 |
+| Function length (lines) | 30 |
+| Class length (lines) | 300 |
+| File length (lines) | 500 |
+| Function parameter count | 5 |
+| Constructor parameter count | 7 |
+| Nesting depth | 3 |
+| Number of returns / fn | 3 |
+| Line length (chars) | 100 hard, **80 target** |
+
+Refactor strategies when over limit:
+
+- **Over function length**: extract helpers, replace inline lambdas with
+ named functions, collapse `when` into table-driven lookup.
+- **Over class length**: extract collaborators, split by responsibility,
+ move helpers to top-level or extensions.
+- **Over file length**: split by type or feature. One public type per file
+ is preferred; never more than three.
+- **Over param count**: introduce a parameter object (often a `data class`
+ internally) or a builder.
+- **Over nesting**: invert conditions, early-return guard clauses, extract.
+- **Over cyclomatic**: replace conditional logic with polymorphism, use
+ `when` over chained `if`, table-driven dispatch.
+
+---
+
+## 12. File & Function Size Targets
+
+- One public top-level declaration per file is the default.
+- Co-locate small, tightly coupled internal helpers in the same file.
+- Filename matches the primary public declaration in PascalCase.kt.
+- Aim for files under **200 lines**. The 500-line cap is the maximum,
+ not a goal.
+- Aim for functions under **15 lines**. The 30-line cap is the maximum.
+- Line length aspires to **80 characters**. The 100 cap is a forcing
+ function for line breaks, not a license.
+
+---
+
+## 13. Documentation (KDoc)
+
+- Every public symbol has KDoc. No exceptions for "obvious" ones.
+- First sentence is a single-line summary, ending in a period.
+- Document `@param`, `@return`, `@throws`, `@sample`, `@since` as relevant.
+- Document **thread safety / coroutine context** assumptions explicitly.
+- Document nullability semantics in prose when the type signature is
+ ambiguous about meaning (e.g. "null means use default").
+- `@sample` to runnable code in a `*-samples` source set when the API is
+ non-trivial.
+- Mark deprecations with `@Deprecated(message, ReplaceWith(...), level)`
+ and a `@since` for when removal is planned.
+
+### §13 amendment — `@SerialName` wire-protocol properties
+
+When a property carries `@SerialName` and the enclosing class KDoc
+references the spec section that defines the field, a separate
+property-level KDoc is not required. Detekt's `UndocumentedPublicProperty`
+remains off for this codebase; `UndocumentedPublicClass` and
+`UndocumentedPublicFunction` are enforced.
+
+---
+
+## 14. Build & Tooling (non-negotiable)
+
+- Kotlin compiler: `-Xexplicit-api=strict`, `-Werror`,
+ `-Xjvm-default=all` (JVM modules), `-opt-in` per experimental need only.
+- ktlint or Spotless with ktlint, default ruleset + project overrides.
+ CI fails on lint violation.
+- Detekt with the limits in §11 codified in `detekt.yml`. CI fails on
+ violation. No baseline suppressions added without a TODO ticket linked.
+- Kotlin Binary Compatibility Validator. `.api` files committed.
+- Test coverage: line coverage gate on public modules (project-defined
+ threshold, not less than 80% on changed code).
+- No `// TODO` without an issue link. No `// FIXME` in merged code.
+
+---
+
+## 15. Forbidden Patterns (refactor on sight)
+
+- `!!` (see §3).
+- `GlobalScope` (see §7).
+- `runBlocking` outside permitted locations (see §7).
+- `lateinit var` for non-DI fields (see §3).
+- Bare `catch (e: Exception)` / `catch (e: Throwable)` (see §8).
+- Public `data class` (see §1 — exemption in §1 amendment).
+- Public `MutableList`/`MutableMap`/`MutableSet` (see §1).
+- `companion object` used as a static utility dumping ground (see §6).
+- `Object.getInstance()` singletons — use `object` (see §6).
+- `Boolean` parameters where two functions or an enum would clarify intent.
+- `if (x != null) x.foo() else default` — use `x?.foo() ?: default`.
+- `for (i in 0 until list.size)` — use `forEachIndexed` or `withIndex()`.
+- String concatenation with `+` across more than two operands.
+- `getX()`/`setX()` style accessors — use properties.
+- Nested ternary-style `if` expressions across more than 3 lines.
+- Returning `null` from a function that conceptually returns a collection
+ — return an empty collection.
+- Magic numbers and strings — extract to named `const val` or enum.
+
+---
+
+## 16. Testing the API (not just the impl)
+
+- Public API has contract tests pinning behavior, not just unit tests
+ on internals.
+- Test through the public surface. If you cannot, the public surface
+ is wrong, not the test.
+- Inject `TestDispatcher` for coroutines. Never `delay()` in tests
+ without `runTest` virtual time.
+- One assertion concept per test. Multiple `assert*` calls fine if they
+ describe one behavior.
+- Test names describe behavior, not implementation
+ (`` `returns empty list when source is empty`() ``).
diff --git a/build.gradle.kts b/build.gradle.kts
index 130967e..e548f64 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -5,11 +5,12 @@ plugins {
alias(libs.plugins.detekt) apply false
alias(libs.plugins.kover) apply false
alias(libs.plugins.dokka) apply false
+ alias(libs.plugins.nexus.publish)
}
allprojects {
group = "dev.arcp"
- version = "0.1.0"
+ version = "1.1.0"
}
subprojects {
@@ -59,3 +60,19 @@ subprojects {
}
}
}
+
+// ---------------------------------------------------------------------------
+// OSSRH / Maven Central
+// ---------------------------------------------------------------------------
+nexusPublishing {
+ repositories {
+ sonatype {
+ nexusUrl.set(uri("https://s01.oss.sonatype.org/service/local/"))
+ snapshotRepositoryUrl.set(
+ uri("https://s01.oss.sonatype.org/content/repositories/snapshots/"),
+ )
+ username.set(providers.environmentVariable("OSSRH_USERNAME"))
+ password.set(providers.environmentVariable("OSSRH_PASSWORD"))
+ }
+ }
+}
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..7f15cfa
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,49 @@
+# ARCP Kotlin SDK — Documentation
+
+Reference Kotlin implementation of the
+[Agent Runtime Control Protocol (ARCP) v1.1](https://github.com/agentruntimecontrolprotocol/spec).
+
+---
+
+## Start here
+
+- [Getting started](getting-started.md) — install, quickstart, first session
+- [Architecture](architecture.md) — layering diagram, module descriptions, wire format
+- [Conformance](conformance.md) — spec section-by-section coverage table
+- [Troubleshooting](troubleshooting.md) — common failure modes and fixes
+
+---
+
+## Guides
+
+Concept-first explanations of each protocol surface:
+
+| Guide | RFC |
+|-------|-----|
+| [Sessions](guides/sessions.md) | §6 |
+| [Authentication](guides/auth.md) | §6.1 |
+| [Resume & replay](guides/resume.md) | §6.3 |
+| [Jobs](guides/jobs.md) | §7 |
+| [Job events](guides/job-events.md) | §8 |
+| [Leases & budgets](guides/leases.md) | §9 |
+| [Delegation & handoff](guides/delegation.md) | §10 |
+| [Observability](guides/observability.md) | §11 |
+| [Errors](guides/errors.md) | §12 |
+| [Vendor extensions](guides/vendor-extensions.md) | §15 |
+
+---
+
+## Modules
+
+API reference for each Gradle module:
+
+- [`arcp` (lib)](modules/arcp.md) — the protocol library
+- [`arcp-cli` (cli)](modules/arcp-cli.md) — the `arcp` binary
+
+---
+
+## Reference
+
+- [Transports](transports.md) — WebSocket, stdio, in-memory
+- [CLI](cli.md) — `arcp serve`, `arcp submit`, `arcp replay`
+- [Recipes](recipes.md) — copy-paste solutions for common patterns
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..abdad31
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,111 @@
+# Architecture
+
+## Diagram
+
+
+
+
+
+
+## Layers
+
+```
+┌──────────────────────────────────────────────────────────────────────┐
+│ Application / Agent │
+├──────────────────────────┬───────────────────────────────────────────┤
+│ ARCPClient │ ARCPRuntime │
+│ dev.arcp.client │ dev.arcp.runtime │
+├──────────────────────────┴───────────────────────────────────────────┤
+│ Messages / Envelope │
+│ dev.arcp.messages dev.arcp.envelope │
+├──────────────────────────────────────────────────────────────────────┤
+│ Transport │
+│ dev.arcp.transport (Memory / WebSocket / stdio) │
+├──────────────────────────────────────────────────────────────────────┤
+│ Supporting services │
+│ auth credentials lease store trace extensions ids error │
+└──────────────────────────────────────────────────────────────────────┘
+```
+
+## Modules
+
+### `:lib` — protocol library (`dev.arcp:arcp`)
+
+The publishable artifact. All public API lives here.
+
+| Package | Contents |
+|---------|----------|
+| `dev.arcp.envelope` | `Envelope` — the canonical wire container; custom serializer hoists the `type` discriminator per RFC §6.1 |
+| `dev.arcp.messages` | Every RFC §6.2 message type as a `@Serializable @SerialName` data class implementing `MessageType` |
+| `dev.arcp.client` | `ARCPClient` — opens sessions, sends messages, assembles result chunks |
+| `dev.arcp.runtime` | `ARCPRuntime` — handshake, dispatch loop, capability negotiation, job inventory, budget tracking |
+| `dev.arcp.transport` | `Transport` interface + `MemoryTransport` |
+| `dev.arcp.auth` | `BearerAuth`, `JwtAuth`, `StaticBearerAuth` |
+| `dev.arcp.credentials` | `Credential`, `CredentialStore`, `CredentialProvisioner` |
+| `dev.arcp.lease` | `CostBudget`, `ModelUseLease`, `BudgetRegistry`, subset validation |
+| `dev.arcp.store` | `EventLog` — append-only SQLite store with idempotency, replay, and resume |
+| `dev.arcp.trace` | `TraceContext` — W3C TraceContext propagation |
+| `dev.arcp.extensions` | `ExtensionRegistry` — vendor extension dispatch |
+| `dev.arcp.ids` | Typed ID wrappers (`SessionId`, `JobId`, `MessageId`, …) |
+| `dev.arcp.error` | `ARCPException` hierarchy, `ErrorCode` enum |
+| `dev.arcp.json` | `arcpJson` — pre-configured `Json` instance (lenient, ignores unknown keys) |
+
+### `:cli` — the `arcp` binary (`dev.arcp:arcp-cli`)
+
+A thin JVM command-line tool built on the library. See [CLI](cli.md).
+
+### `:samples` — runnable examples
+
+One Kotlin file (or small directory) per scenario. Each scenario is
+independently runnable via `./gradlew :samples:run`. See the
+`samples/` directory for the full list.
+
+### `:tests` — integration tests
+
+End-to-end tests that pair an `ARCPRuntime` with an `ARCPClient` over
+`MemoryTransport`. These tests target the public SDK surface, not internals.
+
+## Wire format
+
+ARCP uses JSON over a bidirectional transport (RFC §6.1). Every message is
+wrapped in an `Envelope`:
+
+```json
+{
+ "id": "msg_01234",
+ "type": "session.open",
+ "timestamp": "2026-05-09T13:00:00Z",
+ "session_id": "sess_abcde",
+ "job_id": null,
+ "correlation_id": null,
+ "causation_id": null,
+ "trace_id": null,
+ "priority": "normal",
+ "payload": { /* message-specific fields */ }
+}
+```
+
+The `type` field drives polymorphic deserialization: `arcpJson` (a
+`kotlinx.serialization` `Json` instance) decodes the payload into the
+correct `MessageType` subclass via `@SerialName` annotations.
+
+## RFC section map
+
+| RFC § | Implementation |
+|-------|----------------|
+| §6.1 envelope | `envelope/Envelope.kt` |
+| §6.2 message types | `messages/*.kt` |
+| §6.3 resume / replay | `store/EventLog.kt` |
+| §6.4 idempotency | `store/EventLog.kt` |
+| §7 capability negotiation | `runtime/CapabilityNegotiation.kt` |
+| §8 session handshake | `runtime/ARCPRuntime.kt`, `client/ARCPClient.kt` |
+| §9 leases & budgets | `lease/`, `runtime/ARCPRuntime.kt` |
+| §10 cancellation & delegation | `messages/Control.kt`, `runtime/ARCPRuntime.kt` |
+| §11 observability / metrics | `messages/Telemetry.kt`, `trace/TraceContext.kt` |
+| §12 error taxonomy | `error/ErrorCode.kt`, `error/ARCPException.kt` |
+| §15 vendor extensions | `extensions/ExtensionRegistry.kt` |
+| §18 error codes | `error/ErrorCode.kt` |
+| §19 resume | `store/EventLog.kt` |
+| §21 extensions | `extensions/ExtensionRegistry.kt` |
diff --git a/docs/cli.md b/docs/cli.md
new file mode 100644
index 0000000..019f830
--- /dev/null
+++ b/docs/cli.md
@@ -0,0 +1,73 @@
+# CLI — `arcp`
+
+The `arcp` binary is a thin JVM command-line tool built on the SDK library.
+It is distributed as the `:cli` Gradle module and published separately as
+`dev.arcp:arcp-cli`.
+
+> **Status**: v0.1 ships the `version` subcommand. The protocol-driving
+> subcommands (`serve`, `tail`, `send`, `replay`) are scheduled for v0.2,
+> when the WebSocket and stdio transports land.
+
+---
+
+## Building the binary
+
+```bash
+./gradlew :cli:installDist
+# Binary is placed at:
+./cli/build/install/arcp/bin/arcp
+```
+
+Or run directly through Gradle:
+
+```bash
+./gradlew :cli:run --args="version"
+```
+
+---
+
+## Commands
+
+### `arcp version`
+
+Print SDK and protocol versions.
+
+```
+$ arcp version
+ARCP protocol: 1.1
+Kotlin SDK: 1.1.0
+SDK kind: kotlin
+```
+
+### `arcp serve` *(v0.2)*
+
+Run an ARCP runtime over a transport:
+
+```
+$ arcp serve --transport=websocket --port=8080
+```
+
+### `arcp send` *(v0.2)*
+
+Submit a job to a running runtime:
+
+```
+$ arcp send --url=wss://runtime.example.com/arcp \
+ --agent=summarise@1.0.0 \
+ --token=my-bearer-token
+```
+
+### `arcp replay` *(v0.2)*
+
+Replay a session log from an `EventLog` SQLite file:
+
+```
+$ arcp replay --db=session.db --session=sess_abcde
+```
+
+---
+
+## Shell completion *(v0.2)*
+
+Completion scripts for bash, zsh, and fish will be generated automatically
+by the Clikt framework when the `--generate-completion` option lands.
diff --git a/docs/conformance.md b/docs/conformance.md
new file mode 100644
index 0000000..627850a
--- /dev/null
+++ b/docs/conformance.md
@@ -0,0 +1,61 @@
+# Conformance
+
+This document maps ARCP v1.1 RFC sections to their Kotlin SDK implementations.
+
+## Implementation status
+
+| RFC § | Title | Status | Implementation |
+|-------|-------|--------|----------------|
+| §6.1 | Envelope format | ✅ | `envelope/Envelope.kt` |
+| §6.2 | Message catalog | ✅ | `messages/*.kt` |
+| §6.3 | Resume | ✅ | `store/EventLog.kt` |
+| §6.4 | Idempotency | ✅ | `store/EventLog.kt` |
+| §6.6 | `session.list_jobs` / `session.jobs` | ✅ | `messages/Session.kt`, `runtime/ARCPRuntime.kt` |
+| §7 | Capability negotiation | ✅ | `runtime/CapabilityNegotiation.kt` |
+| §7.5 | Agent versioning (`name@version`) | ✅ | `runtime/AgentRegistry.kt` |
+| §8 | Session handshake | ✅ | `runtime/ARCPRuntime.kt`, `client/ARCPClient.kt` |
+| §8.2 | Authentication (`bearer`, `signed_jwt`) | ✅ | `auth/BearerAuth.kt`, `auth/JwtAuth.kt` |
+| §8.4 | `result_chunk` streaming | ✅ | `messages/Execution.kt`, `client/ARCPClient.kt` |
+| §9 | Leases & budgets | ✅ | `lease/` |
+| §9.6 | `cost.budget` lease | ✅ | `lease/CostBudget.kt`, `lease/BudgetRegistry.kt` |
+| §9.7 | `model.use` lease | ✅ | `lease/ModelUseLease.kt` |
+| §9.8 | Provisioned credentials | ✅ | `credentials/` |
+| §10 | Cancellation & delegation | ✅ | `messages/Control.kt`, `runtime/ARCPRuntime.kt` |
+| §11 | Observability / metrics | ✅ | `messages/Telemetry.kt`, `trace/TraceContext.kt` |
+| §12 | Error taxonomy | ✅ | `error/ErrorCode.kt`, `error/ARCPException.kt` |
+| §15 | Vendor extensions | ✅ | `extensions/ExtensionRegistry.kt` |
+| §16 | Artifacts | ✅ | `messages/Artifacts.kt` |
+| §17.1 | Distributed tracing (W3C TraceContext) | ✅ | `trace/TraceContext.kt` |
+| §18 | Error codes | ✅ | `error/ErrorCode.kt` |
+| §19 | Session resume | ✅ | `store/EventLog.kt` |
+| §21 | Extension naming (`arcpx.*`) | ✅ | `extensions/ExtensionRegistry.kt` |
+| §22 | Reference transports | ✅ (memory) | `transport/MemoryTransport.kt` |
+| WebSocket transport | — | 🔜 v0.2 | `transport/WebSocketTransport.kt` |
+| Stdio transport | — | 🔜 v0.2 | `transport/StdioTransport.kt` |
+
+## Notable v1.1 additions
+
+- **`session.list_jobs` / `session.jobs`** (§6.6): principal-scoped in-memory
+ inventory with cursor pagination.
+- **Agent versioning** (§7.5): `name@version` parsing, advertised descriptors,
+ and `AGENT_VERSION_NOT_AVAILABLE` error.
+- **`result_chunk`** (§8.4): wire payloads plus client-side chunk assembly.
+- **`cost.budget`** (§9.6): budget parser, counters, subset checks, and
+ `BUDGET_EXHAUSTED` error.
+- **`model.use` and provisioned credentials** (§9.7, §9.8): lease matching,
+ credential wire types, provisioner interface, in-memory implementation,
+ redaction, issue/revoke hooks, and rotation status events.
+- **Error taxonomy** (§12): `BUDGET_EXHAUSTED`, `AGENT_VERSION_NOT_AVAILABLE`,
+ and `LEASE_SUBSET_VIOLATION` are recognized wire codes.
+
+## Conformance testing
+
+Integration tests live in `:tests` and target the public SDK surface over
+`MemoryTransport`. Run with:
+
+```bash
+./gradlew :tests:test
+```
+
+For cross-language conformance tracking, refer to the ARCP spec repository
+and shared issue milestones.
diff --git a/docs/getting-started.md b/docs/getting-started.md
new file mode 100644
index 0000000..0bfe50d
--- /dev/null
+++ b/docs/getting-started.md
@@ -0,0 +1,89 @@
+# Getting started
+
+## Prerequisites
+
+- **JDK 21** or newer ([Adoptium](https://adoptium.net) or Homebrew `openjdk@21`)
+- **Gradle 8.10+** — the wrapper (`./gradlew`) is included; no separate install needed
+
+## Install
+
+Add the library to your Gradle project:
+
+```kotlin
+// build.gradle.kts
+dependencies {
+ implementation("dev.arcp:arcp:1.1.0")
+}
+```
+
+The library requires the Kotlin coroutines and serialization runtimes; those
+are declared as `api` dependencies and are pulled in automatically.
+
+## Minimal example
+
+The snippet below opens a session, submits a job, and closes cleanly.
+It uses `MemoryTransport` — the same transport the integration tests use;
+swap it for `WebSocketTransport` or `StdioTransport` in production.
+
+```kotlin
+import dev.arcp.client.ARCPClient
+import dev.arcp.messages.Capabilities
+import dev.arcp.runtime.ARCPRuntime
+import dev.arcp.runtime.AgentRegistry
+import dev.arcp.transport.MemoryTransport
+import kotlinx.coroutines.runBlocking
+
+fun main() = runBlocking {
+ // 1. Paired in-memory transport (client ↔ runtime).
+ val (clientTransport, runtimeTransport) = MemoryTransport.pair()
+
+ // 2. Runtime with one registered agent.
+ val registry = AgentRegistry()
+ registry.register("summarise", listOf("1.0.0"))
+ val runtime = ARCPRuntime(
+ supportedCapabilities = Capabilities(streaming = true),
+ agentRegistry = registry,
+ )
+
+ // 3. Let the runtime accept the connection in the background.
+ runtime.accept(runtimeTransport)
+
+ // 4. Open a session from the client side.
+ val client = ARCPClient(
+ transport = clientTransport,
+ auth = ARCPClient.bearer("my-token"),
+ client = ARCPClient.defaultClientInfo(),
+ capabilities = Capabilities(streaming = true),
+ )
+ val session = client.open() // returns SessionAccepted
+ println("session: ${session.sessionId}")
+
+ // 5. Submit a job.
+ val jobId = client.send(
+ session.sessionId,
+ dev.arcp.messages.JobSubmit(agent = "summarise@1.0.0"),
+ )
+ println("submitted job: $jobId")
+
+ // 6. Graceful close.
+ client.send(session.sessionId, dev.arcp.messages.SessionClose())
+ runtime.close()
+}
+```
+
+## Build from source
+
+```bash
+git clone https://github.com/agentruntimecontrolprotocol/kotlin-sdk
+cd kotlin-sdk
+./gradlew build # compile, lint, test
+./gradlew :lib:test # unit tests only
+./gradlew :tests:test # integration tests over MemoryTransport
+./gradlew :samples:run01 # run the minimal session sample
+```
+
+## Next steps
+
+- [Architecture](architecture.md) — understand the layering before writing more code
+- [Transports](transports.md) — connect over WebSocket or stdio
+- [Guides](README.md#guides) — deep-dives on sessions, jobs, leases, and more
diff --git a/docs/guides/auth.md b/docs/guides/auth.md
new file mode 100644
index 0000000..34f5af9
--- /dev/null
+++ b/docs/guides/auth.md
@@ -0,0 +1,104 @@
+# Authentication
+
+ARCP supports two bearer-style auth mechanisms: static bearer tokens and
+signed JWTs. Both are enforced during the session handshake (RFC §8.2).
+
+## Static bearer tokens
+
+`StaticBearerAuth` maps a token string to a principal name. Comparison uses
+constant-time equality to resist timing attacks.
+
+```kotlin
+val auth = StaticBearerAuth(mapOf(
+ "token-alice" to "alice",
+ "token-bob" to "bob",
+))
+
+val runtime = ARCPRuntime(
+ supportedCapabilities = Capabilities(),
+ bearerAuth = auth,
+)
+```
+
+The principal name (e.g. `"alice"`) is stored in the session and used for
+job-scoped lease and credential checks. An empty or whitespace token always
+fails; `ARCPException.Unauthenticated` is thrown if the token is not in the
+map.
+
+## JWT authentication
+
+`JwtAuth` validates a signed JWT against an expected audience. Use
+`JwtAuth.hmac()` for HMAC-SHA256 shared-secret tokens:
+
+```kotlin
+val secret = System.getenv("ARCP_JWT_SECRET").toByteArray()
+val jwtAuth = JwtAuth.hmac(secret, audience = "arcp-runtime")
+
+val runtime = ARCPRuntime(
+ supportedCapabilities = Capabilities(),
+ jwtAuth = jwtAuth,
+)
+```
+
+`JwtAuth` validates:
+
+- **Signature** — using the provided `JWSVerifier`
+- **`aud`** — must match `expectedAudience`
+- **`sub`** — must be non-blank (becomes the principal name)
+- **`exp`** — token must not be expired
+- **`nbf`** — token must be active (if present)
+
+Any failure throws `ARCPException.Unauthenticated`.
+
+### Custom JWS verifier
+
+For asymmetric keys (RSA, EC), supply a `JWSVerifier` directly:
+
+```kotlin
+val publicKey: RSAPublicKey = loadPublicKey()
+val verifier = RSASSAVerifier(publicKey)
+val jwtAuth = JwtAuth(verifier, expectedAudience = "arcp-runtime")
+```
+
+## Client-side auth
+
+`ARCPClient` accepts the auth credential via `ARCPClient.bearer(token)`:
+
+```kotlin
+val client = ARCPClient(
+ transport = clientTransport,
+ auth = ARCPClient.bearer("token-alice"),
+ client = ARCPClient.defaultClientInfo(),
+ capabilities = Capabilities(),
+)
+```
+
+During `client.open()` the client responds to any `SessionChallenge` with a
+`SessionAuthenticate` message carrying the token as a `bearer` credential.
+
+## Session challenge flow
+
+If the runtime requires authentication, the handshake is:
+
+```
+client ─── SessionOpen ──────────────────────> runtime
+ <── SessionChallenge ─────────────────
+ ─── SessionAuthenticate (bearer/JWT) ──>
+ <── SessionAccepted ──────────────────
+```
+
+If the runtime accepts anonymous sessions (`Capabilities(anonymous = true)`),
+it may skip the challenge and go straight to `SessionAccepted`.
+
+## Trust levels
+
+The runtime assigns each session a `TrustLevel` based on authentication:
+
+| Level | Description |
+|-------|-------------|
+| `UNTRUSTED` | No valid credentials |
+| `CONSTRAINED` | Authenticated but restricted capabilities |
+| `TRUSTED` | Fully authenticated principal |
+| `PRIVILEGED` | Elevated administrative access |
+
+The trust level is visible in `SessionAccepted.trustLevel`.
diff --git a/docs/guides/delegation.md b/docs/guides/delegation.md
new file mode 100644
index 0000000..eba36fa
--- /dev/null
+++ b/docs/guides/delegation.md
@@ -0,0 +1,104 @@
+# Cancellation & Delegation
+
+## Cancellation
+
+`Cancel` is a *cooperative* cancellation request — the target is asked to
+stop, not killed (RFC §10.4). The runtime may accept or refuse.
+
+```kotlin
+// Cancel a job
+client.send(sessionId, Cancel(
+ target = CancelTarget.JOB,
+ targetId = jobId.value,
+ reason = "user aborted",
+ deadlineMs = 5_000, // give the job 5 s to clean up
+))
+
+// Handle the runtime's reply
+is CancelAccepted -> println("Job ${msg.targetId} will be cancelled")
+is CancelRefused -> println("Refused: ${msg.reason}")
+```
+
+### Cancel targets
+
+| `CancelTarget` | What is cancelled |
+|----------------|------------------|
+| `JOB` | A single job by `jobId` |
+| `STREAM` | An open stream by `streamId` |
+| `SESSION` | The entire session |
+
+## Interrupt
+
+`Interrupt` pauses a job and asks it a question — it is *not* a cancel
+(RFC §10.5):
+
+```kotlin
+client.send(sessionId, Interrupt(
+ target = CancelTarget.JOB,
+ targetId = jobId.value,
+ prompt = "Should I continue with the destructive step?",
+))
+```
+
+The job transitions to `BLOCKED` and waits for the caller's answer. Resume
+with a `JobSubmit` or `Cancel` as appropriate.
+
+## Agent delegation
+
+When a job needs to hand work to another agent it sends `AgentDelegate`
+(RFC §10.3). The child job is automatically constrained to a subset of the
+parent's lease:
+
+```kotlin
+// Inside an agent's execution context:
+session.send(AgentDelegate(
+ agent = AgentRef.parse("classifier@1.0.0"),
+ input = classifyInput,
+ leaseRequest = buildJsonObject { put("cost.budget", buildJsonArray { add("USD:0.50") }) },
+ reason = "delegating classification sub-task",
+))
+```
+
+The runtime creates a child job, enforces the lease subset rule, and fans
+out results back to the parent. `ARCPException.LeaseSubsetViolation` is
+thrown if the child requests a broader budget than the parent's remaining
+balance.
+
+## Agent handoff
+
+`AgentHandoff` terminates the current agent and transfers execution to
+another:
+
+```kotlin
+session.send(AgentHandoff(
+ agent = AgentRef.parse("writer@1.0.0"),
+ input = writerInput,
+ reason = "planning complete; handing off to writer",
+))
+```
+
+Unlike delegation, handoff does not create a child — the current job ends
+and a new one begins.
+
+## Ping / Pong — liveness
+
+Use `Ping`/`Pong` to check whether the remote end is still alive:
+
+```kotlin
+client.send(sessionId, Ping(nonce = "hello"))
+is Pong -> println("Alive! nonce=${msg.nonce}")
+```
+
+## Ack / Nack
+
+Every command receives either an `Ack` (success) or a `Nack` (failure):
+
+```kotlin
+is Ack -> println("Command ${msg.ackFor} succeeded")
+is Nack -> {
+ println("Command ${msg.nackFor} failed: ${msg.code} — ${msg.message}")
+ if (msg.retryable == true) retry()
+}
+```
+
+`Nack.retryable` overrides the `ErrorCode.retryableByDefault` flag when set.
diff --git a/docs/guides/errors.md b/docs/guides/errors.md
new file mode 100644
index 0000000..2235315
--- /dev/null
+++ b/docs/guides/errors.md
@@ -0,0 +1,134 @@
+# Error Handling
+
+ARCP errors map 1:1 to typed `ARCPException` subclasses. Catch the specific
+subclass to access structured fields.
+
+## Catching typed exceptions
+
+```kotlin
+try {
+ client.open()
+} catch (e: ARCPException.Unauthenticated) {
+ println("Auth failed: ${e.message}")
+} catch (e: ARCPException.BudgetExhausted) {
+ println("Budget exhausted: ${e.currency} on job ${e.jobId}")
+} catch (e: ARCPException.DeadlineExceeded) {
+ if (e.retryable) retry()
+}
+```
+
+## Retryable exceptions
+
+Check `ARCPException.retryable` (or `ErrorCode.retryableByDefault`) before
+retrying:
+
+```kotlin
+fun withRetry(block: () -> T): T {
+ repeat(3) { attempt ->
+ try {
+ return block()
+ } catch (e: ARCPException) {
+ if (!e.retryable || attempt == 2) throw e
+ delay(100L * (attempt + 1))
+ }
+ }
+ error("unreachable")
+}
+```
+
+Retryable codes by default: `DEADLINE_EXCEEDED`, `RESOURCE_EXHAUSTED`,
+`ABORTED`, `INTERNAL`, `UNAVAILABLE`, `HEARTBEAT_LOST`,
+`BACKPRESSURE_OVERFLOW`.
+
+**Note**: `ARCPException.BudgetExhausted` overrides `retryable = false`
+regardless of the wire code's default.
+
+## Nack — wire-level failures
+
+Operations that do not throw may instead return a `Nack` envelope:
+
+```kotlin
+is Nack -> {
+ val code = ErrorCode.fromWire(msg.code.wire)
+ println("Failed: $code — ${msg.message}")
+ if (msg.retryable == true) {
+ // Nack.retryable overrides the code default when non-null
+ retry()
+ }
+}
+```
+
+`ErrorCode.fromWire("RATE_LIMITED")` maps the alias to `RESOURCE_EXHAUSTED`.
+
+## Exception taxonomy
+
+| Exception | Code | Retryable | Notes |
+|-----------|------|-----------|-------|
+| `ARCPException.Cancelled` | `CANCELLED` | No | Client cancelled |
+| `ARCPException.Unknown` | `UNKNOWN` | No | Unexpected server error |
+| `ARCPException.InvalidArgument` | `INVALID_ARGUMENT` | No | Malformed request |
+| `ARCPException.DeadlineExceeded` | `DEADLINE_EXCEEDED` | Yes | Timed out |
+| `ARCPException.NotFound` | `NOT_FOUND` | No | Resource absent |
+| `ARCPException.AlreadyExists` | `ALREADY_EXISTS` | No | Duplicate `message_id` |
+| `ARCPException.PermissionDenied` | `PERMISSION_DENIED` | No | Missing lease |
+| `ARCPException.ResourceExhausted` | `RESOURCE_EXHAUSTED` | Yes | Rate-limit; alias `RATE_LIMITED` |
+| `ARCPException.FailedPrecondition` | `FAILED_PRECONDITION` | No | Invalid state |
+| `ARCPException.Aborted` | `ABORTED` | Yes | Concurrency conflict |
+| `ARCPException.OutOfRange` | `OUT_OF_RANGE` | No | Value out of bounds |
+| `ARCPException.Unimplemented` | `UNIMPLEMENTED` | No | Feature not available |
+| `ARCPException.Internal` | `INTERNAL` | Yes | SDK bug; please report |
+| `ARCPException.Unavailable` | `UNAVAILABLE` | Yes | Runtime temporarily down |
+| `ARCPException.DataLoss` | `DATA_LOSS` | No | Message missing from log |
+| `ARCPException.Unauthenticated` | `UNAUTHENTICATED` | No | Bad/missing token |
+| `ARCPException.HeartbeatLost` | `HEARTBEAT_LOST` | Yes | Missed heartbeat deadlines |
+| `ARCPException.LeaseExpired` | `LEASE_EXPIRED` | No | Lease TTL elapsed |
+| `ARCPException.LeaseRevoked` | `LEASE_REVOKED` | No | Grantor revoked |
+| `ARCPException.LeaseSubsetViolation` | `LEASE_SUBSET_VIOLATION` | No | Child exceeds parent |
+| `ARCPException.BudgetExhausted` | `BUDGET_EXHAUSTED` | **No** | Budget cap reached |
+| `ARCPException.AgentVersionNotAvailable` | `AGENT_VERSION_NOT_AVAILABLE` | No | Agent not registered |
+| `ARCPException.BackpressureOverflow` | `BACKPRESSURE_OVERFLOW` | Yes | Channel full |
+
+## Typed exception fields
+
+### `ARCPException.BudgetExhausted`
+
+```kotlin
+} catch (e: ARCPException.BudgetExhausted) {
+ logger.warn { "Job ${e.jobId} exceeded ${e.currency} budget" }
+}
+```
+
+### `ARCPException.HeartbeatLost`
+
+```kotlin
+} catch (e: ARCPException.HeartbeatLost) {
+ logger.error { "Job missed ${e.missedDeadlines} consecutive heartbeat deadlines" }
+}
+```
+
+### `ARCPException.Unimplemented`
+
+```kotlin
+} catch (e: ARCPException.Unimplemented) {
+ // e.message == "unimplemented (RFC §${e.section}): ${e.detail}"
+ logger.warn { e.message }
+}
+```
+
+### `ARCPException.AgentVersionNotAvailable`
+
+```kotlin
+} catch (e: ARCPException.AgentVersionNotAvailable) {
+ logger.error { "Agent ${e.agent}@${e.version} is not registered" }
+}
+```
+
+## Translating wire codes
+
+```kotlin
+val code: ErrorCode = ErrorCode.fromWire("RATE_LIMITED")
+// → ErrorCode.RESOURCE_EXHAUSTED (alias mapping)
+
+println(code.wire) // "RESOURCE_EXHAUSTED"
+println(code.retryableByDefault) // true
+```
diff --git a/docs/guides/job-events.md b/docs/guides/job-events.md
new file mode 100644
index 0000000..a1a3d62
--- /dev/null
+++ b/docs/guides/job-events.md
@@ -0,0 +1,130 @@
+# Job Events
+
+Between `JobStarted` and the terminal event, a job may emit any number of
+progress, heartbeat, chunk, and status events.
+
+## JobStarted
+
+Sent when the agent begins executing:
+
+```kotlin
+is JobStarted -> println("Job ${msg.jobId} is now running")
+```
+
+## JobProgress
+
+Human-readable progress indication, useful for display:
+
+```kotlin
+is JobProgress -> println("[${msg.percent?.let { "$it%" } ?: "…"}] ${msg.message}")
+```
+
+`JobProgress` fields:
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `message` | `String` | Display text |
+| `percent` | `Int?` | 0–100, optional |
+| `data` | `JsonObject` | Structured progress data |
+
+## JobHeartbeat
+
+Agents send `JobHeartbeat` on the cadence negotiated in
+`Capabilities.heartbeatIntervalSeconds` (default 30 s). The runtime marks
+a job as dead if it misses consecutive beats.
+
+```kotlin
+is JobHeartbeat -> {
+ println("Heartbeat seq ${msg.sequence}, state=${msg.state}, deadline=${msg.deadlineMs}ms")
+}
+```
+
+`JobHeartbeat` fields:
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `sequence` | `Long` | Monotonically increasing beat number |
+| `deadlineMs` | `Long` | Milliseconds until the next expected beat |
+| `state` | `JobLifecycleState` | Current job state |
+
+If the runtime detects missed beats it emits `ARCPException.HeartbeatLost`
+with a `missedDeadlines` count. Set `Capabilities.heartbeatRecovery =
+HeartbeatRecovery.BLOCK` to park the job rather than kill it.
+
+## JobStatusEvent
+
+General-purpose structured status update:
+
+```kotlin
+is JobStatusEvent -> println("Status: ${msg.status} phase=${msg.phase}")
+```
+
+## JobResultChunk — streaming results (RFC §8.4)
+
+For large outputs the agent streams result fragments. Each chunk carries a
+sequence number and a `more` flag:
+
+```kotlin
+val buffer = StringBuilder()
+is JobResultChunk -> {
+ buffer.append(msg.data)
+ if (!msg.more) println("Full result: $buffer")
+}
+```
+
+`JobResultChunk` fields:
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `resultId` | `String` | Groups chunks for the same result |
+| `chunkSeq` | `Long` | Zero-based sequence within `resultId` |
+| `data` | `String` | Payload fragment |
+| `encoding` | `ResultChunkEncoding` | `UTF8` or `BASE64` |
+| `more` | `Boolean` | `false` on the last chunk |
+
+Enable streaming by advertising `Capabilities(streaming = true)` on both
+sides.
+
+### ResultChunkEncoding
+
+| Value | Meaning |
+|-------|---------|
+| `UTF8` | `data` is plain UTF-8 text |
+| `BASE64` | `data` is Base64-encoded binary |
+
+## General streams (RFC §11)
+
+For open-ended event, log, thought, or binary streams use the `stream.*`
+envelope family:
+
+```kotlin
+is StreamOpen -> println("Stream ${env.id} opened, kind=${msg.kind}")
+is StreamChunk -> processChunk(msg.sequence, msg.data)
+is StreamClose -> println("Stream closed after ${msg.totalChunks} chunks")
+is StreamError -> throw RuntimeException("Stream error: ${msg.code}")
+```
+
+Use `Backpressure` to ask the sender to slow down:
+
+```kotlin
+client.send(sessionId, Backpressure(
+ streamId = streamId,
+ desiredRatePerSecond = 10,
+))
+```
+
+## JobCompleted / JobFailed / JobCancelled
+
+Terminal events end the job. Handle all three:
+
+```kotlin
+is JobCompleted -> {
+ println("Completed in ${msg.runtimeMs}ms: ${msg.result}")
+}
+is JobFailed -> {
+ println("Failed: code=${msg.error.code} ${msg.error.message}")
+}
+is JobCancelled -> {
+ println("Cancelled: ${msg.reason}")
+}
+```
diff --git a/docs/guides/jobs.md b/docs/guides/jobs.md
new file mode 100644
index 0000000..e5f1022
--- /dev/null
+++ b/docs/guides/jobs.md
@@ -0,0 +1,114 @@
+# Jobs
+
+A *job* is a discrete unit of work submitted to a registered agent. Jobs
+progress through a well-defined lifecycle and produce a terminal result.
+
+## Lifecycle
+
+```
+JobSubmit ──> JobAccepted ──> JobStarted ──> JobCompleted
+ └──> JobFailed
+ └──> JobCancelled
+```
+
+Intermediate events (`JobProgress`, `JobHeartbeat`, `JobStatusEvent`,
+`JobResultChunk`) may arrive between `JobStarted` and the terminal event.
+See [job-events.md](job-events.md) for details.
+
+## Submitting a job
+
+```kotlin
+val msgId: MessageId = client.send(session.sessionId, JobSubmit(
+ agent = AgentRef.parse("summarise@1.0.0"),
+ input = buildJsonObject { put("text", "...") },
+))
+```
+
+`JobSubmit` fields:
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `agent` | `AgentRef` | `name` or `name@version` |
+| `input` | `JsonElement` | Agent-specific payload |
+| `leaseRequest` | `JsonObject?` | Requested capabilities (e.g. `cost.budget`) |
+| `leaseConstraints` | `JsonObject?` | Client-imposed constraints on sub-jobs |
+| `idempotencyKey` | `String?` | Deduplicate resubmissions |
+| `maxRuntimeSec` | `Long?` | Hard timeout in seconds |
+
+## JobAccepted
+
+The runtime immediately replies with `JobAccepted`, carrying the assigned
+`jobId` and the negotiated `leaseId`:
+
+```kotlin
+// Receive loop (illustrative — real code uses Flow)
+val accepted: JobAccepted = awaitMessage(msgId)
+val jobId = accepted.jobId
+```
+
+If the agent or version is not registered the runtime replies with `Nack`
+carrying `ErrorCode.AGENT_VERSION_NOT_AVAILABLE`.
+
+## Registering agents
+
+Agents must be registered before the runtime starts accepting connections:
+
+```kotlin
+val registry = AgentRegistry()
+registry.register("summarise", listOf("1.0.0", "2.0.0"))
+
+val runtime = ARCPRuntime(
+ supportedCapabilities = Capabilities(),
+ agentRegistry = registry,
+)
+```
+
+`AgentRef.parse("summarise@1.0.0")` parses the `name@version` wire form.
+`AgentRef.parse("summarise")` references the agent without pinning a version;
+the runtime selects the default.
+
+## Awaiting completion
+
+```kotlin
+// Pseudocode — collect from the session's envelope flow
+session.envelopes
+ .filter { it.jobId == jobId }
+ .collect { env ->
+ when (val msg = env.payload) {
+ is JobCompleted -> { println("result: ${msg.result}"); cancel() }
+ is JobFailed -> { throw RuntimeException(msg.error.message) }
+ is JobCancelled -> { println("cancelled: ${msg.reason}") }
+ else -> { /* progress / heartbeat / chunk */ }
+ }
+ }
+```
+
+## Idempotency
+
+Pass an `idempotencyKey` to ensure at-most-once dispatch:
+
+```kotlin
+client.send(session.sessionId, JobSubmit(
+ agent = AgentRef.parse("summarise"),
+ input = input,
+ idempotencyKey = "req-${requestId}",
+))
+```
+
+If the runtime has already processed a `JobSubmit` with the same key in the
+same session, it returns the stored `JobAccepted` without re-running the job.
+
+## Job lifecycle states
+
+`JobLifecycleState` is carried in `JobHeartbeat` and `JobStatusEvent`:
+
+| State | Meaning |
+|-------|---------|
+| `ACCEPTED` | Runtime accepted the submit |
+| `QUEUED` | Waiting for an executor slot |
+| `RUNNING` | Agent is actively executing |
+| `BLOCKED` | Waiting for a resource (lease, tool reply) |
+| `PAUSED` | Interrupted; waiting for resume |
+| `COMPLETED` | Terminal success |
+| `FAILED` | Terminal failure |
+| `CANCELLED` | Terminated by client cancel request |
diff --git a/docs/guides/leases.md b/docs/guides/leases.md
index e2812e0..756d2f8 100644
--- a/docs/guides/leases.md
+++ b/docs/guides/leases.md
@@ -1,44 +1,154 @@
-# Leases
+# Leases & Budgets
-ARCP v1.1 adds runtime-enforced lease capabilities that can be used directly by the Kotlin SDK or delegated to an upstream provider through provisioned credentials.
+Leases are runtime-enforced capability grants. They limit what a job may
+do — which models it may call, how much it may spend, which tools it may
+invoke — and can be delegated to sub-jobs as equal or narrower subsets
+(RFC §9).
-## `cost.budget`
+## Permission request / grant flow
-`cost.budget` values use the wire form `currency:decimal`, for example `USD:5.00` or `credits:100`. The SDK parses these into `BudgetAmount` values and tracks them per job with `BudgetCounter`.
+```
+runtime ─── PermissionRequest ──> client
+ <── PermissionGrant ───── (or PermissionDeny)
+ ─── LeaseGranted ──────> client
+```
+
+```kotlin
+is PermissionRequest -> {
+ println("Runtime requests ${msg.permission} on ${msg.resource}")
+ // Approve:
+ client.send(sessionId, PermissionGrant(
+ permission = msg.permission,
+ resource = msg.resource,
+ leaseSeconds = 300,
+ ))
+ // Or deny:
+ // client.send(sessionId, PermissionDeny(msg.permission, msg.resource, "not allowed"))
+}
+
+is LeaseGranted -> {
+ println("Lease ${msg.leaseId} granted, expires ${msg.expiresAt}")
+}
+```
+
+## Lease refresh
+
+A job can extend its lease before it expires:
+
+```kotlin
+client.send(sessionId, LeaseRefresh(
+ leaseId = leaseId,
+ requestedExtensionSeconds = 120,
+))
+
+is LeaseExtended -> println("Lease extended to ${msg.expiresAt}")
+```
+
+If the grantor revokes the lease before expiry:
+
+```kotlin
+is LeaseRevoked -> throw ARCPException.LeaseRevoked("Lease ${msg.leaseId}: ${msg.reason}")
+```
+
+## cost.budget
+
+`cost.budget` values use the wire form `currency:decimal` (e.g. `USD:5.00`,
+`credits:100`).
+
+```kotlin
+val budget = CostBudget(
+ budgets = listOf(BudgetAmount.parse("USD:10.00")),
+)
+```
+
+Include the budget in the job's `leaseRequest`:
-When a cost metric such as `cost.inference` arrives on a job envelope, the runtime decrements the matching currency counter. Once the remaining value reaches zero, the runtime reports `BUDGET_EXHAUSTED` with `retryable = false`.
+```kotlin
+client.send(sessionId, JobSubmit(
+ agent = AgentRef.parse("summarise@1.0.0"),
+ input = input,
+ leaseRequest = buildJsonObject {
+ put("cost.budget", buildJsonArray { add("USD:5.00") })
+ },
+))
+```
+
+The runtime tracks spending per job with `BudgetRegistry`. When the counter
+reaches zero it emits `ARCPException.BudgetExhausted`:
+
+```kotlin
+} catch (e: ARCPException.BudgetExhausted) {
+ logger.warn { "Job ${e.jobId} exceeded ${e.currency} budget" }
+}
+```
+
+### Delegation subset rule
+
+A child job may only request a budget ≤ the parent's *remaining* balance:
+
+```
+parent budget: USD:5.00 (spent: USD:3.00, remaining: USD:2.00)
+child request: USD:1.50 ✅
+child request: USD:2.50 ❌ LEASE_SUBSET_VIOLATION
+```
-Child jobs may only request budgets that are less than or equal to the parent's remaining budget, and they may not introduce a new currency.
+The runtime enforces this automatically; `ARCPException.LeaseSubsetViolation`
+is thrown if the child's request exceeds the parent's remaining budget.
-## `model.use`
+## model.use
-`model.use` constrains which model identifiers a job may use. Patterns are segment-aware globs:
+`model.use` limits which model IDs a job may call. Patterns are
+segment-aware globs:
-- `tier-fast/*` matches `tier-fast/haiku` but not `tier-slow/haiku`.
-- `provider/**` matches all models below `provider/`.
+| Pattern | Matches | Does not match |
+|---------|---------|----------------|
+| `tier-fast/*` | `tier-fast/haiku` | `tier-slow/haiku` |
+| `provider/**` | `provider/v1/chat` | `other/v1/chat` |
+| `**` | anything | — |
-Delegated jobs must request a subset of the parent lease. A literal model such as `tier-fast/haiku` is a subset of `tier-fast/*`; broadening from a literal to `tier-fast/*` is rejected.
+```kotlin
+val lease = ModelUseLease(patterns = listOf("anthropic/*"))
+lease.allows("anthropic/claude-3") // true
+lease.allows("openai/gpt-4o") // false
+```
+
+Subset check:
+
+```kotlin
+ModelUseLease.subset(
+ parent = ModelUseLease(listOf("tier-fast/**")),
+ child = ModelUseLease(listOf("tier-fast/haiku")),
+) // true — literal is subset of glob
+```
+
+## Provisioned credentials
-## Provisioned Credentials
+When a `CredentialProvisioner` is configured, the runtime issues per-job
+credentials after lease finalization. They arrive in `JobAccepted.credentials`
+and are redacted in logs:
-When a `CredentialProvisioner` is configured, the runtime issues credentials after job lease finalization and attaches them to `job.accepted.credentials`. Credentials use this vendor-neutral shape:
+```kotlin
+is JobAccepted -> {
+ val cred = accepted.credentials
+ // cred.value is the actual secret — Credential.toString() redacts it
+}
+```
+
+Credential shape:
```json
{
"id": "cred_...",
"scheme": "bearer",
- "value": "secret",
+ "value": "...",
"endpoint": "https://provider.example/v1",
"constraints": {
"cost.budget": ["USD:1.00"],
- "model.use": ["tier-fast/*"],
- "expires_at": "2026-05-09T13:00:00Z"
+ "model.use": ["tier-fast/*"],
+ "expires_at": "2026-05-09T13:00:00Z"
}
}
```
-The `value` field is treated as a secret. `Credential.toString()` redacts it, and job introspection should only expose credentials to the submitting principal.
-
-On terminal job states (`completed`, `failed`, `cancelled`, or timeout), the runtime revokes outstanding credentials with retry. `CredentialStore.pendingRevocations()` is the durability hook used to retry revocation after restart.
-
-Credential rotation is exposed through `ARCPRuntime.rotateCredential(...)`. It issues a replacement, revokes the prior credential, and can emit a `status` event with `phase = "credential_rotated"`.
+Credentials are automatically revoked on job termination. Use
+`ARCPRuntime.rotateCredential(jobId)` to rotate mid-job.
diff --git a/docs/guides/observability.md b/docs/guides/observability.md
new file mode 100644
index 0000000..62893f7
--- /dev/null
+++ b/docs/guides/observability.md
@@ -0,0 +1,128 @@
+# Observability
+
+ARCP provides structured logging, metrics, traces, and generic events as
+first-class protocol messages (RFC §§11, 17).
+
+## Distributed tracing — W3C TraceContext
+
+`TraceContext` propagates W3C trace context through coroutines as a
+`CoroutineContext` element (RFC §17.1):
+
+```kotlin
+// Start a new root trace
+withContext(TraceContext.newRoot()) {
+ withSpan("session-open") {
+ val session = client.open()
+
+ withSpan("submit-job") {
+ client.send(session.sessionId, JobSubmit(...))
+ }
+ }
+}
+```
+
+### Accessing the current trace
+
+```kotlin
+val trace: TraceContext? = currentTrace()
+println("trace=${trace?.traceId} span=${trace?.spanId} parent=${trace?.parentSpanId}")
+```
+
+### `withSpan`
+
+`withSpan(name, block)` creates a child span that inherits `traceId` from
+the ambient context and generates a fresh `spanId`:
+
+```kotlin
+withSpan("classify") {
+ // currentTrace()?.traceId == parent trace ID
+ // currentTrace()?.parentSpanId == parent span ID
+ doWork()
+}
+```
+
+### Wire representation
+
+`TraceSpan` is the wire message sent when emitting a completed span:
+
+```kotlin
+client.send(sessionId, TraceSpan(
+ name = "classify",
+ kind = "CLIENT",
+ startedAt = start,
+ endedAt = Instant.now(),
+ attributes = buildJsonObject { put("model", "claude-3") },
+))
+```
+
+## Structured logging
+
+```kotlin
+client.send(sessionId, Log(
+ level = LogLevel.INFO,
+ message = "Job started",
+ attributes = buildJsonObject { put("job_id", jobId.value) },
+))
+```
+
+`LogLevel` values (in order of severity): `TRACE`, `DEBUG`, `INFO`, `WARN`,
+`ERROR`, `CRITICAL`.
+
+## Metrics
+
+```kotlin
+client.send(sessionId, Metric(
+ name = StandardMetrics.TOKENS_USED,
+ value = JsonPrimitive(1234),
+ unit = "tokens",
+ dims = buildJsonObject { put("kind", "output") },
+))
+```
+
+### Standard metric names (RFC §17.3.1)
+
+| Constant | Wire name | Unit |
+|----------|-----------|------|
+| `TOKENS_USED` | `tokens.used` | `tokens`; `dims.kind ∈ input,output,cache_read,cache_write` |
+| `COST_USD` | `cost.usd` | `USD` (decimal, ≤ 6 fractional digits) |
+| `COST_BUDGET_REMAINING` | `cost.budget.remaining` | per-currency budget |
+| `GPU_SECONDS` | `gpu.seconds` | `s` |
+| `TOOL_INVOCATIONS` | `tool.invocations` | count |
+| `LATENCY_MS` | `latency.ms` | `ms`; `dims.phase ∈ queue,exec,total` |
+| `BYTES_IN` | `bytes.in` | `bytes` |
+| `BYTES_OUT` | `bytes.out` | `bytes` |
+| `ERRORS_TOTAL` | `errors.total` | count; `dims.code` = canonical error code |
+
+Non-standard metrics must be namespaced (e.g. `acme.model.cache_hits`).
+
+## Generic events
+
+`EventEmit` carries arbitrary structured events:
+
+```kotlin
+client.send(sessionId, EventEmit(
+ eventType = "x-vendor.acme.email.parsed",
+ data = buildJsonObject {
+ put("subject", "Re: Q3 budget")
+ put("from", "alice@example.com")
+ put("thread_id", "t_abc123")
+ },
+))
+```
+
+Event types must match the `arcpx.*` naming convention if they are
+vendor-defined (see [vendor-extensions.md](vendor-extensions.md)).
+
+## Backpressure
+
+When consuming a stream that delivers faster than the receiver can process,
+send `Backpressure` to request a slower rate:
+
+```kotlin
+client.send(sessionId, Backpressure(
+ streamId = streamId,
+ desiredRatePerSecond = 5,
+ bufferRemainingBytes = 1024,
+ reason = "downstream slow",
+))
+```
diff --git a/docs/guides/resume.md b/docs/guides/resume.md
new file mode 100644
index 0000000..f80d5fe
--- /dev/null
+++ b/docs/guides/resume.md
@@ -0,0 +1,117 @@
+# Session Resume
+
+ARCP supports resuming a session after a transport disconnect without
+re-running jobs. The `EventLog` records every envelope so the runtime can
+replay what the client missed (RFC §§6.3, 6.4, 19).
+
+## EventLog
+
+`EventLog` is an append-only SQLite-backed event store. Two factory
+functions create instances:
+
+```kotlin
+// In-memory (tests and samples)
+val log = EventLog.openInMemory()
+
+// Persistent file
+val log = EventLog.openFile(Path("sessions.db"))
+```
+
+### Appending
+
+```kotlin
+val rowId: Long = log.append(envelope)
+```
+
+`append` throws `ARCPException.AlreadyExists` if an envelope with the same
+`message_id` is already in the log — enforcing idempotency automatically.
+
+### Replaying
+
+```kotlin
+val envelopes: Flow = log.replay(
+ sessionId = sessionId,
+ afterMessageId = lastReceivedMessageId, // null → replay from start
+)
+envelopes.collect { env -> /* deliver to client */ }
+```
+
+`replay` runs on `Dispatchers.IO` via JDBC; the returned `Flow` is cold and
+completes when all matching rows have been emitted.
+
+If `afterMessageId` is not found in the log, `EventLog.replay` throws
+`ARCPException.DataLoss`. Always pass a `MessageId` that was actually
+received, or `null` to start from the beginning.
+
+### Idempotent operations
+
+`EventLog` also supports idempotency keys for non-envelope operations:
+
+```kotlin
+val existing: String? = log.lookupIdempotent(idempotencyKey)
+if (existing == null) {
+ log.recordIdempotent(idempotencyKey, resultJson)
+}
+```
+
+## Resume message
+
+The client sends a `Resume` message to replay past the last received
+envelope:
+
+```kotlin
+client.send(sessionId, Resume(
+ sessionId = sessionId,
+ afterMessageId = lastMessageId,
+ jobId = jobId, // optional: scope replay to one job
+ includeOpenStreams = true, // re-open any streams still active
+))
+```
+
+| Field | Purpose |
+|-------|---------|
+| `sessionId` | Which session to resume |
+| `afterMessageId` | Only replay envelopes after this ID |
+| `jobId` | Narrow replay to a specific job (optional) |
+| `checkpointId` | Resume from a named checkpoint (optional) |
+| `includeOpenStreams` | Re-deliver open stream frames (optional) |
+
+## Full resume pattern
+
+```kotlin
+// 1. Persist the last message ID seen
+var lastSeen: MessageId? = null
+session.envelopes.collect { env ->
+ lastSeen = env.id
+ process(env)
+}
+
+// 2. Later, reconnect and replay
+val newClient = ARCPClient(transport = newTransport, auth = bearer, client = info, capabilities = caps)
+val newSession = newClient.open()
+
+if (lastSeen != null) {
+ newClient.send(newSession.sessionId, Resume(
+ sessionId = originalSessionId,
+ afterMessageId = lastSeen,
+ ))
+}
+```
+
+## Checkpoints
+
+A job can be asked to save a checkpoint at its next safe point:
+
+```kotlin
+client.send(sessionId, CheckpointCreate(jobId = jobId, label = "before-step-3"))
+// The job emits a JobCheckpoint envelope when ready
+```
+
+To restore:
+
+```kotlin
+client.send(sessionId, CheckpointRestore(
+ jobId = jobId,
+ checkpointId = "ckpt_abc123",
+))
+```
diff --git a/docs/guides/sessions.md b/docs/guides/sessions.md
new file mode 100644
index 0000000..ae53193
--- /dev/null
+++ b/docs/guides/sessions.md
@@ -0,0 +1,113 @@
+# Sessions
+
+A *session* is the top-level authenticated context between a client and a
+runtime. All jobs, leases, and subscriptions are scoped to a session.
+
+## Session lifecycle
+
+```
+client ──────────────────────────────────────── runtime
+ │ │
+ │── SessionOpen ────────────────────────────> │ (1) open
+ │<─ SessionChallenge ───────────────────────── │ (2) auth challenge (optional)
+ │── SessionAuthenticate ───────────────────> │ (3) bearer / JWT
+ │<─ SessionAccepted ────────────────────────── │ (4) negotiated capabilities
+ │ … session active … │
+ │── SessionClose ──────────────────────────> │ (5) graceful close
+```
+
+If the runtime is configured with `StaticBearerAuth` or `JwtAuth`, the
+challenge/authenticate round-trip occurs; otherwise the runtime may skip
+directly to `SessionAccepted`.
+
+## Opening a session
+
+```kotlin
+val (clientTransport, serverTransport) = MemoryTransport.pair()
+
+val runtime = ARCPRuntime(
+ supportedCapabilities = Capabilities(streaming = true, durableJobs = true),
+ bearerAuth = StaticBearerAuth(mapOf("my-token" to "alice")),
+ agentRegistry = AgentRegistry().also { it.register("summarise", listOf("1.0.0")) },
+)
+runtime.accept(serverTransport) // launches coroutine; non-blocking
+
+val client = ARCPClient(
+ transport = clientTransport,
+ auth = ARCPClient.bearer("my-token"),
+ client = ARCPClient.defaultClientInfo(),
+ capabilities = Capabilities(streaming = true),
+)
+
+val session: SessionAccepted = client.open()
+println("Session ${session.sessionId} negotiated")
+```
+
+`client.open()` returns only after `SessionAccepted` is received; it throws
+`ARCPException.Unauthenticated` if the token is rejected or
+`ARCPException.FailedPrecondition` if the runtime is not ready.
+
+## Capability negotiation
+
+`SessionAccepted.capabilities` contains the *intersection* of what the
+client advertised and what the runtime supports. Inspect it before using
+optional features:
+
+```kotlin
+val caps = session.capabilities
+if (caps.streaming) {
+ // safe to submit jobs that use result_chunk streaming
+}
+if (caps.durableJobs) {
+ // safe to use EventLog-backed resume
+}
+```
+
+### Advertised capabilities
+
+The full set of `Capabilities` fields:
+
+| Field | Default | Purpose |
+|-------|---------|---------|
+| `streaming` | `false` | `result_chunk` streaming (RFC §8.4) |
+| `durableJobs` | `false` | durable event log / resume |
+| `checkpoints` | `false` | mid-job checkpoint / restore |
+| `binaryStreams` | `false` | binary stream frames |
+| `agentHandoff` | `false` | agent delegation |
+| `artifacts` | `false` | file artifact transfer |
+| `subscriptions` | `false` | push subscriptions |
+| `scheduledJobs` | `false` | future-scheduled job dispatch |
+| `provisionedCredentials` | `false` | per-job credential issue |
+| `modelUse` | `false` | `model.use` lease enforcement |
+| `anonymous` | `false` | unauthenticated clients allowed |
+| `interrupt` | `true` | cooperative interrupt signal |
+| `heartbeatIntervalSeconds` | `30` | expected heartbeat cadence |
+| `heartbeatRecovery` | `FAIL` | `FAIL` or `BLOCK` on missed beats |
+| `binaryEncoding` | `false` | binary-encoded envelopes |
+| `extensions` | `[]` | vendor extension names (`arcpx.*`) |
+| `agents` | `[]` | available agent descriptors |
+
+## Listing jobs
+
+Use `session.list_jobs` to enumerate active jobs in the session:
+
+```kotlin
+client.send(session.sessionId, SessionListJobs(
+ filter = JobListFilter(state = listOf(JobLifecycleState.RUNNING)),
+ cursor = null,
+ limit = 20,
+))
+// The runtime replies with a `session.jobs` envelope
+```
+
+## Closing a session
+
+Send `SessionClose` to perform a graceful shutdown; the runtime drains
+in-flight jobs before tearing down:
+
+```kotlin
+client.send(session.sessionId, SessionClose(reason = "done"))
+```
+
+The transport's `close()` is called automatically by `ARCPClient` after the
+close handshake completes.
diff --git a/docs/guides/vendor-extensions.md b/docs/guides/vendor-extensions.md
new file mode 100644
index 0000000..9551f3a
--- /dev/null
+++ b/docs/guides/vendor-extensions.md
@@ -0,0 +1,107 @@
+# Vendor Extensions
+
+ARCP reserves the `arcpx.*` namespace for vendor-defined message types and
+event names (RFC §§15, 21). Extensions let runtime operators add proprietary
+messages without forking the protocol.
+
+## Naming convention
+
+Extension names must match one of two patterns:
+
+```
+arcpx...v
+com.example.feature.v1 (reverse-DNS form)
+```
+
+Examples:
+- `arcpx.acme.email.v1`
+- `arcpx.anthropic.reasoning.v2`
+- `com.mycompany.billing.v1`
+
+Names that do not match these patterns are rejected by `ExtensionRegistry`.
+
+## ExtensionRegistry
+
+```kotlin
+val extensions = ExtensionRegistry()
+extensions.advertise("arcpx.acme.email.v1")
+extensions.advertise("arcpx.acme.billing.v1")
+```
+
+Advertise extensions in the runtime's `Capabilities`:
+
+```kotlin
+val capabilities = Capabilities(
+ extensions = listOf("arcpx.acme.email.v1", "arcpx.acme.billing.v1"),
+)
+val runtime = ARCPRuntime(supportedCapabilities = capabilities, ...)
+```
+
+Both sides must advertise an extension for it to be considered active. The
+`SessionAccepted.capabilities.extensions` list contains the negotiated
+intersection.
+
+## Handling unknown message types
+
+When a message arrives with an unrecognised `type` field, the runtime asks
+`ExtensionRegistry.classifyUnknown()` what to do:
+
+```kotlin
+when (extensions.classifyUnknown(wireType, optional, advertisedExtensions)) {
+ UnknownAction.Drop -> { /* silently ignore */ }
+ UnknownAction.Nack -> { /* send Nack with UNIMPLEMENTED */ }
+}
+```
+
+An unknown type is `Drop`ped if:
+- its namespace matches a locally-advertised extension (the peer may have
+ added a new message within the extension), or
+- the sender marked the message as optional.
+
+Otherwise the runtime `Nack`s the message with `ErrorCode.UNIMPLEMENTED`.
+
+## Checking acceptance
+
+```kotlin
+extensions.acceptsType("arcpx.acme.email.v1") // true — advertised
+extensions.acceptsType("arcpx.acme.weather.v1") // false — not advertised
+```
+
+## Emitting extension events
+
+Use `EventEmit` with a namespaced `eventType`:
+
+```kotlin
+client.send(sessionId, EventEmit(
+ eventType = "arcpx.acme.email.v1.parsed",
+ data = buildJsonObject {
+ put("subject", "Q3 report")
+ put("sender", "alice@acme.com")
+ put("thread", "t_xyz")
+ },
+))
+```
+
+The wire `type` for `EventEmit` is always `event.emit`; the vendor namespace
+lives in the `event_type` payload field, not the envelope `type` discriminator.
+
+## Custom wire message types (advanced)
+
+To define a fully custom wire type that participates in polymorphic
+deserialization, register a `@SerialName` subclass of `MessageType` and
+configure `arcpJson`:
+
+```kotlin
+@Serializable
+@SerialName("arcpx.acme.email.v1.send")
+data class AcmeEmailSend(
+ val to: String,
+ val subject: String,
+ val body: String,
+) : MessageType
+
+// Then extend arcpJson with a module that includes AcmeEmailSend
+```
+
+This is an advanced integration point; for most use cases `EventEmit` is
+sufficient.
diff --git a/docs/modules/arcp-cli.md b/docs/modules/arcp-cli.md
new file mode 100644
index 0000000..45d2a0e
--- /dev/null
+++ b/docs/modules/arcp-cli.md
@@ -0,0 +1,89 @@
+# Module: arcp-cli (`dev.arcp:arcp-cli`)
+
+The `:cli` Gradle module provides the `arcp` command-line binary, built on
+top of the `:lib` protocol library.
+
+**Maven coordinates**: `dev.arcp:arcp-cli:1.1.0`
+
+---
+
+## Building
+
+```bash
+./gradlew :cli:installDist
+# Binary placed at:
+./cli/build/install/arcp/bin/arcp
+```
+
+Or run directly via Gradle:
+
+```bash
+./gradlew :cli:run --args="version"
+```
+
+---
+
+## Commands
+
+### `arcp version`
+
+Print SDK and protocol version information:
+
+```
+$ arcp version
+ARCP protocol: 1.1
+Kotlin SDK: 1.1.0
+SDK kind: kotlin
+```
+
+### `arcp serve` *(v0.2)*
+
+Run an ARCP runtime over a named transport:
+
+```
+$ arcp serve --transport=websocket --port=8080
+```
+
+> Not functional in v0.1. Prints `"runtime serve mode is v0.2"`.
+
+### `arcp send` *(v0.2)*
+
+Submit a job to a running runtime:
+
+```
+$ arcp send --url=wss://runtime.example.com/arcp \
+ --agent=summarise@1.0.0 \
+ --token=my-bearer-token
+```
+
+> Not functional in v0.1.
+
+### `arcp replay` *(v0.2)*
+
+Replay a session log from a SQLite `EventLog` file:
+
+```
+$ arcp replay --db=session.db --session=sess_abcde
+```
+
+> Not functional in v0.1.
+
+---
+
+## Shell completion *(v0.2)*
+
+Bash, zsh, and fish completion scripts will be generated automatically by
+the Clikt framework when `--generate-completion` lands in v0.2.
+
+---
+
+## Entry point
+
+`dev.arcp.cli.main` — the JVM `main` function. The binary is assembled by
+the `application` plugin and distributed as a zip/tar via
+`:cli:distZip` / `:cli:distTar`.
+
+```kotlin
+// cli/src/main/kotlin/dev/arcp/cli/Main.kt
+fun main(args: Array) = ArcpCli().main(args)
+```
diff --git a/docs/modules/arcp.md b/docs/modules/arcp.md
new file mode 100644
index 0000000..1ce5444
--- /dev/null
+++ b/docs/modules/arcp.md
@@ -0,0 +1,382 @@
+# Module: arcp (`dev.arcp:arcp`)
+
+The `:lib` Gradle module is the publishable ARCP protocol library. All
+public API lives here.
+
+**Maven coordinates**: `dev.arcp:arcp:1.1.0`
+
+---
+
+## dev.arcp.envelope
+
+### `Envelope`
+
+The canonical wire container for every ARCP message (RFC §6.1).
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `id` | `MessageId` | Unique per-message identifier |
+| `type` | `String` | Wire discriminator (e.g. `"session.open"`) |
+| `timestamp` | `Instant` | ISO 8601 send time |
+| `sessionId` | `SessionId?` | Owning session |
+| `jobId` | `JobId?` | Owning job (optional) |
+| `correlationId` | `MessageId?` | Request/response correlation |
+| `causationId` | `MessageId?` | Causal predecessor |
+| `traceId` | `String?` | W3C trace ID |
+| `priority` | `String` | `"normal"` or `"high"` |
+| `payload` | `MessageType` | Polymorphic message body |
+
+The custom serializer hoists the `type` discriminator from `payload` to the
+envelope root, matching the RFC §6.1 wire layout.
+
+---
+
+## dev.arcp.messages
+
+All RFC §6.2 message types as `@Serializable @SerialName` data classes
+implementing `MessageType`.
+
+### Session messages
+
+| Class | Wire type | Direction |
+|-------|-----------|-----------|
+| `SessionOpen` | `session.open` | C → R |
+| `SessionChallenge` | `session.challenge` | R → C |
+| `SessionAuthenticate` | `session.authenticate` | C → R |
+| `SessionAccepted` | `session.accepted` | R → C |
+| `SessionUnauthenticated` | `session.unauthenticated` | R → C |
+| `SessionRejected` | `session.rejected` | R → C |
+| `SessionRefresh` | `session.refresh` | either |
+| `SessionEvicted` | `session.evicted` | R → C |
+| `SessionClose` | `session.close` | either |
+| `SessionListJobs` | `session.list_jobs` | C → R |
+| `SessionJobs` | `session.jobs` | R → C |
+
+Key types on `SessionAccepted`: `sessionId: SessionId`, `capabilities:
+Capabilities`, `runtime: RuntimeIdentity`, `trustLevel: TrustLevel`.
+
+`TrustLevel`: `UNTRUSTED`, `CONSTRAINED`, `TRUSTED`, `PRIVILEGED`.
+
+### Execution messages
+
+| Class | Wire type |
+|-------|-----------|
+| `JobSubmit` | `job.submit` |
+| `JobAccepted` | `job.accepted` |
+| `JobStarted` | `job.started` |
+| `JobProgress` | `job.progress` |
+| `JobHeartbeat` | `job.heartbeat` |
+| `JobStatusEvent` | `job.status` |
+| `JobResultChunk` | `job.result_chunk` |
+| `JobResult` | `job.result` |
+| `JobCompleted` | `job.completed` |
+| `JobFailed` | `job.failed` |
+| `JobCancelled` | `job.cancelled` |
+| `JobCheckpoint` | `job.checkpoint` |
+| `ToolInvoke` | `tool.invoke` |
+| `ToolResult` | `tool.result` |
+| `ToolError` | `tool.error` |
+
+`ResultChunkEncoding`: `UTF8`, `BASE64`.
+`JobLifecycleState`: `ACCEPTED`, `QUEUED`, `RUNNING`, `BLOCKED`, `PAUSED`,
+`COMPLETED`, `FAILED`, `CANCELLED`.
+
+### Control messages
+
+| Class | Wire type |
+|-------|-----------|
+| `Ping` | `ping` |
+| `Pong` | `pong` |
+| `Ack` | `ack` |
+| `Nack` | `nack` |
+| `Cancel` | `cancel` |
+| `CancelAccepted` | `cancel.accepted` |
+| `CancelRefused` | `cancel.refused` |
+| `Interrupt` | `interrupt` |
+| `Resume` | `resume` |
+| `Backpressure` | `backpressure` |
+| `CheckpointCreate` | `checkpoint.create` |
+| `CheckpointRestore` | `checkpoint.restore` |
+
+`CancelTarget`: `JOB`, `STREAM`, `SESSION`.
+
+### Permission / lease messages
+
+| Class | Wire type |
+|-------|-----------|
+| `PermissionRequest` | `permission.request` |
+| `PermissionGrant` | `permission.grant` |
+| `PermissionDeny` | `permission.deny` |
+| `LeaseGranted` | `lease.granted` |
+| `LeaseRefresh` | `lease.refresh` |
+| `LeaseExtended` | `lease.extended` |
+| `LeaseRevoked` | `lease.revoked` |
+
+### Streaming messages
+
+| Class | Wire type |
+|-------|-----------|
+| `StreamOpen` | `stream.open` |
+| `StreamChunk` | `stream.chunk` |
+| `StreamClose` | `stream.close` |
+| `StreamError` | `stream.error` |
+
+`StreamKind`: `TEXT`, `BINARY`, `EVENT`, `LOG`, `METRIC`, `THOUGHT`.
+
+### Telemetry messages
+
+| Class | Wire type |
+|-------|-----------|
+| `EventEmit` | `event.emit` |
+| `Log` | `log` |
+| `Metric` | `metric` |
+| `TraceSpan` | `trace.span` |
+
+`LogLevel`: `TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, `CRITICAL`.
+
+Standard metric name constants are in `StandardMetrics`.
+
+### Agent messages
+
+| Class | Description |
+|-------|-------------|
+| `AgentRef` | `name` or `name@version` reference; `AgentRef.parse(wire)` |
+| `AgentDescriptor` | Versions advertised by a runtime |
+
+---
+
+## dev.arcp.client
+
+### `ARCPClient`
+
+```kotlin
+ARCPClient(
+ transport : Transport,
+ auth : BearerAuth,
+ client : ClientInfo,
+ capabilities : Capabilities,
+)
+```
+
+| Method | Returns | Description |
+|--------|---------|-------------|
+| `open()` | `SessionAccepted` | Authenticate and negotiate session |
+| `send(sessionId, payload)` | `MessageId` | Send a message |
+
+Companion factories:
+
+```kotlin
+ARCPClient.bearer(token: String): BearerAuth
+ARCPClient.defaultClientInfo(): ClientInfo
+```
+
+---
+
+## dev.arcp.runtime
+
+### `ARCPRuntime`
+
+```kotlin
+ARCPRuntime(
+ supportedCapabilities : Capabilities,
+ bearerAuth : BearerAuth? = null,
+ jwtAuth : JwtAuth? = null,
+ agentRegistry : AgentRegistry = AgentRegistry(),
+ budgetRegistry : BudgetRegistry = BudgetRegistry(),
+ eventLog : EventLog? = null,
+ credentialProvisioner : CredentialProvisioner? = null,
+ extensionRegistry : ExtensionRegistry = ExtensionRegistry(),
+)
+```
+
+| Method | Description |
+|--------|-------------|
+| `accept(transport)` | Start the server coroutine (non-blocking) |
+| `rotateCredential(jobId)` | Issue replacement credential mid-job |
+
+### `AgentRegistry`
+
+```kotlin
+val registry = AgentRegistry()
+registry.register("summarise", listOf("1.0.0", "2.0.0"))
+```
+
+---
+
+## dev.arcp.transport
+
+### `Transport` interface
+
+```kotlin
+interface Transport {
+ suspend fun send(envelope: Envelope)
+ fun receive(): Flow
+ fun close()
+}
+```
+
+### `MemoryTransport`
+
+```kotlin
+val (clientTransport, serverTransport) = MemoryTransport.pair()
+// or:
+val (c, s) = MemoryTransport.pair(capacity = 128)
+```
+
+`DEFAULT_CAPACITY = 64`. The channel uses `BufferOverflow.SUSPEND` so
+real backpressure propagates in tests.
+
+---
+
+## dev.arcp.auth
+
+### `BearerAuth` (fun interface)
+
+```kotlin
+fun interface BearerAuth {
+ fun verify(token: String): String // returns principal name
+}
+```
+
+### `StaticBearerAuth`
+
+```kotlin
+StaticBearerAuth(tokens: Map)
+// key=token, value=principal
+```
+
+### `JwtAuth`
+
+```kotlin
+JwtAuth(verifier: JWSVerifier, expectedAudience: String)
+// Companion:
+JwtAuth.hmac(secret: ByteArray, audience: String): JwtAuth
+```
+
+---
+
+## dev.arcp.credentials
+
+| Class | Description |
+|-------|-------------|
+| `Credential` | Wire credential (id, scheme, value, endpoint, constraints); `toString()` redacts `value` |
+| `CredentialStore` | In-memory store; `issue(jobId, cred)`, `revoke(credId)`, `pendingRevocations()` |
+| `CredentialProvisioner` | Interface: `provision(jobId, lease): Credential` |
+
+---
+
+## dev.arcp.lease
+
+| Class | Description |
+|-------|-------------|
+| `Currency` | Value class wrapping a currency string |
+| `BudgetAmount` | `(currency, value: BigDecimal)`; `BudgetAmount.parse("USD:5.00")` |
+| `CostBudget` | `(budgets: List)` — lease constraint container |
+| `BudgetRegistry` | Per-job counters; `register`, `consume`, `terminate`, `remaining` |
+| `BudgetCounter` | Single-job counter; `consume(amount): Outcome` (`Ok` or `Exhausted`) |
+| `ModelUseLease` | `(patterns: List)`; `allows(modelId)`, `subset(parent, child)` |
+| `LeaseSubset` | Static helpers for subset validation |
+
+---
+
+## dev.arcp.store
+
+### `EventLog`
+
+```kotlin
+EventLog.openInMemory(): EventLog
+EventLog.openFile(path: Path): EventLog
+```
+
+| Method | Description |
+|--------|-------------|
+| `append(envelope): Long` | Append; throws `AlreadyExists` on dup `message_id` |
+| `replay(sessionId, afterMessageId?): Flow` | Replay envelopes |
+| `lookupIdempotent(key): String?` | Check idempotency key |
+| `recordIdempotent(key, value)` | Record idempotency result |
+
+---
+
+## dev.arcp.trace
+
+### `TraceContext`
+
+```kotlin
+data class TraceContext(
+ val traceId: TraceId,
+ val spanId: SpanId,
+ val parentSpanId: SpanId? = null,
+) : AbstractCoroutineContextElement(Key)
+```
+
+| Function | Description |
+|----------|-------------|
+| `TraceContext.newRoot()` | Create root span (random traceId + spanId) |
+| `currentTrace()` | Get ambient `TraceContext` from coroutine context |
+| `withSpan(name, block)` | Run block in child span; returns block result |
+
+---
+
+## dev.arcp.extensions
+
+### `ExtensionRegistry`
+
+```kotlin
+val ext = ExtensionRegistry()
+ext.advertise("arcpx.acme.email.v1")
+ext.acceptsType(wireType): Boolean
+ext.classifyUnknown(wireType, optional, advertised): UnknownAction
+```
+
+`UnknownAction`: `Drop`, `Nack`.
+
+---
+
+## dev.arcp.ids
+
+Typed ID wrappers (all are `@JvmInline value class` wrapping `String`):
+
+`SessionId`, `JobId`, `MessageId`, `LeaseId`, `StreamId`,
+`PermissionName`, `TraceId`, `SpanId`.
+
+---
+
+## dev.arcp.error
+
+### `ErrorCode` enum
+
+24 codes; `wire: String`, `retryableByDefault: Boolean`.
+
+```kotlin
+ErrorCode.fromWire("RATE_LIMITED") // → RESOURCE_EXHAUSTED
+```
+
+### `ARCPException` sealed class
+
+24 subclasses. Common pattern:
+
+```kotlin
+try {
+ client.open()
+} catch (e: ARCPException) {
+ if (e.retryable) retry()
+}
+```
+
+---
+
+## dev.arcp.json
+
+### `arcpJson`
+
+Pre-configured `kotlinx.serialization` `Json` instance:
+
+```kotlin
+val json = arcpJson // lenient, ignores unknown keys, custom serializers registered
+```
+
+Use `arcpJson` to parse raw ARCP JSON strings:
+
+```kotlin
+val envelope = arcpJson.decodeFromString(rawJson)
+```
diff --git a/docs/recipes.md b/docs/recipes.md
new file mode 100644
index 0000000..8366362
--- /dev/null
+++ b/docs/recipes.md
@@ -0,0 +1,106 @@
+# Recipes
+
+Copy-paste solutions for complete, real-world ARCP patterns. Each recipe wires
+together multiple protocol features around an actual LLM workload; unlike the
+[`samples/`](../samples/) programs — which isolate a single concept — recipes
+are end-to-end shapes you can adapt directly to production use cases.
+
+Kotlin recipe source lives in [`recipes/`](../recipes/).
+
+---
+
+## [multi-agent-budget](../recipes/multi-agent-budget/) — OpenAI
+
+A planner decomposes a question into sub-questions and delegates each to a
+worker that carries a budget slice carved from the planner's remaining cap.
+
+**Features demonstrated**
+
+- `CostBudget` / `BudgetAmount` subset enforcement
+- `LeaseGranted` / `LeaseRevoked` delegation handshake
+- `BUDGET_EXHAUSTED` handling — sub-questions that no longer fit are skipped
+ before the `agent.delegate` rather than failing mid-flight
+- `StandardMetrics.COST_USD` emitted after each delegate so the runtime's
+ subset check sees an honest remaining balance
+
+**Key types**: `BudgetRegistry`, `BudgetCounter`, `ARCPException.BudgetExhausted`
+
+See [guides/leases.md](guides/leases.md) and [guides/delegation.md](guides/delegation.md).
+
+---
+
+## [email-vendor-leases](../recipes/email-vendor-leases/) — Claude
+
+A triage agent drives Claude through a tool-use loop with three tools, but the
+lease grants only the two read-only ones. When the model proposes `send_reply`
+the agent's lease validator throws `PERMISSION_DENIED` and feeds the denial
+back to Claude, which observes the deny and returns a drafted-but-unsent reply.
+
+**Features demonstrated**
+
+- `PermissionRequest` / `PermissionGrant` / `PermissionDeny` flow
+- `LeaseGranted` with `operation` constraints on individual tool names
+- `EventEmit` with `arcpx.acme.email.v1.parsed` vendor event type — dashboards
+ recognising the namespace can render parsed metadata specially
+- `ExtensionRegistry.advertise("arcpx.acme.email.v1")` capability negotiation
+
+**Key types**: `PermissionRequest`, `PermissionDeny`, `EventEmit`, `ExtensionRegistry`
+
+See [guides/leases.md](guides/leases.md) and [guides/vendor-extensions.md](guides/vendor-extensions.md).
+
+---
+
+## [stream-resume](../recipes/stream-resume/) — GLM-5
+
+A writer pipes GLM-5's streaming deltas into `StreamChunk` envelopes
+(~200 chars each). The client deliberately drops the transport mid-stream,
+opens a fresh connection with `Resume`, and the runtime replays every envelope
+past the cutoff from the `EventLog` so reassembly completes seamlessly across
+the gap.
+
+**Features demonstrated**
+
+- `StreamOpen` / `StreamChunk` / `StreamClose` with `ResultChunkEncoding.UTF8`
+- `EventLog.openFile(path)` — every envelope lands in a SQLite log under a
+ monotonic `event_seq`
+- `Resume(afterMessageId, includeOpenStreams = true)` — client reconnects and
+ receives only the envelopes it missed
+- `JobCheckpoint` before the gap so resume can skip already-processed steps
+- `Backpressure` signalling when the consumer falls behind
+
+**Key types**: `EventLog`, `Resume`, `StreamChunk`, `JobCheckpoint`
+
+See [guides/resume.md](guides/resume.md) and [guides/job-events.md](guides/job-events.md).
+
+---
+
+## [mcp-skill](../recipes/mcp-skill/) — MCP bridge
+
+An MCP server fronts the `multi-agent-budget` planner so any MCP host
+(Claude Code, Cursor, Desktop) can invoke it as a single `research` tool.
+The bridge keeps one long-lived ARCP session; each MCP tool call submits a
+fresh planner job and returns the terminal result as the tool's text response.
+
+**Features demonstrated**
+
+- Long-lived `ARCPClient` session shared across many short MCP calls
+- `JobSubmit` with `idempotencyKey` so duplicate MCP retries don't re-execute
+- `JobResult` / `JobCompleted` terminal-event collection
+- `Ping` / `Pong` keepalive loop on the shared session
+
+**Key types**: `ARCPClient`, `JobSubmit`, `JobCompleted`, `Ping`
+
+See [guides/jobs.md](guides/jobs.md) and [transports.md](transports.md).
+
+---
+
+## Related reading
+
+| Topic | Guide |
+|-------|-------|
+| Lease delegation and budget subsets | [guides/leases.md](guides/leases.md) |
+| Vendor extension naming and `EventEmit` | [guides/vendor-extensions.md](guides/vendor-extensions.md) |
+| `EventLog` append and replay | [guides/resume.md](guides/resume.md) |
+| Job lifecycle and `JobSubmit` fields | [guides/jobs.md](guides/jobs.md) |
+| Streaming chunks and `Backpressure` | [guides/job-events.md](guides/job-events.md) |
+| `Ping`/`Pong`, `Ack`/`Nack`, `Cancel` | [guides/delegation.md](guides/delegation.md) |
diff --git a/docs/transports.md b/docs/transports.md
new file mode 100644
index 0000000..9bfa740
--- /dev/null
+++ b/docs/transports.md
@@ -0,0 +1,114 @@
+# Transports
+
+A `Transport` is the bidirectional channel over which `Envelope` frames flow
+between client and runtime. The SDK ships one production-ready transport and
+one for testing.
+
+## Interface
+
+```kotlin
+package dev.arcp.transport
+
+interface Transport {
+ suspend fun send(envelope: Envelope)
+ fun receive(): Flow
+ fun close()
+}
+```
+
+Implementors agree to:
+
+- **Ordering** — frames are delivered in send order (per direction).
+- **Backpressure** — `send` suspends when the receiver is slow; it does not drop.
+- **Cancellation** — `close()` terminates both the outbound and inbound flows.
+
+---
+
+## MemoryTransport
+
+`MemoryTransport` pairs two in-process channels. It is the transport used by
+all integration tests and the `samples/` programs.
+
+### Construction
+
+```kotlin
+val (clientTransport, serverTransport) = MemoryTransport.pair()
+// or with a custom channel capacity:
+val (c, s) = MemoryTransport.pair(capacity = 128)
+```
+
+`pair()` returns `Pair`. The first element
+is the client side, the second is the server (runtime) side. Each side's
+`send` writes to the other's `receive` flow.
+
+**Default capacity** is `64` envelopes per direction
+(`MemoryTransport.DEFAULT_CAPACITY`). When the channel is full the sender
+suspends, so real backpressure propagates even in tests.
+
+### Use case
+
+```kotlin
+val (ct, rt) = MemoryTransport.pair()
+val runtime = ARCPRuntime(supportedCapabilities = Capabilities(), agentRegistry = registry)
+runtime.accept(rt)
+
+val client = ARCPClient(
+ transport = ct,
+ auth = ARCPClient.bearer("my-token"),
+ client = ARCPClient.defaultClientInfo(),
+ capabilities = Capabilities(),
+)
+val session = client.open()
+```
+
+---
+
+## WebSocketTransport (v0.2)
+
+WebSocket support ships in SDK v0.2. The transport class will live in
+`dev.arcp.transport.WebSocketTransport` and wrap a Ktor `DefaultClientWebSocketSession`.
+
+Expected API (subject to change before release):
+
+```kotlin
+// Client side
+val client = ARCPClient(
+ transport = WebSocketTransport.connect("wss://runtime.example.com/arcp"),
+ auth = ARCPClient.bearer(token),
+ client = ARCPClient.defaultClientInfo(),
+ capabilities = Capabilities(streaming = true),
+)
+```
+
+---
+
+## StdioTransport (v0.2)
+
+Standard-input/output transport for subprocess-based runtimes ships in
+SDK v0.2. It will live in `dev.arcp.transport.StdioTransport`.
+
+---
+
+## Writing a custom transport
+
+Implement the `Transport` interface and inject it at construction time:
+
+```kotlin
+class MyCustomTransport : Transport {
+ override suspend fun send(envelope: Envelope) { /* ... */ }
+ override fun receive(): Flow = /* cold Flow */ TODO()
+ override fun close() { /* ... */ }
+}
+
+val client = ARCPClient(
+ transport = MyCustomTransport(),
+ auth = ARCPClient.bearer("token"),
+ client = ARCPClient.defaultClientInfo(),
+ capabilities = Capabilities(),
+)
+```
+
+The `receive()` flow should be cold (one consumer activates it). The flow
+completes normally when the connection closes and throws on transport errors;
+`ARCPClient`/`ARCPRuntime` will propagate those errors as `ARCPException`
+subclasses.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
new file mode 100644
index 0000000..ce634fe
--- /dev/null
+++ b/docs/troubleshooting.md
@@ -0,0 +1,116 @@
+# Troubleshooting
+
+## Error codes
+
+Every ARCP error maps to an `ErrorCode` constant (RFC §18.2). Catch
+`ARCPException` subclasses to react to them:
+
+```kotlin
+try {
+ client.open()
+} catch (e: ARCPException.Unauthenticated) {
+ println("Auth failed: ${e.message}")
+} catch (e: ARCPException.BudgetExhausted) {
+ println("Budget exhausted (${e.currency}) on job ${e.jobId}")
+}
+```
+
+### Full error taxonomy
+
+| Wire code | Retryable? | Exception class | Common cause |
+|-----------|-----------|-----------------|--------------|
+| `OK` | — | — | Success (non-error) |
+| `CANCELLED` | No | `ARCPException.Cancelled` | Client cancelled the operation |
+| `UNKNOWN` | No | `ARCPException.Unknown` | Unexpected server-side error |
+| `INVALID_ARGUMENT` | No | `ARCPException.InvalidArgument` | Malformed request field |
+| `DEADLINE_EXCEEDED` | Yes | `ARCPException.DeadlineExceeded` | Operation timed out |
+| `NOT_FOUND` | No | `ARCPException.NotFound` | Resource or agent does not exist |
+| `ALREADY_EXISTS` | No | `ARCPException.AlreadyExists` | Duplicate `message_id` in event log |
+| `PERMISSION_DENIED` | No | `ARCPException.PermissionDenied` | Missing lease or permission |
+| `RESOURCE_EXHAUSTED` | Yes | `ARCPException.ResourceExhausted` | Rate-limit hit (wire alias: `RATE_LIMITED`) |
+| `FAILED_PRECONDITION` | No | `ARCPException.FailedPrecondition` | Operation not valid in current state |
+| `ABORTED` | Yes | `ARCPException.Aborted` | Concurrency conflict; retry |
+| `OUT_OF_RANGE` | No | `ARCPException.OutOfRange` | Value exceeds valid bounds |
+| `UNIMPLEMENTED` | No | `ARCPException.Unimplemented` | Feature not yet implemented |
+| `INTERNAL` | Yes | `ARCPException.Internal` | Runtime bug; report it |
+| `UNAVAILABLE` | Yes | `ARCPException.Unavailable` | Runtime temporarily unreachable |
+| `DATA_LOSS` | No | `ARCPException.DataLoss` | Message missing from event log |
+| `UNAUTHENTICATED` | No | `ARCPException.Unauthenticated` | Bad or missing bearer/JWT token |
+| `HEARTBEAT_LOST` | Yes | `ARCPException.HeartbeatLost` | Job missed consecutive heartbeat deadlines |
+| `LEASE_EXPIRED` | No | `ARCPException.LeaseExpired` | Lease TTL passed before use |
+| `LEASE_REVOKED` | No | `ARCPException.LeaseRevoked` | Runtime revoked the lease |
+| `LEASE_SUBSET_VIOLATION` | No | `ARCPException.LeaseSubsetViolation` | Requested capability exceeds granted subset |
+| `BUDGET_EXHAUSTED` | No | `ARCPException.BudgetExhausted` | Cost budget ceiling reached |
+| `AGENT_VERSION_NOT_AVAILABLE` | No | `ARCPException.AgentVersionNotAvailable` | No matching `agent@version` registered |
+| `BACKPRESSURE_OVERFLOW` | Yes | `ARCPException.BackpressureOverflow` | Subscription channel full |
+
+---
+
+## Common failure modes
+
+### `ARCPException.Unauthenticated` at `client.open()`
+
+The bearer token was rejected. Check:
+- Token is not empty or whitespace.
+- Server's `StaticBearerAuth` map includes the exact token.
+- For JWT: the `sub` claim is non-blank, `aud` matches the configured audience,
+ and the token has not expired.
+
+### `ARCPException.AgentVersionNotAvailable`
+
+The client requested `agent@version` that is not registered:
+
+```kotlin
+// Runtime must register the agent + version before accepting connections
+val registry = AgentRegistry()
+registry.register("summarise", listOf("1.0.0"))
+val runtime = ARCPRuntime(agentRegistry = registry, ...)
+```
+
+### `ARCPException.BudgetExhausted`
+
+The job's provisioned credential contained a `cost.budget` cap that was
+reached. The exception carries the `currency` and `jobId`:
+
+```kotlin
+} catch (e: ARCPException.BudgetExhausted) {
+ logger.warn { "Job ${e.jobId} exceeded ${e.currency} budget" }
+}
+```
+
+### `ARCPException.HeartbeatLost`
+
+The runtime stopped receiving heartbeat acknowledgements from a job. The
+`missedDeadlines` property shows how many consecutive beats were missed.
+If the job is an external subprocess, check that it is calling
+`JobHeartbeat` on schedule.
+
+### `ARCPException.DataLoss` during resume
+
+`EventLog.replay()` could not find `afterMessageId` in the log for the given
+session. This usually means the log was deleted or the wrong session ID was
+used. Verify the SQLite file path and the `session_id` value.
+
+### Build error: "Unable to locate a Java Runtime"
+
+The Gradle wrapper cannot find JDK 21. Fix:
+
+```bash
+export JAVA_HOME=/opt/homebrew/opt/openjdk@21
+export PATH="$JAVA_HOME/bin:$PATH"
+./gradlew build
+```
+
+### Detekt violation in CI
+
+Run detekt locally to see the specific rule:
+
+```bash
+./gradlew :lib:detekt
+```
+
+Common fixes:
+- **FunctionTooLong** — extract helpers; target ≤15 lines, hard cap 30.
+- **ComplexMethod** — extract branches into named functions or `when` tables.
+- **MagicNumber** — move numeric literals to `const val`.
+- **ForbiddenVoid** — use `Unit` instead of `void`-style expressions.
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index c592519..a845a90 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -17,6 +17,7 @@ detekt = "1.23.7"
kover = "0.8.3"
dokka = "1.9.20"
binary-compatibility-validator = "0.16.3"
+nexus-publish = "2.0.0"
[libraries]
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
@@ -52,3 +53,4 @@ detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" }
dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }
binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binary-compatibility-validator" }
+nexus-publish = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "nexus-publish" }
diff --git a/lib/api/lib.api b/lib/api/lib.api
index de537bd..f0dabe7 100644
--- a/lib/api/lib.api
+++ b/lib/api/lib.api
@@ -1525,18 +1525,17 @@ public final class dev/arcp/messages/Capabilities {
public static final field Companion Ldev/arcp/messages/Capabilities$Companion;
public static final field DEFAULT_HEARTBEAT_INTERVAL_SECONDS I
public fun ()V
- public fun (ZZZZZZZZZZZZZILdev/arcp/messages/HeartbeatRecovery;Ljava/util/List;Ljava/util/List;Ljava/util/List;)V
- public synthetic fun (ZZZZZZZZZZZZZILdev/arcp/messages/HeartbeatRecovery;Ljava/util/List;Ljava/util/List;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public fun (ZZZZZZZZZZZZILdev/arcp/messages/HeartbeatRecovery;Ljava/util/List;Ljava/util/List;Ljava/util/List;)V
+ public synthetic fun (ZZZZZZZZZZZZILdev/arcp/messages/HeartbeatRecovery;Ljava/util/List;Ljava/util/List;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Z
public final fun component10 ()Z
public final fun component11 ()Z
public final fun component12 ()Z
- public final fun component13 ()Z
- public final fun component14 ()I
- public final fun component15 ()Ldev/arcp/messages/HeartbeatRecovery;
+ public final fun component13 ()I
+ public final fun component14 ()Ldev/arcp/messages/HeartbeatRecovery;
+ public final fun component15 ()Ljava/util/List;
public final fun component16 ()Ljava/util/List;
public final fun component17 ()Ljava/util/List;
- public final fun component18 ()Ljava/util/List;
public final fun component2 ()Z
public final fun component3 ()Z
public final fun component4 ()Z
@@ -1545,8 +1544,8 @@ public final class dev/arcp/messages/Capabilities {
public final fun component7 ()Z
public final fun component8 ()Z
public final fun component9 ()Z
- public final fun copy (ZZZZZZZZZZZZZILdev/arcp/messages/HeartbeatRecovery;Ljava/util/List;Ljava/util/List;Ljava/util/List;)Ldev/arcp/messages/Capabilities;
- public static synthetic fun copy$default (Ldev/arcp/messages/Capabilities;ZZZZZZZZZZZZZILdev/arcp/messages/HeartbeatRecovery;Ljava/util/List;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Ldev/arcp/messages/Capabilities;
+ public final fun copy (ZZZZZZZZZZZZILdev/arcp/messages/HeartbeatRecovery;Ljava/util/List;Ljava/util/List;Ljava/util/List;)Ldev/arcp/messages/Capabilities;
+ public static synthetic fun copy$default (Ldev/arcp/messages/Capabilities;ZZZZZZZZZZZZILdev/arcp/messages/HeartbeatRecovery;Ljava/util/List;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Ldev/arcp/messages/Capabilities;
public fun equals (Ljava/lang/Object;)Z
public final fun getAgentHandoff ()Z
public final fun getAgents ()Ljava/util/List;
@@ -1559,7 +1558,6 @@ public final class dev/arcp/messages/Capabilities {
public final fun getExtensions ()Ljava/util/List;
public final fun getHeartbeatIntervalSeconds ()I
public final fun getHeartbeatRecovery ()Ldev/arcp/messages/HeartbeatRecovery;
- public final fun getHumanInput ()Z
public final fun getInterrupt ()Z
public final fun getModelUse ()Z
public final fun getProvisionedCredentials ()Z
@@ -1724,195 +1722,6 @@ public final class dev/arcp/messages/HeartbeatRecovery$Companion {
public final fun serializer ()Lkotlinx/serialization/KSerializer;
}
-public final class dev/arcp/messages/HumanChoiceOption {
- public static final field Companion Ldev/arcp/messages/HumanChoiceOption$Companion;
- public fun (Ljava/lang/String;Ljava/lang/String;)V
- public final fun component1 ()Ljava/lang/String;
- public final fun component2 ()Ljava/lang/String;
- public final fun copy (Ljava/lang/String;Ljava/lang/String;)Ldev/arcp/messages/HumanChoiceOption;
- public static synthetic fun copy$default (Ldev/arcp/messages/HumanChoiceOption;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Ldev/arcp/messages/HumanChoiceOption;
- public fun equals (Ljava/lang/Object;)Z
- public final fun getId ()Ljava/lang/String;
- public final fun getLabel ()Ljava/lang/String;
- public fun hashCode ()I
- public fun toString ()Ljava/lang/String;
-}
-
-public synthetic class dev/arcp/messages/HumanChoiceOption$$serializer : kotlinx/serialization/internal/GeneratedSerializer {
- public static final field INSTANCE Ldev/arcp/messages/HumanChoiceOption$$serializer;
- public final fun childSerializers ()[Lkotlinx/serialization/KSerializer;
- public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/HumanChoiceOption;
- public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
- public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor;
- public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Ldev/arcp/messages/HumanChoiceOption;)V
- public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V
- public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer;
-}
-
-public final class dev/arcp/messages/HumanChoiceOption$Companion {
- public final fun serializer ()Lkotlinx/serialization/KSerializer;
-}
-
-public final class dev/arcp/messages/HumanChoiceRequest : dev/arcp/messages/MessageType {
- public static final field Companion Ldev/arcp/messages/HumanChoiceRequest$Companion;
- public fun (Ljava/lang/String;Ljava/util/List;Lkotlinx/datetime/Instant;)V
- public final fun component1 ()Ljava/lang/String;
- public final fun component2 ()Ljava/util/List;
- public final fun component3 ()Lkotlinx/datetime/Instant;
- public final fun copy (Ljava/lang/String;Ljava/util/List;Lkotlinx/datetime/Instant;)Ldev/arcp/messages/HumanChoiceRequest;
- public static synthetic fun copy$default (Ldev/arcp/messages/HumanChoiceRequest;Ljava/lang/String;Ljava/util/List;Lkotlinx/datetime/Instant;ILjava/lang/Object;)Ldev/arcp/messages/HumanChoiceRequest;
- public fun equals (Ljava/lang/Object;)Z
- public final fun getExpiresAt ()Lkotlinx/datetime/Instant;
- public final fun getOptions ()Ljava/util/List;
- public final fun getPrompt ()Ljava/lang/String;
- public fun hashCode ()I
- public fun toString ()Ljava/lang/String;
-}
-
-public synthetic class dev/arcp/messages/HumanChoiceRequest$$serializer : kotlinx/serialization/internal/GeneratedSerializer {
- public static final field INSTANCE Ldev/arcp/messages/HumanChoiceRequest$$serializer;
- public final fun childSerializers ()[Lkotlinx/serialization/KSerializer;
- public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/HumanChoiceRequest;
- public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
- public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor;
- public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Ldev/arcp/messages/HumanChoiceRequest;)V
- public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V
- public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer;
-}
-
-public final class dev/arcp/messages/HumanChoiceRequest$Companion {
- public final fun serializer ()Lkotlinx/serialization/KSerializer;
-}
-
-public final class dev/arcp/messages/HumanChoiceResponse : dev/arcp/messages/MessageType {
- public static final field Companion Ldev/arcp/messages/HumanChoiceResponse$Companion;
- public fun (Ljava/lang/String;Ljava/lang/String;Lkotlinx/datetime/Instant;)V
- public synthetic fun (Ljava/lang/String;Ljava/lang/String;Lkotlinx/datetime/Instant;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
- public final fun component1 ()Ljava/lang/String;
- public final fun component2 ()Ljava/lang/String;
- public final fun component3 ()Lkotlinx/datetime/Instant;
- public final fun copy (Ljava/lang/String;Ljava/lang/String;Lkotlinx/datetime/Instant;)Ldev/arcp/messages/HumanChoiceResponse;
- public static synthetic fun copy$default (Ldev/arcp/messages/HumanChoiceResponse;Ljava/lang/String;Ljava/lang/String;Lkotlinx/datetime/Instant;ILjava/lang/Object;)Ldev/arcp/messages/HumanChoiceResponse;
- public fun equals (Ljava/lang/Object;)Z
- public final fun getChoiceId ()Ljava/lang/String;
- public final fun getRespondedAt ()Lkotlinx/datetime/Instant;
- public final fun getRespondedBy ()Ljava/lang/String;
- public fun hashCode ()I
- public fun toString ()Ljava/lang/String;
-}
-
-public synthetic class dev/arcp/messages/HumanChoiceResponse$$serializer : kotlinx/serialization/internal/GeneratedSerializer {
- public static final field INSTANCE Ldev/arcp/messages/HumanChoiceResponse$$serializer;
- public final fun childSerializers ()[Lkotlinx/serialization/KSerializer;
- public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/HumanChoiceResponse;
- public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
- public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor;
- public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Ldev/arcp/messages/HumanChoiceResponse;)V
- public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V
- public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer;
-}
-
-public final class dev/arcp/messages/HumanChoiceResponse$Companion {
- public final fun serializer ()Lkotlinx/serialization/KSerializer;
-}
-
-public final class dev/arcp/messages/HumanInputCancelled : dev/arcp/messages/MessageType {
- public static final field Companion Ldev/arcp/messages/HumanInputCancelled$Companion;
- public fun ()V
- public fun (Ldev/arcp/error/ErrorCode;Ljava/lang/String;)V
- public synthetic fun (Ldev/arcp/error/ErrorCode;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
- public final fun component1 ()Ldev/arcp/error/ErrorCode;
- public final fun component2 ()Ljava/lang/String;
- public final fun copy (Ldev/arcp/error/ErrorCode;Ljava/lang/String;)Ldev/arcp/messages/HumanInputCancelled;
- public static synthetic fun copy$default (Ldev/arcp/messages/HumanInputCancelled;Ldev/arcp/error/ErrorCode;Ljava/lang/String;ILjava/lang/Object;)Ldev/arcp/messages/HumanInputCancelled;
- public fun equals (Ljava/lang/Object;)Z
- public final fun getCode ()Ldev/arcp/error/ErrorCode;
- public final fun getReason ()Ljava/lang/String;
- public fun hashCode ()I
- public fun toString ()Ljava/lang/String;
-}
-
-public synthetic class dev/arcp/messages/HumanInputCancelled$$serializer : kotlinx/serialization/internal/GeneratedSerializer {
- public static final field INSTANCE Ldev/arcp/messages/HumanInputCancelled$$serializer;
- public final fun childSerializers ()[Lkotlinx/serialization/KSerializer;
- public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/HumanInputCancelled;
- public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
- public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor;
- public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Ldev/arcp/messages/HumanInputCancelled;)V
- public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V
- public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer;
-}
-
-public final class dev/arcp/messages/HumanInputCancelled$Companion {
- public final fun serializer ()Lkotlinx/serialization/KSerializer;
-}
-
-public final class dev/arcp/messages/HumanInputRequest : dev/arcp/messages/MessageType {
- public static final field Companion Ldev/arcp/messages/HumanInputRequest$Companion;
- public fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonElement;Lkotlinx/datetime/Instant;)V
- public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonElement;Lkotlinx/datetime/Instant;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
- public final fun component1 ()Ljava/lang/String;
- public final fun component2 ()Lkotlinx/serialization/json/JsonObject;
- public final fun component3 ()Lkotlinx/serialization/json/JsonElement;
- public final fun component4 ()Lkotlinx/datetime/Instant;
- public final fun copy (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonElement;Lkotlinx/datetime/Instant;)Ldev/arcp/messages/HumanInputRequest;
- public static synthetic fun copy$default (Ldev/arcp/messages/HumanInputRequest;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonElement;Lkotlinx/datetime/Instant;ILjava/lang/Object;)Ldev/arcp/messages/HumanInputRequest;
- public fun equals (Ljava/lang/Object;)Z
- public final fun getDefault ()Lkotlinx/serialization/json/JsonElement;
- public final fun getExpiresAt ()Lkotlinx/datetime/Instant;
- public final fun getPrompt ()Ljava/lang/String;
- public final fun getResponseSchema ()Lkotlinx/serialization/json/JsonObject;
- public fun hashCode ()I
- public fun toString ()Ljava/lang/String;
-}
-
-public synthetic class dev/arcp/messages/HumanInputRequest$$serializer : kotlinx/serialization/internal/GeneratedSerializer {
- public static final field INSTANCE Ldev/arcp/messages/HumanInputRequest$$serializer;
- public final fun childSerializers ()[Lkotlinx/serialization/KSerializer;
- public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/HumanInputRequest;
- public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
- public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor;
- public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Ldev/arcp/messages/HumanInputRequest;)V
- public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V
- public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer;
-}
-
-public final class dev/arcp/messages/HumanInputRequest$Companion {
- public final fun serializer ()Lkotlinx/serialization/KSerializer;
-}
-
-public final class dev/arcp/messages/HumanInputResponse : dev/arcp/messages/MessageType {
- public static final field Companion Ldev/arcp/messages/HumanInputResponse$Companion;
- public fun (Lkotlinx/serialization/json/JsonElement;Ljava/lang/String;Lkotlinx/datetime/Instant;)V
- public synthetic fun (Lkotlinx/serialization/json/JsonElement;Ljava/lang/String;Lkotlinx/datetime/Instant;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
- public final fun component1 ()Lkotlinx/serialization/json/JsonElement;
- public final fun component2 ()Ljava/lang/String;
- public final fun component3 ()Lkotlinx/datetime/Instant;
- public final fun copy (Lkotlinx/serialization/json/JsonElement;Ljava/lang/String;Lkotlinx/datetime/Instant;)Ldev/arcp/messages/HumanInputResponse;
- public static synthetic fun copy$default (Ldev/arcp/messages/HumanInputResponse;Lkotlinx/serialization/json/JsonElement;Ljava/lang/String;Lkotlinx/datetime/Instant;ILjava/lang/Object;)Ldev/arcp/messages/HumanInputResponse;
- public fun equals (Ljava/lang/Object;)Z
- public final fun getRespondedAt ()Lkotlinx/datetime/Instant;
- public final fun getRespondedBy ()Ljava/lang/String;
- public final fun getValue ()Lkotlinx/serialization/json/JsonElement;
- public fun hashCode ()I
- public fun toString ()Ljava/lang/String;
-}
-
-public synthetic class dev/arcp/messages/HumanInputResponse$$serializer : kotlinx/serialization/internal/GeneratedSerializer {
- public static final field INSTANCE Ldev/arcp/messages/HumanInputResponse$$serializer;
- public final fun childSerializers ()[Lkotlinx/serialization/KSerializer;
- public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/HumanInputResponse;
- public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
- public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor;
- public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Ldev/arcp/messages/HumanInputResponse;)V
- public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V
- public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer;
-}
-
-public final class dev/arcp/messages/HumanInputResponse$Companion {
- public final fun serializer ()Lkotlinx/serialization/KSerializer;
-}
-
public final class dev/arcp/messages/Interrupt : dev/arcp/messages/MessageType {
public static final field Companion Ldev/arcp/messages/Interrupt$Companion;
public fun (Ldev/arcp/messages/CancelTarget;Ljava/lang/String;Ljava/lang/String;)V
diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts
index 615072b..dbc6b31 100644
--- a/lib/build.gradle.kts
+++ b/lib/build.gradle.kts
@@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.binary.compatibility.validator)
`java-library`
`maven-publish`
+ signing
}
kotlin {
@@ -56,11 +57,12 @@ java {
publishing {
publications {
create("maven") {
+ artifactId = "arcp"
from(components["java"])
pom {
name.set("ARCP Kotlin SDK")
description.set(
- "Reference Kotlin implementation of the Agent Runtime Control Protocol (ARCP) v1.0.",
+ "Reference Kotlin implementation of the Agent Runtime Control Protocol (ARCP) v1.1.",
)
url.set("https://github.com/agentruntimecontrolprotocol/kotlin-sdk")
licenses {
@@ -88,3 +90,18 @@ publishing {
}
}
}
+
+// ---------------------------------------------------------------------------
+// GPG signing — required by Maven Central.
+// Keys are injected via environment variables in CI; local builds skip signing
+// when SIGNING_KEY is absent.
+// ---------------------------------------------------------------------------
+signing {
+ val signingKey = providers.environmentVariable("SIGNING_KEY")
+ val signingKeyId = providers.environmentVariable("SIGNING_KEY_ID")
+ val signingPassword = providers.environmentVariable("SIGNING_PASSWORD")
+ if (signingKey.isPresent) {
+ useInMemoryPgpKeys(signingKeyId.orNull, signingKey.orNull, signingPassword.orNull)
+ sign(publishing.publications["maven"])
+ }
+}
diff --git a/lib/src/main/kotlin/dev/arcp/messages/Human.kt b/lib/src/main/kotlin/dev/arcp/messages/Human.kt
deleted file mode 100644
index 607aa9f..0000000
--- a/lib/src/main/kotlin/dev/arcp/messages/Human.kt
+++ /dev/null
@@ -1,72 +0,0 @@
-package dev.arcp.messages
-
-import dev.arcp.error.ErrorCode
-import kotlinx.datetime.Instant
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.json.JsonElement
-import kotlinx.serialization.json.JsonObject
-
-/** `human.input.request` — solicit structured input (RFC §12.1). */
-@Serializable
-@SerialName("human.input.request")
-public data class HumanInputRequest(
- val prompt: String,
- @SerialName("response_schema")
- val responseSchema: JsonObject? = null,
- val default: JsonElement? = null,
- @SerialName("expires_at")
- val expiresAt: Instant,
-) : MessageType
-
-/** `human.input.response` — corresponding response (RFC §12.1). */
-@Serializable
-@SerialName("human.input.response")
-public data class HumanInputResponse(
- val value: JsonElement,
- @SerialName("responded_by")
- val respondedBy: String? = null,
- @SerialName("responded_at")
- val respondedAt: Instant? = null,
-) : MessageType
-
-/** Single option in [HumanChoiceRequest]. */
-@Serializable
-public data class HumanChoiceOption(
- val id: String,
- val label: String,
-)
-
-/** `human.choice.request` — multi-option picker (RFC §12.2). */
-@Serializable
-@SerialName("human.choice.request")
-public data class HumanChoiceRequest(
- val prompt: String,
- val options: List,
- @SerialName("expires_at")
- val expiresAt: Instant,
-) : MessageType
-
-/** `human.choice.response` — chosen option (RFC §12.2). */
-@Serializable
-@SerialName("human.choice.response")
-public data class HumanChoiceResponse(
- @SerialName("choice_id")
- val choiceId: String,
- @SerialName("responded_by")
- val respondedBy: String? = null,
- @SerialName("responded_at")
- val respondedAt: Instant? = null,
-) : MessageType
-
-/**
- * `human.input.cancelled` — request was cleared/expired (RFC §12.3 / §12.4).
- *
- * Emitted both for deadline expiry and for cross-channel resolution.
- */
-@Serializable
-@SerialName("human.input.cancelled")
-public data class HumanInputCancelled(
- val code: ErrorCode = ErrorCode.CANCELLED,
- val reason: String? = null,
-) : MessageType
diff --git a/lib/src/main/kotlin/dev/arcp/messages/Session.kt b/lib/src/main/kotlin/dev/arcp/messages/Session.kt
index 2627308..d0b12e0 100644
--- a/lib/src/main/kotlin/dev/arcp/messages/Session.kt
+++ b/lib/src/main/kotlin/dev/arcp/messages/Session.kt
@@ -38,8 +38,6 @@ public data class Capabilities(
val binaryStreams: Boolean = false,
@SerialName("agent_handoff")
val agentHandoff: Boolean = false,
- @SerialName("human_input")
- val humanInput: Boolean = false,
val artifacts: Boolean = false,
val subscriptions: Boolean = false,
@SerialName("scheduled_jobs")
diff --git a/lib/src/main/kotlin/dev/arcp/runtime/CapabilityNegotiation.kt b/lib/src/main/kotlin/dev/arcp/runtime/CapabilityNegotiation.kt
index eea91b5..af78de4 100644
--- a/lib/src/main/kotlin/dev/arcp/runtime/CapabilityNegotiation.kt
+++ b/lib/src/main/kotlin/dev/arcp/runtime/CapabilityNegotiation.kt
@@ -43,7 +43,6 @@ private fun mergeCapabilities(
checkpoints = bools.getValue("checkpoints"),
binaryStreams = bools.getValue("binary_streams"),
agentHandoff = bools.getValue("agent_handoff"),
- humanInput = bools.getValue("human_input"),
artifacts = bools.getValue("artifacts"),
subscriptions = bools.getValue("subscriptions"),
scheduledJobs = bools.getValue("scheduled_jobs"),
@@ -87,7 +86,6 @@ private fun negotiateBooleanFlags(
"checkpoints" to (proposed.checkpoints to supported.checkpoints),
"binary_streams" to (proposed.binaryStreams to supported.binaryStreams),
"agent_handoff" to (proposed.agentHandoff to supported.agentHandoff),
- "human_input" to (proposed.humanInput to supported.humanInput),
"artifacts" to (proposed.artifacts to supported.artifacts),
"subscriptions" to (proposed.subscriptions to supported.subscriptions),
"scheduled_jobs" to (proposed.scheduledJobs to supported.scheduledJobs),
diff --git a/lib/src/test/kotlin/dev/arcp/messages/MessageCatalogTest.kt b/lib/src/test/kotlin/dev/arcp/messages/MessageCatalogTest.kt
index 639f8f2..03e2a1b 100644
--- a/lib/src/test/kotlin/dev/arcp/messages/MessageCatalogTest.kt
+++ b/lib/src/test/kotlin/dev/arcp/messages/MessageCatalogTest.kt
@@ -137,16 +137,6 @@ class MessageCatalogTest :
StreamChunk(sequence = 1, content = "hello"),
StreamClose(totalChunks = 1),
StreamError(code = ErrorCode.CANCELLED, message = "x"),
- // Human
- HumanInputRequest(prompt = "?", expiresAt = ts),
- HumanInputResponse(value = JsonPrimitive("yes")),
- HumanChoiceRequest(
- prompt = "?",
- options = listOf(HumanChoiceOption("a", "A")),
- expiresAt = ts,
- ),
- HumanChoiceResponse(choiceId = "a"),
- HumanInputCancelled(reason = "expired"),
// Permissions
PermissionRequest(
permission = PermissionName("filesystem.read"),
diff --git a/recipes/README.md b/recipes/README.md
new file mode 100644
index 0000000..5260d32
--- /dev/null
+++ b/recipes/README.md
@@ -0,0 +1,44 @@
+# ARCP Kotlin SDK — Recipes
+
+Runnable end-to-end examples for the most common ARCP integration patterns.
+Each recipe is a self-contained directory with its own `README.md` and
+Kotlin source files. All recipes run **in-process** using `MemoryTransport`,
+so no network setup or external server is needed.
+
+| Recipe | Highlights |
+|---|---|
+| [multi-agent-budget](multi-agent-budget/) | Budget cascade across a planner → worker delegation tree (RFC §13.2, §9.6) |
+| [email-vendor-leases](email-vendor-leases/) | Read-only tool leases, vendor-extension events, graceful PERMISSION_DENIED (RFC §13.4, §15) |
+| [stream-resume](stream-resume/) | Chunked streaming + EventLog replay after a transport drop (RFC §8.4, §19) |
+| [mcp-skill](mcp-skill/) | MCP ↔ ARCP bridge: expose a planner agent as a Claude Code skill (RFC §4.4) |
+
+## Prerequisites
+
+- **JDK 21** — `export JAVA_HOME=/opt/homebrew/opt/openjdk@21`
+- **Gradle wrapper** — all builds are run via `./gradlew`
+
+## Running a recipe
+
+```bash
+# multi-agent budget cascade
+./gradlew :recipes:runMultiAgentBudget
+
+# email parsing with read-only leases and vendor events
+./gradlew :recipes:runEmailVendorLeases
+
+# streaming with EventLog resume
+./gradlew :recipes:runStreamResume
+
+# MCP ↔ ARCP bridge (stdio skill)
+./gradlew :recipes:runMcpSkill
+```
+
+> **Note**: The LLM-backed recipes (`multi-agent-budget`, `email-vendor-leases`,
+> `stream-resume`) require API keys exported in the environment before you run
+> Gradle. See each recipe's `README.md` for the exact variable name.
+
+## Relationship to samples
+
+The `samples/` tree demonstrates individual protocol features in isolation.
+These recipes combine multiple features into realistic end-to-end flows that
+mirror real production usage.
diff --git a/recipes/build.gradle.kts b/recipes/build.gradle.kts
new file mode 100644
index 0000000..9da0e00
--- /dev/null
+++ b/recipes/build.gradle.kts
@@ -0,0 +1,56 @@
+plugins {
+ alias(libs.plugins.kotlin.jvm)
+ alias(libs.plugins.kotlin.serialization)
+ application
+}
+
+kotlin {
+ jvmToolchain(21)
+ compilerOptions {
+ allWarningsAsErrors = true
+ }
+}
+
+sourceSets {
+ main {
+ kotlin.srcDirs(
+ "multi-agent-budget",
+ "email-vendor-leases",
+ "stream-resume",
+ "mcp-skill",
+ )
+ }
+}
+
+application {
+ mainClass.set("com.arcp.recipes.multiagentbudget.MainKt")
+}
+
+dependencies {
+ implementation(project(":lib"))
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.ktor.client.core)
+ implementation(libs.ktor.client.cio)
+ implementation(libs.kotlin.logging)
+ runtimeOnly(libs.logback.classic)
+}
+
+val recipeClasses =
+ mapOf(
+ "runMultiAgentBudget" to "com.arcp.recipes.multiagentbudget.MainKt",
+ "runEmailVendorLeases" to "com.arcp.recipes.emailvendorleases.MainKt",
+ "runStreamResume" to "com.arcp.recipes.streamresume.MainKt",
+ "runMcpSkill" to "com.arcp.recipes.mcpskill.MainKt",
+ )
+
+recipeClasses.forEach { (name, mainClassFqn) ->
+ tasks.register(name) {
+ group = "recipes"
+ description = "Run recipe $name"
+ classpath = sourceSets["main"].runtimeClasspath
+ mainClass.set(mainClassFqn)
+ // Inherit environment variables so API keys set in the shell are visible.
+ environment = System.getenv() as Map
+ }
+}
diff --git a/recipes/email-vendor-leases/Client.kt b/recipes/email-vendor-leases/Client.kt
new file mode 100644
index 0000000..0d31589
--- /dev/null
+++ b/recipes/email-vendor-leases/Client.kt
@@ -0,0 +1,207 @@
+package com.arcp.recipes.emailvendorleases
+
+import dev.arcp.client.ARCPClient
+import dev.arcp.envelope.Envelope
+import dev.arcp.ids.MessageId
+import dev.arcp.messages.Ack
+import dev.arcp.messages.Capabilities
+import dev.arcp.messages.EventEmit
+import dev.arcp.messages.JobAccepted
+import dev.arcp.messages.JobCompleted
+import dev.arcp.messages.JobSubmit
+import dev.arcp.messages.Metric
+import dev.arcp.messages.Nack
+import dev.arcp.messages.StandardMetrics
+import dev.arcp.transport.Transport
+import kotlinx.coroutines.flow.first
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+
+// ---------------------------------------------------------------------------
+// Allowed tools — send_reply is intentionally absent to demonstrate
+// self-enforced PERMISSION_DENIED (RFC §13.4).
+// ---------------------------------------------------------------------------
+
+private val ALLOWED_TOOLS = listOf("inbox_list", "inbox_read")
+
+// ---------------------------------------------------------------------------
+// Simulated tool stubs
+// ---------------------------------------------------------------------------
+
+private fun simulateInboxList(): List