diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index 2edc559c1d..2872c16d58 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -2923,6 +2923,18 @@ dependencies = [
"v8",
]
+[[package]]
+name = "codex-wasm-harness"
+version = "0.0.0"
+dependencies = [
+ "js-sys",
+ "pretty_assertions",
+ "serde",
+ "serde_json",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+]
+
[[package]]
name = "codex-windows-sandbox"
version = "0.0.0"
diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml
index d5a4baa4d5..a91e2f86be 100644
--- a/codex-rs/Cargo.toml
+++ b/codex-rs/Cargo.toml
@@ -52,6 +52,7 @@ members = [
"tui",
"tools",
"v8-poc",
+ "wasm-harness",
"utils/absolute-path",
"utils/cargo-bin",
"git-utils",
@@ -229,6 +230,7 @@ insta = "1.46.3"
inventory = "0.3.19"
itertools = "0.14.0"
jsonwebtoken = "9.3.1"
+js-sys = "0.3.85"
keyring = { version = "3.6", default-features = false }
landlock = "0.4.4"
lazy_static = "1"
@@ -335,6 +337,8 @@ urlencoding = "2.1"
uuid = "1"
vt100 = "0.16.2"
walkdir = "2.5.0"
+wasm-bindgen = "0.2.108"
+wasm-bindgen-futures = "0.4.58"
webbrowser = "1.0"
which = "8"
wildmatch = "2.6.1"
diff --git a/codex-rs/wasm-harness/.gitignore b/codex-rs/wasm-harness/.gitignore
new file mode 100644
index 0000000000..5c01e36ff1
--- /dev/null
+++ b/codex-rs/wasm-harness/.gitignore
@@ -0,0 +1 @@
+/examples/pkg/
diff --git a/codex-rs/wasm-harness/BUILD.bazel b/codex-rs/wasm-harness/BUILD.bazel
new file mode 100644
index 0000000000..e383cad250
--- /dev/null
+++ b/codex-rs/wasm-harness/BUILD.bazel
@@ -0,0 +1,6 @@
+load("//:defs.bzl", "codex_rust_crate")
+
+codex_rust_crate(
+ name = "wasm-harness",
+ crate_name = "codex_wasm_harness",
+)
diff --git a/codex-rs/wasm-harness/Cargo.toml b/codex-rs/wasm-harness/Cargo.toml
new file mode 100644
index 0000000000..6b5583e5b1
--- /dev/null
+++ b/codex-rs/wasm-harness/Cargo.toml
@@ -0,0 +1,23 @@
+[package]
+edition.workspace = true
+license.workspace = true
+name = "codex-wasm-harness"
+version.workspace = true
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+name = "codex_wasm_harness"
+path = "src/lib.rs"
+
+[lints]
+workspace = true
+
+[dependencies]
+js-sys = { workspace = true }
+serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true }
+wasm-bindgen = { workspace = true }
+wasm-bindgen-futures = { workspace = true }
+
+[dev-dependencies]
+pretty_assertions = { workspace = true }
diff --git a/codex-rs/wasm-harness/README.md b/codex-rs/wasm-harness/README.md
new file mode 100644
index 0000000000..da5eb38186
--- /dev/null
+++ b/codex-rs/wasm-harness/README.md
@@ -0,0 +1,46 @@
+# Codex WASM Harness Prototype
+
+This crate is the first browser-facing seam for a Codex harness prototype.
+
+It does not yet call `codex-core::run_turn` or `RegularTask::run`. Instead, it
+establishes the intended browser API shape:
+
+- submit a prompt from JavaScript;
+- stream Codex-shaped turn events back to the page; and
+- resolve after a `turn_complete` event.
+
+The sampler is currently a JavaScript callback so the browser demo can keep
+network and credential policy outside the WASM bundle. The demo page uses a
+deterministic local sampler by default, or a direct Responses API request when
+the user enters an API key.
+
+The API key field is for local prototype use only: it stores the key in browser
+`localStorage` and sends it directly from the page. A production browser
+integration should use a proxy or an ephemeral-token flow instead of persisting
+long-lived API keys in the page origin.
+
+The next step is to replace the callback boundary with a real model transport
+and then wire the facade to the Codex turn loop after host services are
+injectable.
+
+## Current Limitations
+
+This is a boundary prototype, not a port of `codex-core` yet.
+
+- It does not construct a `Session` or `TurnContext`.
+- It does not call `RegularTask::run` or `run_turn`.
+- It does not expose native Codex tools.
+- It emits Codex-shaped events, but not the full protocol event set.
+
+The immediate implementation value is that the browser API and demo page can be
+iterated independently while the host-heavy Codex services are moved behind
+browser-compatible traits.
+
+## Build Sketch
+
+```sh
+rustup target add wasm32-unknown-unknown
+codex-rs/wasm-harness/scripts/build-browser-demo.sh
+```
+
+Then serve `codex-rs/wasm-harness/examples` and open `/browser/index.html`.
diff --git a/codex-rs/wasm-harness/examples/browser/index.html b/codex-rs/wasm-harness/examples/browser/index.html
new file mode 100644
index 0000000000..b99969e800
--- /dev/null
+++ b/codex-rs/wasm-harness/examples/browser/index.html
@@ -0,0 +1,344 @@
+
+
+
+
+
+ Codex WASM Harness Prototype
+
+
+
+
+
Codex WASM harness prototype
+
+ This page exercises the browser boundary: submit a prompt, stream
+ Codex-shaped events from WASM, and resolve when the turn completes.
+
+
+
+
Responses API key
+
+ Local prototype only. The key is stored in this browser's
+ localStorage and is sent directly from this page to the Responses
+ API when you run a turn.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/codex-rs/wasm-harness/scripts/build-browser-demo.sh b/codex-rs/wasm-harness/scripts/build-browser-demo.sh
new file mode 100755
index 0000000000..9b39718880
--- /dev/null
+++ b/codex-rs/wasm-harness/scripts/build-browser-demo.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+crate_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+workspace_dir="$(cd "$crate_dir/.." && pwd)"
+
+cd "$workspace_dir"
+
+cargo build \
+ -p codex-wasm-harness \
+ --target wasm32-unknown-unknown
+
+wasm-bindgen \
+ --target web \
+ --out-dir "$crate_dir/examples/pkg" \
+ "$workspace_dir/target/wasm32-unknown-unknown/debug/codex_wasm_harness.wasm"
diff --git a/codex-rs/wasm-harness/src/lib.rs b/codex-rs/wasm-harness/src/lib.rs
new file mode 100644
index 0000000000..fb27e70da7
--- /dev/null
+++ b/codex-rs/wasm-harness/src/lib.rs
@@ -0,0 +1,224 @@
+//! Browser-facing prototype facade for a future Codex WASM harness.
+//!
+//! This crate intentionally starts outside `codex-core`: the first milestone is
+//! a working browser boundary that streams Codex-shaped turn events. Later
+//! iterations can replace the sampler callback with the real Codex
+//! `RegularTask` / `run_turn` path as host services become injectable.
+
+use js_sys::Function;
+use js_sys::Promise;
+use js_sys::Reflect;
+use serde::Deserialize;
+use serde::Serialize;
+use wasm_bindgen::JsCast;
+use wasm_bindgen::prelude::*;
+use wasm_bindgen_futures::JsFuture;
+
+const DEFAULT_INSTRUCTIONS: &str = "You are Codex running in a browser WASM prototype.";
+
+/// Browser entrypoint for the prototype harness.
+#[wasm_bindgen]
+pub struct BrowserCodex {
+ sampler: Option,
+ next_turn_id: u32,
+}
+
+#[wasm_bindgen]
+impl BrowserCodex {
+ /// Creates a new browser harness.
+ ///
+ /// `sampler` may be a JavaScript function that accepts a request object and
+ /// returns either a string, `{ message: string }`, or a Promise for either.
+ /// When omitted, the harness uses a deterministic local demo response.
+ #[wasm_bindgen(constructor)]
+ #[must_use]
+ pub fn new(sampler: JsValue) -> Self {
+ Self {
+ sampler: sampler.dyn_into::().ok(),
+ next_turn_id: 0,
+ }
+ }
+
+ /// Submits one browser turn and calls `on_event` for every emitted event.
+ ///
+ /// The method resolves after `turn_complete` has been emitted. This mirrors
+ /// Codex's event-driven shape rather than exposing a `prompt -> string`
+ /// shortcut.
+ pub async fn submit_turn(
+ &mut self,
+ prompt: String,
+ on_event: Function,
+ ) -> Result {
+ self.next_turn_id += 1;
+ let turn_id = format!("browser-turn-{}", self.next_turn_id);
+
+ emit_event(
+ &on_event,
+ &HarnessEvent::TurnStarted {
+ turn_id: turn_id.clone(),
+ model_context_window: None,
+ collaboration_mode_kind: "default",
+ },
+ )?;
+ emit_event(
+ &on_event,
+ &HarnessEvent::UserMessage {
+ turn_id: turn_id.clone(),
+ message: prompt.clone(),
+ },
+ )?;
+
+ let request = SamplingRequest::new(turn_id.clone(), prompt.clone());
+ let agent_message = self.sample(request).await?;
+
+ emit_event(
+ &on_event,
+ &HarnessEvent::AgentMessageDelta {
+ turn_id: turn_id.clone(),
+ delta: agent_message.clone(),
+ },
+ )?;
+ emit_event(
+ &on_event,
+ &HarnessEvent::AgentMessage {
+ turn_id: turn_id.clone(),
+ message: agent_message.clone(),
+ },
+ )?;
+ emit_event(
+ &on_event,
+ &HarnessEvent::TurnComplete {
+ turn_id,
+ last_agent_message: Some(agent_message.clone()),
+ },
+ )?;
+
+ Ok(JsValue::from_str(&agent_message))
+ }
+}
+
+impl BrowserCodex {
+ async fn sample(&self, request: SamplingRequest) -> Result {
+ let Some(sampler) = &self.sampler else {
+ return Ok(default_demo_response(&request.prompt));
+ };
+
+ let request_json = serde_json::to_string(&request).map_err(js_error)?;
+ let request_value = js_sys::JSON::parse(&request_json)?;
+ let sampled = sampler.call1(&JsValue::NULL, &request_value)?;
+ let resolved = JsFuture::from(Promise::resolve(&sampled)).await?;
+ extract_message(resolved)
+ }
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct SamplingRequest {
+ turn_id: String,
+ prompt: String,
+ instructions: &'static str,
+ tools: Vec,
+}
+
+impl SamplingRequest {
+ fn new(turn_id: String, prompt: String) -> Self {
+ Self {
+ turn_id,
+ prompt,
+ instructions: DEFAULT_INSTRUCTIONS,
+ tools: Vec::new(),
+ }
+ }
+}
+
+#[derive(Debug, Serialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+enum HarnessEvent<'a> {
+ TurnStarted {
+ turn_id: String,
+ model_context_window: Option,
+ collaboration_mode_kind: &'a str,
+ },
+ UserMessage {
+ turn_id: String,
+ message: String,
+ },
+ AgentMessageDelta {
+ turn_id: String,
+ delta: String,
+ },
+ AgentMessage {
+ turn_id: String,
+ message: String,
+ },
+ TurnComplete {
+ turn_id: String,
+ last_agent_message: Option,
+ },
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct SamplerResponse {
+ message: Option,
+}
+
+fn emit_event(on_event: &Function, event: &HarnessEvent<'_>) -> Result<(), JsValue> {
+ let json = serde_json::to_string(event).map_err(js_error)?;
+ let value = js_sys::JSON::parse(&json)?;
+ on_event.call1(&JsValue::NULL, &value)?;
+ Ok(())
+}
+
+fn extract_message(value: JsValue) -> Result {
+ if let Some(message) = value.as_string() {
+ return Ok(message);
+ }
+
+ if value.is_object() {
+ if let Some(message) = Reflect::get(&value, &JsValue::from_str("message"))?.as_string() {
+ return Ok(message);
+ }
+
+ let json = js_sys::JSON::stringify(&value)?;
+ if let Some(json) = json.as_string()
+ && let Ok(response) = serde_json::from_str::(&json)
+ && let Some(message) = response.message
+ {
+ return Ok(message);
+ }
+ }
+
+ Err(js_error(
+ "sampler must return a string or an object with a string message field",
+ ))
+}
+
+fn default_demo_response(prompt: &str) -> String {
+ if prompt.to_ascii_lowercase().contains("hello world") {
+ "Here is a minimal hello world example:\n\n```js\nconsole.log(\"hello world\");\n```"
+ .to_string()
+ } else {
+ format!(
+ "Browser Codex prototype received the prompt, but no model sampler was configured: {prompt}"
+ )
+ }
+}
+
+fn js_error(message: impl ToString) -> JsValue {
+ JsValue::from_str(&message.to_string())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::default_demo_response;
+ use pretty_assertions::assert_eq;
+
+ #[test]
+ fn default_demo_response_handles_hello_world() {
+ assert_eq!(
+ default_demo_response("write hello world"),
+ "Here is a minimal hello world example:\n\n```js\nconsole.log(\"hello world\");\n```"
+ );
+ }
+}