Chio/Docs

Run Chio in the Browser

You want to run Chio policy enforcement inside a browser tab: a browser-extension agent, a CopilotKit-style in-page copilot, or a client-side tool-calling LLM that must not round-trip every tool call to a remote kernel. The portable kernel core compiles to WebAssembly via wasm-bindgen, which lets you carry the pure authorization path into the page. This guide walks through what ships today, what does not, and how to wire the pieces.

Lifecycle

The browser kernel is early. The pure verified core compiles and runs in every major browser, the JS entry points are stable enough to build against, and the native test suite passes. Guard modules, IndexedDB persistence, and a managed JS SDK are still on the roadmap. Treat this as a capable preview, not a finished product.

Why a Browser Kernel

The Chio trust-computing base is two layers. The bottom layer, chio-kernel-core, is pure computation: no async runtime, no filesystem, no network, no ambient authority. Every input is an explicit function argument; the only outputs are a verdict, a signed receipt, or a structured error. The upper layer, chio-kernel, wraps the core with tokio, SQLite, HTTP transport, revocation stores, and budget persistence.

Splitting the TCB this way has one consequence that matters here: because the core has no I/O, it compiles to wasm32-unknown-unknown unchanged. The same verdict-producing code path that runs inside the desktop sidecar can run inside a browser tab, a Cloudflare Worker, or a mobile app compiled through UniFFI. The browser target is one adapter among several.

For a longer discussion of which components live at which TCB tier and why, see Architecture — TCB Map. For the cross-target rationale, see Portable Kernel.

Prerequisites

You need a WASM toolchain — wasm-pack is the supported path — plus a recent Rust. You also need an agent runtime that actually lives in the page: a browser-side LLM call loop, an extension background script, or a CopilotKit host. And you need to be comfortable with the Chio capability model (capabilities, scopes, time-bounded tokens) because the browser entry points assume the caller has one.

What Ships in the Browser Build

The browser crate, chio-kernel-browser, exports a narrow surface. Every entry point deserializes JSON, calls into chio-kernel-core, and serializes the result back. No hidden state is kept across calls.

Included

  • Capability verification: verify_capability checks the signature, walks the trusted issuer set, and confirms the time-bound interval is live against Date.now().
  • Scope resolution: the kernel-core scope matcher decides whether a requested tool call falls inside a grant on the capability.
  • Evaluation: evaluate runs the full sync verdict path — signature, time, subject binding, scope match — and returns allow, deny, or pending_approval along with a machine-readable reason.
  • Receipt signing: sign_receipt accepts an ChioReceiptBody and an Ed25519 seed (typically minted via mint_signing_seed_hex()) and returns a signed ChioReceipt.
  • Web Crypto entropy: the WebCryptoRng adapter fills buffers from window.crypto.getRandomValues, with a fail-closed zero-seed guard so receipts cannot be signed with degraded entropy.

Not Included

  • Receipt persistence. The browser kernel produces signed receipts but does not store them. You decide where each receipt goes — IndexedDB, a remote trust-control plane, a best-effort post to an analytics endpoint.
  • Revocation lookups. The evaluator cannot consult a revocation store. A capability that is unexpired by its own time bounds will be accepted even if it has been revoked upstream. Mitigate by keeping capabilities short-lived.
  • Budget mutation. There is no persistent budget counter. The browser sees only the budget fields declared on the capability itself and can refuse calls that exceed a per-call ceiling, but it cannot track spend across a session.
  • Guard pipeline. The current browser evaluate call passes an empty guard list into the core. Guard modules compiled to WASM and loaded as nested modules are planned; they are not wired up.
  • Transport. There is no HTTP server, stdio pipe, or MCP adapter. The browser host calls entry points directly as JS functions.

Pure authorization path, not the full kernel

Treat the browser kernel as the signature + scope + time-bound decision, plus a signer. Anything that requires durable state (revocation, budgets, receipt persistence) must be satisfied by a component outside the tab, typically a trust-control plane the page talks to on a slower cadence than per-request.

Architecture

A browser deployment splits the trust work between a short-lived in-page kernel and a long-lived server-side authority. The page-side agent calls evaluate on every tool call. The authority issues capabilities, tracks revocations, and optionally ingests receipts. They communicate over HTTPS on a cadence that does not block per-call decisions.

rendering…
Browser kernel deployment: the tab owns evaluation and signing; the authority owns issuance, revocation, and durable receipt storage.

Contrast this with a server-side deployment, where the kernel sits in-process with the revocation store, budget store, and receipt store, and the tab does nothing but send tool calls over HTTP. The browser deployment trades durable state for lower latency and offline operation, and accepts a smaller security envelope as a result.


Build and Load

Build the wasm-pack bundle from the crate root:

bash
# From the chio repo root
$ wasm-pack build --target web --release crates/chio-kernel-browser

wasm-pack emits a pkg/ directory inside the crate containing the compiled wasm module, an ES-module JS glue file, a .d.ts with TypeScript declarations, and a package.json ready for npm publish or direct use from a bundler that understands ES modules.

pkg/
pkg/
  chio_kernel_browser_bg.wasm      # compiled kernel
  chio_kernel_browser.js           # ES-module glue
  chio_kernel_browser.d.ts         # TypeScript declarations
  package.json                     # npm-publishable manifest

Import the module from a bundler or directly from a static file server. The JS glue exposes the four entry points the Rust crate declared: evaluate, sign_receipt, verify_capability, and mint_signing_seed_hex.

src/chio-kernel.ts
import init, {
  evaluate,
  sign_receipt,
  verify_capability,
  mint_signing_seed_hex,
} from "./pkg/chio_kernel_browser.js";

// Call once per page load. init() fetches and instantiates the wasm module.
await init();

// The four entry points are now callable.
export { evaluate, sign_receipt, verify_capability, mint_signing_seed_hex };

Node and edge targets

The same crate also builds under --target nodejs for Node.js hosts and can be compiled against wasm32-wasip1 for Cloudflare Workers and similar V8-isolate runtimes. This guide focuses on the browser target; the JS surface is identical.

Issuing Capabilities for a Browser Session

The browser does not issue its own capabilities. A server-side authority signs a short-lived capability scoped to what the page is allowed to do, hands it to the tab, and lets the tab present it to the WASM kernel for every tool call.

A typical server-side handler mints a one-hour capability when a session starts:

server/src/session.rs
// Server-side: a Chio authority issues a short-lived capability
// and returns it to the browser over a trusted channel.
use chio_core_types::capability::{
    ChioScope, CapabilityToken, CapabilityTokenBody, Operation, ToolGrant,
};
use chio_core_types::crypto::Keypair;

pub fn mint_session_capability(
    authority: &Keypair,
    agent_subject: &Keypair,
    now_unix_secs: u64,
) -> CapabilityToken {
    let scope = ChioScope {
        grants: vec![ToolGrant {
            server_id: "srv-search".to_string(),
            tool_name: "search".to_string(),
            operations: vec![Operation::Invoke],
            constraints: vec![],
            max_invocations: Some(100),
            max_cost_per_invocation: None,
            max_total_cost: None,
            dpop_required: None,
        }],
        resource_grants: vec![],
        prompt_grants: vec![],
    };
    let body = CapabilityTokenBody {
        id: format!("cap-{}", uuid::Uuid::new_v4()),
        issuer: authority.public_key(),
        subject: agent_subject.public_key(),
        scope,
        issued_at: now_unix_secs,
        expires_at: now_unix_secs + 3600, // one hour
        delegation_chain: vec![],
    };
    CapabilityToken::sign(body, authority).expect("sign capability")
}

The browser receives the signed token as JSON and holds it in memory. It never persists to localStorage or any other durable store — when the tab closes the capability is gone, which is exactly the desired property.

Narrow aggressively before handing to the page

A capability handed to a browser tab should be the smallest authority the page needs for its session: the exact tools, scoped paths or hosts, a tight expiry, and a capped invocation count. If the page is compromised — XSS, malicious extension, stolen session token — the attacker inherits exactly the privilege encoded in the capability. Keep it minimal. See Capabilities for the full scope surface.

Evaluating a Request

Every tool call the in-page agent wants to make goes through evaluate. The request envelope carries the tool call, the capability, and the list of trusted issuer public keys. The verdict envelope names the outcome, the matched grant, and — on allow — the verified capability identity.

src/agent.ts
import { evaluate } from "./chio-kernel.js";

// The capability you received from the server at session start.
const capability = sessionCapability;

// The authority that signed it (hex-encoded Ed25519 public key).
const TRUSTED_ISSUERS_HEX = [AUTHORITY_PUBLIC_KEY_HEX];

export interface ToolCall {
  request_id: string;
  tool_name: string;
  server_id: string;
  agent_id: string;     // hex-encoded agent public key
  arguments: unknown;
}

export async function authorize(call: ToolCall) {
  const envelope = {
    request: call,
    capability,
    trusted_issuers_hex: TRUSTED_ISSUERS_HEX,
  };

  const verdict = await evaluate(JSON.stringify(envelope));

  if (verdict.verdict !== "allow") {
    throw new Error(
      `denied: ${verdict.reason ?? "no reason given"}`,
    );
  }

  return verdict;
}

The verdict envelope is a plain object the JS caller can read without reaching into Rust enum tags:

typescript
interface EvaluationVerdict {
  verdict: "allow" | "deny" | "pending_approval";
  reason?: string;
  matched_grant_index?: number;
  subject_hex?: string;
  issuer_hex?: string;
  capability_id?: string;
  evaluated_at?: number;
}

On a deny, reason names the failed check: capability has expired, capability issuer is not in the trusted set, no grant matched, and so on. These strings come straight from the kernel-core error enum, so they are stable enough to switch on.

Pinning the clock for tests

The request envelope accepts an optional clock_override_unix_secs field. When set, the core uses the pinned value instead of reading Date.now(). This is how the native test suite exercises expiry paths deterministically and how you should drive acceptance checks in your own tests. Do not pin the clock in production code.

Receipts in the Browser

Receipt signing works locally. The kernel accepts an ChioReceiptBody plus a 32-byte Ed25519 seed, rewrites the body's kernel_key field to match the seed's public key, signs the canonical serialization, and returns a signed ChioReceipt.

src/receipts.ts
import { sign_receipt, mint_signing_seed_hex } from "./chio-kernel.js";

export async function recordDecision(
  verdict: EvaluationVerdict,
  call: ToolCall,
  policyHash: string,
  contentHash: string,
) {
  // Mint a fresh seed per receipt. The kernel rejects zero-filled seeds.
  const seedHex = await mint_signing_seed_hex();

  const body = {
    id: crypto.randomUUID(),
    timestamp: Math.floor(Date.now() / 1000),
    capability_id: verdict.capability_id,
    tool_server: call.server_id,
    tool_name: call.tool_name,
    action: { parameters: call.arguments },
    decision: verdict.verdict === "allow" ? "allow" : "deny",
    content_hash: contentHash,
    policy_hash: policyHash,
    evidence: [],
    trust_level: "mediated",
    kernel_key: null, // rewritten by sign_receipt
  };

  const receipt = await sign_receipt(
    JSON.stringify({ body }),
    seedHex,
  );
  return receipt;
}

The receipt is cryptographically valid the moment it returns. What the browser does with it is a separate decision. Three patterns that work:

  • Batch to the trust-control plane. Queue signed receipts in memory, flush to a server endpoint every N seconds or on tab close via navigator.sendBeacon. The server persists to the durable receipt store.
  • Local IndexedDB + periodic checkpoint. Hold receipts in IndexedDB for offline operation, then upload the accumulated batch when a connection is available. Useful for extensions and PWAs that may run disconnected.
  • Sign-and-forward. For agents whose output is already round-tripped to a server, attach the signed receipt to the response envelope. The server verifies the receipt alongside the result and persists it if it checks out.

Ephemeral signing keys are fine

The browser signs each receipt with a fresh per-call seed. The verifier only cares that the receipt's kernel_key field matches the public key that produced the signature — which it does, because the kernel rewrites it during signing. There is no long-term key to protect. The authority that issued the capability is a separate, long-lived keypair that lives on the server.

Security Boundary

The browser is a hostile environment for key material. Extensions can read page memory under the right permissions. XSS can exfiltrate anything in localStorage or held in a closure. Malicious bundlers can inject code at build time. The design of the browser kernel accepts this and shrinks the blast radius rather than trying to prevent it.

Two rules keep the envelope small:

  • The kernel signer is ephemeral. Each receipt is signed with a freshly minted Ed25519 seed that lives only in memory for the duration of one signing call. Compromising the tab at time T does not grant the attacker any signing key from times before or after — there was no persistent signer to steal.
  • The authority signer is server-side. The long-term root of trust for capability issuance never enters the browser. The page only ever sees capabilities the server already signed, and the trust-control plane can revoke them or refuse to reissue at any time.

This is the same hierarchy the Architecture — Key Hierarchy section describes for every deployment target: authority keys live where the threat model permits them to live, and operational signers are delegated short-lived keys derived beneath them.

Do not persist the capability

Hold the capability in a variable, not in localStorage or sessionStorage. Storage APIs are accessible to any script in the origin and to extensions with broad permissions. A capability loaded from a shared store is a credential that outlives the tab that needed it.

Error Shape

Every entry point fails structured. On error the JS caller receives an object with a code and a message:

typescript
interface BindingError {
  code: string;    // machine-readable
  message: string; // human-readable
}

// Error codes surfaced by chio-kernel-browser:
//   invalid_json_input
//   invalid_issuer_hex
//   invalid_seed_hex
//   invalid_authority_input
//   capability_verification_failed
//   receipt_signing_failed
//   weak_entropy
//   webcrypto_unavailable
//   encode_result_failed

Two of these deserve explicit handling. The weak_entropy code means getRandomValues returned zeros; refuse to operate, do not retry silently. The webcrypto_unavailable code means the host does not expose window.crypto at all, which is common in non-browser wasm hosts. Fall back to a server-side signer in that case, not to a deterministic seed.


Next Steps

Run Chio in the Browser · Chio Docs