Headless permission policy and output formats.

Breaking

⛰️ Features

  • (acp) Headless permission policy and output formats (#866)
  • (acp) Read harness authentication from the protocol (#860)
  • (acp) Harden v1 controller routing (#853)
  • (acp) Add controller identity and session routing (#852)
  • (acp) Add multi-session controller (#849)
  • (acp) Rebuild the ACP agent surface and add the chat TUI (#816)
  • (actions) [breaking] One table for status, list_models, route (#869)
  • (chat) Resolve slash commands against the actions table (#880)
  • (cli) Operate trajectory history
  • (eval) Publish trajectory operations
  • (eval) Add generic route evaluation workflow (#763)
  • (eval) Make policy locks the routing source (#762)
  • (launch) Route every catalog harness (#781)
  • (mcp) [breaking] Drop the complete tool from the origin server (#875)
  • (mcp) Upgrade to stable rmcp 3.1 for MCP 2026-07-28 (#760)
  • (observability) [breaking] Live router view and hosted launch (#803)
  • (optimize) Add agentic workflow loop (#777)
  • (policy) Route models by reasoning effort (#804)
  • (policy) Route agent workflows by predicted role (#790)
  • (policy) Add trajectory progress guard
  • (policy) Harden cost-aware workflow routing (#768)
  • (policy) Generalize agent trace routing (#757)
  • (registry) Add agents and runtimes as a primitive pair (#879)
  • (router) Add shadow classifier bakeoff (#865)
  • (router) Freeze routing measurement (#862)
  • (skills) [breaking] Unify discovery and conform to SEP-2640 (#870)
  • (skills) [breaking] Serve over MCP (SEP-2640), cut installer (#770)
  • (substrate) Detach/reattach session robustness (#743)
  • (trajectory) Reduce replayable health
  • (trajectory) Add durable event ledger
  • Optimize routes from observed history (#833)
  • Publish bitrouter/auto as the router slug (#788)
  • Guide @auto workflow optimization (#779)
  • Add MCP registry discovery (mcp search/list/add) (#759)
  • Add cache-aware routing, Terminus 2 identity, and Claude bridge (#717)

🐛 Bug Fixes

  • (auth) Preserve fallback semantics
  • (auth) Redact extension failures
  • (chat) Show the session log when a launch fails (#857)
  • (cli) Onboard into the default ACP TUI (#886)
  • (cli) Harden trajectory operations
  • (config) Make server.log_level set the log filter (#806)
  • (daemon) Authenticate restart kill target
  • (daemon) Await restart process exit
  • (db) Normalize anchored SQLite URLs on Windows
  • (eval) Preserve unguarded policy subjects
  • (eval) Scope trajectory decision credit
  • (launch) Say launch: not spawn: in console output (#798)
  • (pipeline) Settle routing failures
  • (policy) Bind progress guard evidence
  • (policy) Make runtime mode authoritative (#761)
  • (protocol) Preserve Anthropic tool extension fields (#774)
  • (responses) Budget shutdown drain per batch
  • (responses) Drain after slow reconciler shutdown
  • (responses) Close continuation safety gaps
  • (responses) Require graceful reconciliation
  • (responses) Fence continuation reconciliation
  • (responses) Bound continuation ownership state
  • (responses) Close continuation publication races
  • (responses) Harden continuation publication
  • (responses) Authorize continuation on delivery
  • (responses) Harden continuation delivery
  • (responses) Bind completed continuation
  • (responses) Persist gateway continuation
  • (responses) Preserve native continuation
  • (responses) Stabilize stream identity
  • (responses) Include response creation time
  • (router) Correct research measurement reports (#874)
  • (sdk) Normalize routed stream usage (#887)
  • (tests) Escape Windows database URLs in YAML
  • (tests) Drain settlements before restart assertions
  • (trajectory) Hold continuation starts at episode head (#829)
  • (trajectory) Align start and settlement clocks
  • (trajectory) Count recovery transitions
  • (trajectory) Preserve truncated ancestry honesty
  • (trajectory) Correct outbox delivery audit
  • (trajectory) Bind reused evaluation
  • (trajectory) Stabilize failed retries
  • (trajectory) Expose ancestry conflicts
  • (trajectory) Separate hold from tier selection
  • (trajectory) Validate initial episode start
  • (trajectory) Enforce replay lifecycle
  • (trajectory) Bind native evidence key
  • (trajectory) Close correlation edge cases
  • (trajectory) Harden correlation runtime
  • (trajectory) Harden ledger persistence
  • Point stale surfaces at commands that exist (#799)
  • Harden provider fallback errors (#755)

🚜 Refactor

  • (acp) [breaking] One ACP stack, from the engine to a flat chat loop (#848)
  • (acp) Move the ACP proxy engine into bitrouter-sdk (#793)
  • (docs) Consolidate internal specs under docs/ (#750)
  • (policy) Make selection trajectory-aware
  • (sdk) [breaking] Move the OTLP renderer out of the SDK (#851)
  • (substrate) Drop detach/reattach + fleet machinery (#751)
  • (tui) [breaking] Move all drawing into bitrouter-tui (#830)
  • Consolidate BitRouter Cloud SDK ownership (#846)
  • Unify task-aware routing on agent_route/v1 (#828)

📚 Documentation

  • Sync status and launch docs (#840)
  • Harden Harbor EC2 network preflight (#838)
  • Align docs with current CLI surfaces (#802)
  • Correct routing surface docs (#741)

⚡ Performance

  • (trajectory) Bound prefix lookup cardinality
  • (trajectory) Bound prefix correlation

🧪 Testing

  • (trajectory) Prove progress control

  • Headless permissions and formats for acp prompt / spawn -p. A headless run used to deny every session/request_permission; now the caller states the rule: --deny-all (still the default), --approve-reads (the ACP tool kind is read or search), --approve-all, and a per-tool --permission-policy '{"autoApprove":[…],"autoDeny":[…],"defaultAction":…}' (or @path). Each answer is a new NDJSON line, {"type":"permission","decision":"approved"|"denied","title":…,"kind":…}, and the process exits 5 when at least one request was denied and none approved. --format text prints the transcript exactly as bitrouter chat prints it to a pipe; --format quiet prints the assistant's text only; json (the default) is the unchanged NDJSON. The decision is made by bitrouter_tui::permission::Policy and reaches the agent through one shared interpreter (chat/effects.rs) that the interactive TUI, the piped chat, and acp prompt all run — the first session-verb parity between the terminal and the headless CLI.

  • Breaking (MCP): the origin MCP server's complete tool is removed. MCP is BitRouter's control and introspection surface; running a completion is what the daemon's HTTP inference API is for. Use it instead — it is the transport built for inference, and it always had the surface the tool did not: streaming, the full parameter set, and the metering path.

    // before — MCP tools/call
    { "name": "complete",
      "arguments": { "model": "openai/gpt-4o",
                     "messages": [{ "role": "user", "content": "hi" }] } }
    # after — the daemon's HTTP API (OpenAI-shaped; /v1/messages is the
    # Anthropic-shaped twin)
    curl http://127.0.0.1:4356/v1/chat/completions \
      -H 'content-type: application/json' \
      -d '{"model":"openai/gpt-4o","messages":[{"role":"user","content":"hi"}]}'

    What remains on bitrouter mcp serve is list_models, status, route_preview and the skills pair: what is routable, where it would go, and what it has cost. The stdio profile is now list_models + status + route_preview + skills_search + skills_get; the HTTP profile is list_models, plus status where the backend can answer it.

    The one-line spend footer went with it. It only ever rode successful complete results; status reports the same figure as typed structured content under spend, from the same metering database, with unpriced and the remaining cap a footer had no room for.

    Breaking (Rust API): bitrouter_mcp::backend::Backend::complete and the CompleteRequest / CompleteResponse / Usage types are gone; Backend is now a pure port-handover trait (status_port + models_port). Also removed: server::Builder::completion, server::CostFooter, BitrouterMcp::with_cost_footer, server::CompleteArgs, and ServeOptions::cost_footer. server::serve_stdio loses its cost_footer parameter and now takes the handler alone. serve_http_on, CloudBackend and CloudAuth are unchanged.

  • Breaking (MCP wire, SEP-2640): the skills extension's resources field now matches the specification the MCP Core Maintainers accepted on 2026-09-03. BitRouter previously implemented an earlier draft and emitted entries a conforming host must refuse. Three changes:

    // before — a skill entry BitRouter published
    {
      "uri": "skill://pdf-processing/SKILL.md",
      "frontmatter": { "name": "pdf-processing", "description": "…" },
      "resources": [
        { "uri": "skill://pdf-processing/SKILL.md", "digest": "sha256:…" }
      ]
    }
    
    // after
    {
      "uri": "skill://pdf-processing/SKILL.md",
      "frontmatter": { "name": "pdf-processing", "description": "…" },
      "resources": [
        { "uri": "skill://pdf-processing/SKILL.md", "digest": "sha256:…",
          "size": 5120 }                 // new, REQUIRED on every entry
      ]
    }
    1. size is required on every resources entry — the file's raw byte length. It lets a host budget a skill from the listing alone, and a read whose length differs from size is now a verification failure equivalent to a digest mismatch, whether or not the digest is computed.
    2. resources is required, and "dynamic" is a string marker. The draft let the key be omitted to mean "generated dynamically"; the accepted specification requires the key and takes either the complete array or the literal string "dynamic". An entry with neither "is invalid, and hosts MUST NOT load it". The gateway therefore no longer republishes an upstream entry that omits resources; it skips it, as it already skipped other malformed entries.
    3. Per-skill limits are fixed: 512 resources entries and 16 MiB (16,777,216 bytes) summed over size. BitRouter will not serve or re-publish a skill exceeding either.

    Breaking (Rust API): bitrouter_sdk::mcp::skills::SkillResource gains size: u64, and SkillEntry::resources changes from Option<Vec<SkillResource>> to the new SkillResources enum (Enumerated(Vec<SkillResource>) | Dynamic).

  • Breaking (CLI): bitrouter skills list --json changes shape, and the command sees more skills than it used to. bitrouter skills list and the MCP skills_search tool are now one action over one report type.

    // before
    { "skills": [{ "name": "alpha", "path": "/p/.claude/skills/alpha" }] }
    
    // after
    { "skills": [{
        "name": "alpha",
        "description": "What alpha does",          // new
        "dir": "/p/.claude/skills/alpha",          // was `path`
        "skill_md": "/p/.claude/skills/alpha/SKILL.md",  // new
        "valid": true,                             // new
        "problem": "…"                             // new, omitted when valid
    }] }

    path meant the skill directory to the CLI and the SKILL.md file to the MCP tool. Neither was wrong; sharing one key for both was, so both surfaces now carry dir and skill_md.

    Three behaviour fixes ride along, all from collapsing three discovery rules over two roots into one:

    • All three conventional layouts are listed. bitrouter skills list read only <root>/.claude/skills, so a ./skills/foo skill was invisible to it while the agent could see it.
    • A skill that cannot be loaded is listed and explained, with valid: false and a problem, instead of being listed unmarked by the CLI and dropped silently by the agent's surface. SEP-2640's skills/list still publishes only the loadable ones, which the specification requires.
    • The MCP surfaces read the user-global root, which only -g used to reach. An agent no longer misses a skill because of where it was installed.
  • Fixed (MCP): a bitrouter mcp install-ed client now sees the skills tools. mcp install writes ["mcp", "serve"], but the skills and SEP-2640 surfaces were wired only under mcp serve --backend skills, so an installed client never saw a skill. Every stdio profile now carries them — the identity argument that makes --backend skills stdio-only (the server is a subprocess of the caller whose machine it is) holds identically for stdio mcp serve. The multi-tenant HTTP profile is unchanged and still carries neither.

  • Breaking (CLI): bitrouter route --json changes shape. bitrouter route and the MCP route_preview tool are now one action over one report type, and the report keeps route_preview's richer vocabulary — it was the superset, and it is the one an agent reads. Duplicated keys for one fact are exactly the drift the unification exists to remove, so the old names are gone rather than deprecated.

    // before — `bitrouter route gpt-5 --json`
    {
      "model": "gpt-5",
      "resolved_via": "config",
      "chain": [{ "provider": "openai", "service_id": "gpt-5", "protocol": "openai" }]
    }
    
    // after
    {
      "requested_model": "gpt-5",          // was `model`
      "effective_model": "gpt-5-codex",    // new: what the policy table selects
      "effective_effort": "high",          // new, omitted when policy selects none
      "resolved_via": "config",            // now live | config | zero_config
                                           // (was live daemon | config | zero-config)
      "policy_decision": {  },            // new, omitted on the live-daemon path
      "provider_chain": [                  // was `chain`
        { "provider": "openai", "service_id": "gpt-5-codex", "api_protocol": "openai" }
                                           // `protocol` → `api_protocol`
      ],
      "estimated_cost": {  }              // new, omitted when the registry prices nothing
    }

    Migration is mechanical: .model.requested_model (read .effective_model if you want what would actually run), .chain.provider_chain, .chain[].protocol.provider_chain[].api_protocol, and .resolved_via == "live daemon""live" / "zero-config""zero_config". The resolved_via values are now the same words bitrouter models --json uses for the same fact (live / config), instead of two spellings that almost matched.

    Two behaviour fixes ride along. bitrouter route now runs the policy table in its config fallback, as route_preview always did — it could previously name a model the daemon would never pick, which is why effective_model is a separate field from requested_model. (The live-daemon path is unchanged on both surfaces: the daemon's route verb resolves the model as given, so a live answer reports effective_model == requested_model and no policy_decision.) And route_preview now resolves config per call instead of snapshotting it at bitrouter mcp serve start, so an edited bitrouter.yaml is visible to a long-lived MCP server, as it always was to the CLI.

  • Breaking (Rust API): bitrouter_mcp::capabilities::routing is gone. The routing port moved to bitrouter_mcp::actions::route and is now typed: RoutingQuery::preview(RoutePreviewArgs) -> serde_json::Value becomes RouteQuery::route(RouteInput) -> RouteReport. ServeOptions::routing takes the new trait object.

  • Breaking (MCP tools): the origin server's status and list_models tools return the same report types as bitrouter status and bitrouter models, and advertise them as output_schema.

    • list_models was a bare [{ id, provider }] that kept only the first provider of each model. It is now { models: [{ id, providers: [...] }], resolved_via: "live" | "config" } — the whole fallback chain per model — with an optional provider argument (the same filter as bitrouter models --provider). On stdio + local it reads the daemon's live routing table over the control socket and falls back to a config parse, so it answers with no daemon running; resolved_via says which view it is.
    • status was a GET /v1/models in disguise ({ listen, models, providers } locally, the raw balance on cloud). It is now { running, pid?, listen?, models?, providers, socket?, spend? }: a stopped daemon is running: false, not a tool error, and spend carries two independent halves — spent (today's locally metered estimate, with unpriced saying how partial it is) and limit (a metered account's remaining credit). It is no longer served on HTTP + local — nothing on that transport can read the daemon's control socket — and no longer carries the free-text spend footer, which is now the typed spend block (the footer is gone from the server entirely — see the complete removal above).
  • bitrouter status --json gains providers[] (the distinct providers behind the routable models) and spend; bitrouter models --json gains resolved_via. Both are additive — every pre-existing key is unchanged. bitrouter models now prefers the running daemon's catalog, like bitrouter route, and falls back to the config parse it always used; standalone answers (models, route, spawn's Codex preflight) now resolve a config the way the daemon does, so a subscription-backed provider (claude-code, google-ai) is no longer missing from them.

  • Breaking (Rust API): bitrouter_mcp::backend::{ModelInfo, StatusInfo, ProviderStatus} and Backend::{list_models, status} are gone; Backend gains status_port / models_port (Option<Arc<dyn StatusQuery>> / Option<Arc<dyn ModelsQuery>>), Builder::completion_local is gone (its models(...) half is what survives — see the complete removal above), and ServeOptions gains status and models. bitrouter_sdk::language_model::routing::ModelInfo is the element type of the shared report and now derives JsonSchema, PartialEq and Eq.

  • Breaking (CLI): bitrouter skills add, remove, find, and update are removed, along with the bitrouter-skills crate that backed them. Installing skills is the ecosystem's job — npx skills add, or the Claude Code / Codex plugin marketplaces (this repo ships as one). BitRouter reads the installed-skills directory and serves it over MCP; it does not populate it. That is the same line as "server, not host" applied to content lifecycle: BitRouter handles transport, not distribution.

    bitrouter skills list and bitrouter skills init remain. SKILL.md format support moved into the binary (apps/bitrouter/src/skills/), where its only consumers live; the git-clone, source-resolution, install-to-disk, and registry-client code is gone. The --registry / --namespace flags and the api.bitrouter.ai skills-hub client went with them.

  • Skills over MCP (SEP-2640). BitRouter now serves Agent Skills as an MCP server and proxies them as a gateway, over stdio and Streamable HTTP alike. bitrouter mcp serve --backend skills answers skills/list, skills/get, resources/list, and resources/read over the installed-skills root, with complete sha256: digests per file; the existing skills_search / skills_get tools are unchanged and still served. The daemon's aggregate POST /mcp merges upstream skill catalogs, namespacing each under its configured server name (skill://<server>/<skill-path>/SKILL.md) so two upstreams publishing the same URI cannot shadow one another. BitRouter is a skills server and gateway, never a host: no daemon path installs skills, and gateway-sourced content never touches a filesystem skill-discovery path. Caveats are documented in skills/bitrouter/references/mcp-server.md — the gateway is not a security boundary, and remote catalogs are daemon-scoped rather than caller-scoped.

  • Breaking (behaviour): aggregate resources/read (POST /mcp) no longer tries each member and returns the first success. It resolves exactly one owning member — by skill-URI label, else by which member enumerates the URI — and errors when zero or several match, naming the candidates. First-success scanning let configuration order silently decide which upstream answered a URI two members both served, which is a cross-origin misroute (and the impersonation surface SEP-2640 names for skills). A URI that no member enumerates is now an error on the aggregate endpoint; read it from that server's direct route (POST /mcp/{server}) instead.

  • The gateway's initialize now advertises the resources capability and declares the io.modelcontextprotocol/skills extension. Skills are read through resources/read, so a compliant client that saw no resources capability would never issue one. The extension is declared optimistically — upstream capabilities are discovered lazily, so the gateway cannot know at handshake time whether any member serves skills.

  • Breaking (Rust API): BenchmarkOutcomeRecord has a new request_id field and strict reward feedback joins it to the persisted CapturedIngressTrace.id. Migrate Rust struct literals to BenchmarkOutcomeRecord::new(session_key, task_id, reward) followed by .with_request_id(trace_id) when producing reward-feedback artifacts. Older outcome JSONL remains serde-compatible (request_id defaults to absent), but it is analytical-only and strict feedback rejects it.

  • Breaking (policy routes): active policy_table routing now uses one predictive route contract: agent_route/v1|<task-family>|<role>|<risk>. Exact task-family routes fall back to the corresponding unknown-family role/risk baseline, then to the policy default. Observed agent_trace/v2 keys remain telemetry only. Static agent_trace routes, three-segment predictive v1 routes, and all v2 predictive routes are rejected during config and lock validation. Regenerate policy locks and certificates with the current predictor contract.

    key_strategy: agent_trace selects this deterministic predictor; the retired workflow_state and legacy_fingerprint spellings are rejected. adequacy.max_downgraded_requests_per_session is rejected: session identity is diagnostic-only and no longer affects routing. adequacy.explore_opening is honored for source-neutral opening projections.

  • Rust API: PolicyKeyStrategy now exposes only the canonical AgentTrace variant. PolicyDecision keeps workflow_state_kind and workflow_identity; PolicyDecisionRecord keeps workflow_state and workflow_identity; and PolicyDecisionSummary keeps by_workflow_state. Their JSON output uses canonical trace_state, trace_identity, and by_trace_state names while accepting the old JSON spellings on input. The matching trace_* accessors are available for new Rust callers.

On this page