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
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
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_capabilitychecks the signature, walks the trusted issuer set, and confirms the time-bound interval is live againstDate.now(). - Scope resolution: the kernel-core scope matcher decides whether a requested tool call falls inside a grant on the capability.
- Evaluation:
evaluateruns the full sync verdict path — signature, time, subject binding, scope match — and returnsallow,deny, orpending_approvalalong with a machine-readable reason. - Receipt signing:
sign_receiptaccepts anChioReceiptBodyand an Ed25519 seed (typically minted viamint_signing_seed_hex()) and returns a signedChioReceipt. - Web Crypto entropy: the
WebCryptoRngadapter fills buffers fromwindow.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
evaluatecall 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
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.
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:
# From the chio repo root
$ wasm-pack build --target web --release crates/chio-kernel-browserwasm-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/
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 manifestImport 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.
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
--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-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
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.
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:
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
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.
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
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
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:
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_failedTwo 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
- Portable Kernel · the design rationale behind the core / shell split and the full target matrix
- Architecture — Deployment Modes · side-by-side comparison of sidecar, browser, edge, and mobile deployments
- Rotate Keys & Revoke · how to handle revocation for capabilities the browser already holds
- Bindings API · full reference for the JS surface, wire shapes, and error codes