Chio/Docs

BuildConnect

Run Chio in the Browser

Compile Chio's capability checks and receipt signer to WebAssembly for a browser tab.

Lifecycle

The browser kernel is early. The core compiles for browser targets, and the native test suite passes. The managed JavaScript SDK ships as @chio-protocol/browser (0.1.0), though it currently re-exports verify_receipt plus a verifyReceiptHex helper today . Guard modules and IndexedDB persistence are not wired up yet.

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.

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 for one of these targets.

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 these entry points. Each 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 capability path — signature, time, subject binding, scope match. Because it runs an empty guard pipeline and cannot itself authorize execution, its top-level verdict is never allow: a raw kernel allow is downgraded to pending_approval (with authorized: false), leaving deny as the only terminal negative outcome.
  • 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.

The browser build needs external durable state

The browser build checks signatures, scope, and time bounds and can sign receipts. 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.

In a server-side deployment, the kernel runs with the revocation, budget, and receipt stores. The browser deployment keeps decisions in the tab for lower latency and offline operation, but durable checks remain on the server.


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/kernel/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 suitable for npm publish or direct use from a bundler that understands ES modules. The workspace already wraps this output in a managed package, @chio-protocol/browser, whose package currently exposes verify_receipt; the rest of this guide uses the wasm-pack pkg/ output directly to call the other entry points.

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 seven #[wasm_bindgen] entry points the Rust crate declared: evaluate, sign_receipt, sign_receipt_relaying_trusted_body, verify_capability, verify_capability_with_context, verify_receipt, and mint_signing_seed_hex. This guide uses the first, the signer, capability verification, and the seed minter; the trailing three cover trusted-body relay signing, full trust-root/budget-context verification, and receipt verification.

src/chio-kernel.ts
import init, {
  evaluate,
  sign_receipt,
  sign_receipt_relaying_trusted_body,
  verify_capability,
  verify_capability_with_context,
  verify_receipt,
  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 seven entry points are now callable.
export {
  evaluate,
  sign_receipt,
  sign_receipt_relaying_trusted_body,
  verify_capability,
  verify_capability_with_context,
  verify_receipt,
  mint_signing_seed_hex,
};

Node and edge targets

The build script drives three wasm-pack targets — web, bundler, and nodejs. The Cloudflare Workers SDK (@chio-protocol/workers) is built from the bundler packages; the Vercel Edge and Deno SDKs (@chio-protocol/edge, @chio-protocol/deno) reuse the web package. This guide focuses on the browser target; the JavaScript API is identical across them.

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. Do not persist it to localStorage or any other durable store; closing the tab removes the capability from memory.

Limit the capability before giving it to the page

Give a browser tab only the authority needed 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. See Capabilities for the scope reference.

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 — once the capability checks pass — the verified capability identity. Because the browser build runs an empty guard pipeline, its top-level verdict only ever reads deny or pending_approval: a raw kernel allow is downgraded to pending_approval with authorized: false, because in-page evaluation confirms the capability but cannot itself authorize execution. Treat deny as the failure signal; the raw kernel decision is preserved separately on capability_verdict for diagnostics only.

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));

  // "deny" is the only terminal negative. A capability that passed the
  // signature, time, subject, and scope checks comes back as
  // "pending_approval" (authorized: false) — the in-page kernel cannot
  // itself authorize execution.
  if (verdict.verdict === "deny") {
    throw new Error(
      `denied: ${verdict.reason ?? "no reason given"}`,
    );
  }

  return verdict;
}

The verdict envelope is a plain JavaScript object:

typescript
interface EvaluationVerdict {
  // Browser authority decision. Never "allow" from an in-page evaluate:
  // a raw kernel allow is downgraded to "pending_approval".
  verdict: "deny" | "pending_approval";
  // Raw kernel capability + scope verdict before the downgrade.
  // Diagnostic only — not an execution-authorization signal.
  capability_verdict: "allow" | "deny" | "pending_approval";
  reason?: string;
  // Always false for in-page evaluation (no guard pipeline ran).
  authorized: boolean;
  // Machine-readable authorization state, e.g. "capability_only", "denied".
  authorization_basis: string;
  // Whether a guard pipeline participated. Always false here.
  guards_evaluated: boolean;
  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, behind a WYSIWYS gate. The public sign_receipt takes a JSON payload with two fields — the ChioReceiptBody and the canonical_content byte-array preimage that body.content_hash was derived from — plus a 32-byte Ed25519 seed. It rewrites the body's kernel_key to the seed's public key, recomputes sha256_hex(canonical_content) inside the signer, and refuses to sign (ContentHashMismatch) if that disagrees with body.content_hash, closing the render-A / sign-B gap. The preimage is required: omit it and the call fails closed with canonical_content_required. A caller that only relays an already-minted upstream body — with no preimage in hand — uses the separate sign_receipt_relaying_trusted_body entry point instead, which trusts the caller-supplied content_hash.

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,
  // The exact bytes content_hash was derived from. sign_receipt recomputes
  // sha256_hex(canonicalContent) and refuses if it disagrees with content_hash.
  canonicalContent: Uint8Array,
) {
  // 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 === "deny" ? "deny" : "allow",
    content_hash: contentHash,
    policy_hash: policyHash,
    evidence: [],
    trust_level: "mediated",
    kernel_key: null, // rewritten by sign_receipt
  };

  // canonical_content crosses the wasm-bindgen boundary as a JSON array of u8.
  const receipt = await sign_receipt(
    JSON.stringify({ body, canonical_content: Array.from(canonicalContent) }),
    seedHex,
  );
  return receipt;
}

The receipt is signed when the call returns. Choose a storage or forwarding pattern:

  • 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 browser kernel limits the authority available to a compromised tab.

Use these two rules:

  • 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
//   invalid_budget_snapshot
//   capability_verification_failed
//   canonical_content_required     // sign_receipt without a preimage
//   receipt_signing_failed
//   weak_entropy
//   webcrypto_unavailable
//   encode_result_failed
//   // verify_receipt-specific:
//   invalid_receipt_envelope
//   invalid_trusted_issuers
//   receipt_id_check_failed
//   parameter_hash_check_failed
//   signature_check_failed

Handle these two codes explicitly. 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