BuildConnect
Run Chio in the Browser
Compile Chio's capability checks and receipt signer to WebAssembly for a browser tab.
Lifecycle
@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
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_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 capability path — signature, time, subject binding, scope match. Because it runs an empty guard pipeline and cannot itself authorize execution, its top-levelverdictis neverallow: a raw kernel allow is downgraded topending_approval(withauthorized: false), leavingdenyas the only terminal negative outcome. - 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.
The browser build needs external durable state
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.
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:
# From the Chio repo root
$ wasm-pack build --target web --release crates/kernel/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 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/
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 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.
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
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-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
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.
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:
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
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.
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
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
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
// 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_failedHandle 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
- Portable Kernel · the core / shell split and 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 · reference for the JavaScript API, wire shapes, and error codes