Chio/Docs
LOGIN · JOIN

BuildConnect

Run the Chio Kernel in the Browser

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

What the browser kernel does and does not do

The wasm build carries capability verification, evaluation, and the receipt signer. It does not carry guards: the browser entry points evaluate against an empty guard pipeline, which is why a capability-only success comes back as pending_approval rather than allow. There is no persistence layer either; a receipt the page signs is a value the page has to send somewhere. The managed JavaScript SDK, @chio-protocol/browser 0.1.0, re-exports verify_receipt and a verifyReceiptHex helper, so the rest of this guide calls the wasm-pack output directly.

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

  • A recent Rust with the wasm32-unknown-unknown target, and wasm-pack, which is the supported build path.
  • A capability minted somewhere else. Every browser entry point takes a signed token as input and none of them issues one, so the issuing side lives on a server. See capabilities for scopes, grants, and time bounds.
  • An agent runtime that lives in the page: a browser-side call loop, an extension background script, or a CopilotKit host.
  • For the signer specifically, a real browser. Seed minting and receipt signing go through the Web Crypto API and refuse outside a window, which the checks further down reproduce.

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 a ChioReceiptBody paired with the exact canonical_content preimage, plus an Ed25519 seed (typically minted via mint_signing_seed_hex()), and returns a signed ChioReceipt. A body without the preimage is refused rather than signed.
  • 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 browser evaluate call passes an empty guard list into the core, so a decision in the tab rests on capability scope and time bounds alone. Run guards on a sidecar.
  • 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:

browser-kernel · buildtranscript
$ wasm-pack build --target web --release crates/kernel/chio-kernel-browser \
    | grep -v 'newer version of wasm-pack'
[INFO]: 🎯  Checking for the Wasm target...
[INFO]: 🌀  Compiling to Wasm...
    Finished `release` profile [optimized] target(s) in 0.49s
[INFO]: License key is set in Cargo.toml but no LICENSE file(s) were found; Please add the LICENSE file(s) to your project directory
[INFO]: ✨   Done in 0.88s
[INFO]: 📦   Your wasm pkg is ready to publish at crates/kernel/chio-kernel-browser/pkg.
exit 0in ../../../home/connor/backbay/arc

The filter drops one line: wasm-pack's notice about its own newer release. The license line is the crate's, not the build's.

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.

browser-kernel · pkgtranscript
$ ls -1 pkg
README.md
chio_kernel_browser.d.ts
chio_kernel_browser.js
chio_kernel_browser_bg.wasm
chio_kernel_browser_bg.wasm.d.ts
package.json
exit 0in ../../../home/connor/backbay/arc/crates/kernel/chio-kernel-browser

chio_kernel_browser_bg.wasm is the compiled kernel, chio_kernel_browser.js the ES-module glue, the two .d.ts files the TypeScript declarations for the glue and for the module's raw exports, and package.json an 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 walks evaluate, sign_receipt and mint_signing_seed_hex. The other four cover trusted-body relay signing, single-authority capability verification, capability verification with trust roots and parent-budget snapshots, and receipt verification.

src/chio-kernel.tstypescript
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.rsrust
// 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::{
    scope::{ChioScope, Operation, ToolGrant},
    token::{CapabilityToken, CapabilityTokenBody},
};
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![],
        aggregate_invocation_budget: None,
    };
    CapabilityToken::sign(body, authority).expect("sign capability")
}

The capability submodules are the public API and the root has no flat re-export layer, so the import names the domain: grants and scopes come from capability::scope, the token and its body from capability::token.

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 by XSS, a malicious extension, or a 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 and the matched grant. Once the capability checks pass, it also carries 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.tstypescript
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 function authorize(call: ToolCall) {
  const envelope = {
    request: call,
    capability,
    trusted_issuers_hex: TRUSTED_ISSUERS_HEX,
  };

  const verdict = 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), because 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;
}

Every entry point is synchronous. Only init() is awaited, once, to fetch and instantiate the module; after that evaluate returns its verdict by value.

On a deny, reason names the failed check: capability has expired, capability issuer is not a trusted CA, no grant matched, and so on. These come from KernelCoreError::deny_reason, so they are stable enough to switch on. Note that a second set of near-identical strings exists on the verify_capability path, which reports the same failures with its own wording; match on the path you are actually calling.


Verify the result

The whole path is checkable from a terminal, with no browser and no page. Build the same crate for the node target and drive it against the signed capability vector the Chio source ships:

browser-kernel · build-nodetranscript
$ wasm-pack build --target nodejs --release crates/kernel/chio-kernel-browser \
    | grep -v 'newer version of wasm-pack'
[INFO]: 🎯  Checking for the Wasm target...
[INFO]: 🌀  Compiling to Wasm...
    Blocking waiting for file lock on package cache
    Blocking waiting for file lock on package cache
    Blocking waiting for file lock on package cache
    Finished `release` profile [optimized] target(s) in 0.99s
[INFO]: License key is set in Cargo.toml but no LICENSE file(s) were found; Please add the LICENSE file(s) to your project directory
[INFO]: ✨   Done in 1.40s
[INFO]: 📦   Your wasm pkg is ready to publish at crates/kernel/chio-kernel-browser/pkg.
exit 0in ../../../home/connor/backbay/arc
bk-probe.jsjavascript
const crypto = require("node:crypto");
const fs = require("node:fs");
const kernel = require(process.env.CHIO_BROWSER_PKG ?? "./pkg/chio_kernel_browser.js");

// serde-wasm-bindgen hands arbitrary JSON back as a JS Map, which
// JSON.stringify renders as {}. Convert before printing or serializing.
const plain = (value) =>
  value instanceof Map
    ? Object.fromEntries([...value].map(([k, v]) => [k, plain(v)]))
    : Array.isArray(value)
      ? value.map(plain)
      : value && typeof value === "object"
        ? Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plain(v)]))
        : value;

const show = (value) => console.log(JSON.stringify(plain(value), null, 2));
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");

const vectors = JSON.parse(fs.readFileSync(process.env.CHIO_CAPABILITY_VECTORS, "utf8"));
const capability = vectors.cases.find((c) => c.id === "valid_no_delegation_chain").capability;
const grant = capability.scope.grants[0];

const request = {
  request_id: "req-1",
  tool_name: grant.tool_name,
  server_id: grant.server_id,
  agent_id: capability.subject,
  arguments: { path: "/workspace/notes.md" },
};

const envelope = (overrides) =>
  JSON.stringify({
    request,
    capability,
    trusted_issuers_hex: [capability.issuer],
    clock_override_unix_secs: 1710000400,
    ...overrides,
  });

// A fixed seed keeps this reproducible. A browser mints one per receipt.
const SEED_HEX = "22".repeat(32);
const CANONICAL_CONTENT = Buffer.from(JSON.stringify({ shown: "to-the-human" }));
const PARAMETERS = { path: "/workspace/notes.md" };

const receiptBody = (drop) => {
  const body = {
    id: "browser-rcpt-1",
    timestamp: 1710000400,
    capability_id: capability.id,
    tool_server: grant.server_id,
    tool_name: grant.tool_name,
    action: { parameters: PARAMETERS, parameter_hash: sha256(JSON.stringify(PARAMETERS)) },
    decision: { verdict: "allow" },
    receipt_kind: "mediated_decision",
    boundary_class: "prevent",
    tool_origin: "caller_executed",
    redaction_mode: "none",
    content_hash: sha256(CANONICAL_CONTENT),
    policy_hash: "00".repeat(32),
    evidence: [],
    trust_level: "mediated",
    kernel_key: "00".repeat(32),
  };
  if (drop) delete body[drop];
  return JSON.stringify({ body, canonical_content: Array.from(CANONICAL_CONTENT) });
};

const attempt = (run) => {
  try {
    show(run());
  } catch (error) {
    show(JSON.parse(JSON.stringify(error, Object.getOwnPropertyNames(error ?? {}))));
  }
};

switch (process.argv[2]) {
  case "allow":
    show(kernel.evaluate(envelope()));
    break;
  case "untrusted":
    show(kernel.evaluate(envelope({ trusted_issuers_hex: ["11".repeat(32)] })));
    break;
  case "out-of-scope":
    show(kernel.evaluate(envelope({ request: { ...request, tool_name: "file_write" } })));
    break;
  case "seed":
    attempt(() => kernel.mint_signing_seed_hex());
    break;
  case "receipt":
    attempt(() => kernel.sign_receipt(receiptBody(), SEED_HEX));
    break;
  case "receipt-incomplete":
    attempt(() => kernel.sign_receipt(receiptBody("receipt_kind"), SEED_HEX));
    break;
}

Point CHIO_BROWSER_PKG at the glue file the build wrote and CHIO_CAPABILITY_VECTORS at tests/bindings/vectors/capability/v1.json in the Chio source, then run one mode at a time. The vector's capability grants file_read on srv-files with a /workspace/ path prefix, and the pinned clock sits inside its validity window. Everything passes, and the verdict still is not an allow:

browser-kernel · allowtranscript
$ node bk-probe.js allow
{
  "verdict": "pending_approval",
  "capability_verdict": "allow",
  "reason": "capability-only browser evaluation requires a mediated prevent receipt before execution",
  "authorized": false,
  "authorization_basis": "capability_only",
  "guards_evaluated": false,
  "matched_grant_index": 0,
  "subject_hex": "0b513ad9b4924015ca0902ed079044d3ac5dbec2306f06948c10da8eb6e39f2d",
  "issuer_hex": "66be7e332c7a453332bd9d0a7f7db055f5c5ef1a06ada66d98b39fb6810c473a",
  "capability_id": "cap-bindings-direct",
  "evaluated_at": 1710000400
}
exit 0

That is the whole shape of an in-page decision. capability_verdict is allow, the top-level verdict is pending_approval, authorized is false, and authorization_basis says capability_only. The five optional fields are populated because the capability verified.

Swap the trusted issuer set for a key that did not sign the token and the same call denies. Note which fields vanish: a capability that never verified has no identity to report.

browser-kernel · untrustedtranscript
$ node bk-probe.js untrusted
{
  "verdict": "deny",
  "capability_verdict": "deny",
  "reason": "capability issuer is not a trusted CA",
  "authorized": false,
  "authorization_basis": "denied",
  "guards_evaluated": false
}
exit 0deny

Ask for a tool the grant does not cover and the capability still verifies, so the identity fields survive, but the scope matcher refuses:

browser-kernel · out-of-scopetranscript
$ node bk-probe.js out-of-scope
{
  "verdict": "deny",
  "capability_verdict": "deny",
  "reason": "requested tool file_write on server srv-files is not in capability scope",
  "authorized": false,
  "authorization_basis": "denied",
  "guards_evaluated": false,
  "subject_hex": "0b513ad9b4924015ca0902ed079044d3ac5dbec2306f06948c10da8eb6e39f2d",
  "issuer_hex": "66be7e332c7a453332bd9d0a7f7db055f5c5ef1a06ada66d98b39fb6810c473a",
  "capability_id": "cap-bindings-direct",
  "evaluated_at": 1710000400
}
exit 0deny

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, plus a 32-byte Ed25519 seed. The payload carries the ChioReceiptBody and the canonical_content byte-array preimage that body.content_hash was derived from. The signer rewrites the body's kernel_key to the seed's public key, recomputes sha256_hex(canonical_content) itself, 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.tstypescript
import { sign_receipt, mint_signing_seed_hex } from "./chio-kernel.js";

export function recordDecision(
  verdict: EvaluationVerdict,
  call: ToolCall,
  policyHash: string,
  parameterHash: 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 = mint_signing_seed_hex();

  const body = {
    // Rewritten by the signer to the content-addressed id and kept as the
    // signing nonce, so pass something unique per receipt rather than a
    // constant.
    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, parameter_hash: parameterHash },
    decision: { verdict: verdict.verdict === "deny" ? "deny" : "allow" },
    receipt_kind: "mediated_decision",
    boundary_class: "prevent",
    tool_origin: "caller_executed",
    redaction_mode: "none",
    content_hash: contentHash,
    policy_hash: policyHash,
    evidence: [],
    trust_level: "mediated",
    // Rewritten by sign_receipt to the seed's public key, but the field is
    // not optional: send hex, not null.
    kernel_key: "00".repeat(32),
  };

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

Every field above is required. decision is an object keyed by verdict, not a bare string; action carries the parameters and their canonical hash together; and receipt_kind, boundary_class, tool_origin and redaction_mode classify the receipt rather than describing the call. Leave any of them out and the signer refuses at the parse step, naming the field:

browser-kernel · receipt-incompletetranscript
$ node bk-probe.js receipt-incomplete
{
  "code": "invalid_json_input",
  "message": "sign_receipt body: missing field `receipt_kind` at line 1 column 677"
}
exit 0

With a complete body the signer returns the receipt, having rewritten id to the content address, kernel_key to the seed's public key, and moved the id you supplied into metadata.chio_receipt_signing_nonce:

browser-kernel · receipttranscript
$ node bk-probe.js receipt
{
  "id": "55af714bcc1a7d1bd4973742a3cb96ab58b1a04067bd0c288e54bb5726eab3fd",
  "timestamp": 1710000400,
  "capability_id": "cap-bindings-direct",
  "tool_server": "srv-files",
  "tool_name": "file_read",
  "action": {
    "parameters": {
      "path": "/workspace/notes.md"
    },
    "parameter_hash": "d358b22b4ff6f0515ebb0f5219842ac08c57d468c8f1d6d6bec0927dafb706a2"
  },
  "decision": {
    "verdict": "allow"
  },
  "receipt_kind": "mediated_decision",
  "boundary_class": "prevent",
  "tool_origin": "caller_executed",
  "redaction_mode": "none",
  "content_hash": "96fb4bce475dfb30cc2543e50f7ed86f0c1ac601beebea2c9e87841e54009c02",
  "policy_hash": "0000000000000000000000000000000000000000000000000000000000000000",
  "metadata": {
    "chio_receipt_signing_nonce": "browser-rcpt-1"
  },
  "trust_level": "mediated",
  "kernel_key": "a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0",
  "signature": "0dccc87c83c86642825fc09603ed94c9483dc32ddd311bfaba9c781dda04efbe8accd72d4de2f838942c6424bc8c36133a77561225b3c532f6f8fb8297c88102"
}
exit 0allow

The receipt comes back with Maps inside it

serde-wasm-bindgen hands arbitrary JSON across the boundary as a JavaScript Map, and JSON.stringify renders a Map as {}. Post the returned receipt straight to a server and action.parameters arrives empty while parameter_hash still describes the real parameters, so verification fails on a receipt that was signed correctly. Convert Maps to plain objects before serializing.

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.

An ephemeral signer is not a trusted signer

The browser signs each receipt with a fresh per-call seed, so there is no long-term signing key in the page to steal. The cost is on the verifying side. verify_receipt sets ok only when the signature verifies, the parameter hash matches, the receipt id is the content address, and the receipt's kernel_key appears in a non-empty trusted-issuer set. A key minted seconds ago is in nobody's set, so a verifier pinning issuers returns ok: false and the first three checks true. Decide up front whether the receiving service pins issuers, in which case the page needs a delegated signer it can register, or treats the browser receipt as evidence rather than authority.

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
//   unsupported_authorization_extension   // evaluate, on a request carrying
//                                         // governed approvals or supplemental
//                                         // authorization
//   // 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 what every non-browser wasm host looks like. The node harness used above hits it on the first call:

browser-kernel · seedtranscript
$ node bk-probe.js seed
{
  "code": "webcrypto_unavailable",
  "message": "Web Crypto API unavailable: no Window global is available"
}
exit 0

That is the boundary between what a terminal can check and what only a page can do. Evaluation is pure and runs anywhere; minting a seed and signing a receipt need a real browser. Fall back to a server-side signer when you do not have one, not to a deterministic seed.


Failures and recovery

SymptomCause and recovery
Seed minting or signing throws webcrypto_unavailable.There is no window global, which is what a node or worker host looks like to this crate. Sign on a real page, or move signing to a server. Do not substitute a deterministic seed.
sign_receipt throws invalid_json_input naming a field.The body is missing a required field. Add it; nothing on the body is optional except the fields the signer itself fills in.
sign_receipt throws canonical_content_required.The payload had a body and no preimage. Send the exact bytes content_hash was derived from, or use the relay entry point if you are forwarding a body an upstream producer already minted.
A receipt verifies on three checks and still reports ok: false.The signer is not in the verifier's trusted-issuer set, and an ephemeral browser seed never will be. Pin a signer the page can reuse, or read the receipt as evidence rather than authority.
A posted receipt fails parameter-hash verification on the server.The returned action.parameters is a JavaScript Map and serialized as an empty object. Convert Maps to plain objects before sending.
evaluate throws unsupported_authorization_extension.The request carried a governed intent, an approval token, a threshold proposal, or supplemental authorization. The browser build cannot authenticate any of them; that request belongs on a full kernel.
A revoked capability is still accepted.Expected. There is no revocation store in the page. Keep capabilities short-lived and let expiry do the work.

Next Steps