diff --git a/.github/workflows/actiond-prototype.yaml b/.github/workflows/actiond-prototype.yaml
new file mode 100644
index 0000000..ea1aac4
--- /dev/null
+++ b/.github/workflows/actiond-prototype.yaml
@@ -0,0 +1,77 @@
+name: actiond prototype
+
+on:
+ pull_request:
+ paths:
+ - 'experiments/actiond/**'
+ - '.github/workflows/actiond-prototype.yaml'
+
+permissions:
+ contents: read
+
+jobs:
+ chromium:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 30
+ env:
+ USE_BAZEL_VERSION: '9.2.0'
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
+ with:
+ node-version: '24'
+ - uses: bazel-contrib/setup-bazel@c5acdfb288317d0b5c0bbd7a396a3dc868bb0f86 # 0.19.0
+ with:
+ bazelisk-cache: true
+ repository-cache: true
+ - name: Prepare declared browser runtime
+ run: |
+ corepack enable pnpm
+ pnpm install --frozen-lockfile
+ docker pull --platform linux/amd64 mcr.microsoft.com/playwright:v1.63.0-noble@sha256:bc6ab0d6d44ff4826e4cb8c1e6d801e185bfc42bb0753f8e2a30efc70db054c7
+ bash experiments/actiond/prepare.sh "$RUNNER_TEMP/actiond-prototype"
+ - name: Build kernel with memory-advice syscalls
+ run: |
+ work="$RUNNER_TEMP/actiond-prototype"
+ git -C "$work/actiond" worktree add --detach "$work/kernel-source" c99b0cafc824faa59c2b04498eb1a6e5245b53e7
+ git -C "$work/kernel-source" apply "$GITHUB_WORKSPACE/experiments/actiond/actiond-advice.patch"
+ cd "$work/kernel-source"
+ bazelisk --output_base="$work/kernel-output" build --bes_backend= --remote_executor= --remote_cache= --spawn_strategy=local --jobs=2 --platforms=//platforms:linux_x86_64_musl //vm:linux_kernel.image > "$work/kernel-build.log" 2>&1
+ kernel=$(bazelisk --output_base="$work/kernel-output" cquery --bes_backend= --platforms=//platforms:linux_x86_64_musl //vm:linux_kernel.image --output=files)
+ cp "$kernel" "$work/patched-kernel"
+ - name: Enable KVM and vhost-vsock
+ run: |
+ test -c /dev/kvm
+ sudo chmod a+rw /dev/kvm
+ if [[ ! -e /dev/vhost-vsock ]]; then sudo modprobe vhost_vsock; fi
+ test -c /dev/vhost-vsock
+ sudo chmod a+rw /dev/vhost-vsock
+ - name: Capture inside the actiond VM
+ env:
+ ACTIOND_BAZEL: bazelisk
+ run: |
+ work="$RUNNER_TEMP/actiond-prototype"
+ curl -fsSL https://github.com/hermeticbuild/actiond/releases/download/v0.0.6/linux-actiond_linux_x86_64 -o "$work/actiond-worker"
+ printf '006dc798d4363596fe8ab997606fc93766a0cc427c2d005cf4fc1765fa4c2052 %s\n' "$work/actiond-worker" | sha256sum -c -
+ chmod +x "$work/actiond-worker"
+ "$work/actiond-worker" serve-vm --kernel="$work/patched-kernel" --root="$work/vm" --listen=127.0.0.1:8980 --memory-mib=3072 --cpus=2 --cas-image-size-mib=4096 > "$work/vm.log" 2>&1 &
+ worker_pid=$!
+ trap 'kill "$worker_pid" 2>/dev/null || true' EXIT
+ ready=false
+ for attempt in $(seq 1 90); do
+ kill -0 "$worker_pid"
+ if (echo > /dev/tcp/127.0.0.1/8980) 2>/dev/null; then ready=true; break; fi
+ sleep 1
+ done
+ "$ready"
+ bash experiments/actiond/run-actiond.sh "$work" grpc://127.0.0.1:8980
+ sha256sum "$work/results/first.png" "$work/results/second.png"
+ cmp "$work/results/first.png" "$work/results/second.png"
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ if: always()
+ with:
+ name: actiond-chromium-prototype
+ path: |
+ ${{ runner.temp }}/actiond-prototype/results/
+ ${{ runner.temp }}/actiond-prototype/vm.log
+ ${{ runner.temp }}/actiond-prototype/kernel-build.log
diff --git a/docs/actiond-evaluation.md b/docs/actiond-evaluation.md
new file mode 100644
index 0000000..5d1a50a
--- /dev/null
+++ b/docs/actiond-evaluation.md
@@ -0,0 +1,74 @@
+# actiond evaluation for VRT
+
+Source review on 2026-09-13 at upstream commit
+[`8a42c3d`](https://github.com/hermeticbuild/actiond/tree/8a42c3d481df3a1bf1b80e95a9bb991a207fc035).
+The initial review below is followed by a [runnable prototype](../experiments/actiond/README.md).
+The prototype subsequently rendered under actiond's unmodified process runner.
+Full VM/REAPI execution also passed on a GitHub KVM runner after enabling
+`CONFIG_ADVISE_SYSCALLS` in the guest kernel; see the prototype's linked CI evidence.
+
+## Assessment
+
+Promising optional execution backend; not a drop-in replacement for the current
+Testcontainers VRT implementation. Keep the current backend for AGI and FormatJS
+while evaluating a general remote-execution-compatible VRT path.
+
+## What improves
+
+The entire test action could execute inside Linux: Node, fixture server, browser,
+and comparison. actiond provides a Bazel REAPI executor/cache, immutable declared
+inputs, a VM boundary, and per-action loopback-only networking. The VM has no
+external network device. This would constrain consumer Node code as well as the
+browser and remove Docker/Ryuk from that execution path. These are architectural
+benefits, not a tested Chromium integration.
+[Architecture](https://github.com/hermeticbuild/actiond/blob/8a42c3d481df3a1bf1b80e95a9bb991a207fc035/ARCHITECTURE.md)
+
+## Integration gaps
+
+- **Architecture:** Apple Silicon gets an ARM64 Linux guest; Windows/Linux guests
+ match their host architecture. Our current VRT browser uses Linux amd64 on all
+ hosts. Linux alone does not establish pixel equivalence between ARM64 and amd64;
+ a common architecture or verified separate baseline policy remains necessary.
+ [VM topology](https://github.com/hermeticbuild/actiond/blob/8a42c3d481df3a1bf1b80e95a9bb991a207fc035/ARCHITECTURE.md#topology)
+- **Browser provisioning:** packaged runtimes contain selected glibc versions and
+ Bash. The executor selects those runtimes, rather than a caller-owned OCI image.
+ Chromium, additional shared libraries, fonts, and font configuration would need
+ declared packaging and a suitable launcher, or new runtime support.
+ [Runtime definitions](https://github.com/hermeticbuild/actiond/blob/8a42c3d481df3a1bf1b80e95a9bb991a207fc035/runtimes/BUILD.bazel),
+ [executor](https://github.com/hermeticbuild/actiond/blob/8a42c3d481df3a1bf1b80e95a9bb991a207fc035/src/action_executor.zig#L305-L331)
+- **Rule changes:** our browser tests carry `no-remote` and `no-cache`; VRT starts
+ Testcontainers unconditionally. An executor backend must launch a declared
+ browser within the action and run the fixture server in that same action.
+ [Current rules](../internal/browser.bzl), [runner](../runtime/runner.ts)
+- **Baseline updates:** `.update` currently copies into `BUILD_WORKSPACE_DIRECTORY`.
+ Remote capture must instead produce downloadable outputs, with a small local
+ wrapper applying them to the source workspace. Merely enabling remote execution
+ does not move the final `bazel run` executable into the executor.
+ [Baseline handling](../runtime/baselines.ts), [runner](../runtime/runner.ts)
+- **Chromium compatibility:** the action sandbox rejects installing additional
+ seccomp filters. Chromium sandbox behavior needs a smoke test; do not assume its
+ normal sandbox works unchanged. Time and randomness still require test control.
+ [Sandbox implementation](https://github.com/hermeticbuild/actiond/blob/8a42c3d481df3a1bf1b80e95a9bb991a207fc035/src/action_runner.zig#L817-L854)
+- **Provisioning:** actiond replaces the Docker prerequisite with its own VM worker.
+ Linux requires KVM/vhost-vsock access and io_uring; Windows requires Hyper-V.
+ Linux-compatible Bazel toolchains must also be configured.
+ [Setup](https://github.com/hermeticbuild/actiond/blob/8a42c3d481df3a1bf1b80e95a9bb991a207fc035/README.md#start-the-worker)
+
+## Prototype follow-up
+
+The prototype supplies its own ELF loader alongside Chromium and its libraries,
+so no embedded glibc selection or OCI runtime support is needed for the smoke
+action. It renders with the existing seccomp filter and no `/dev/shm` changes,
+using `chromiumSandbox: false` and `--no-zygote`. The real VM initially failed
+because `madvise` was absent (`ENOSYS`). Enabling `CONFIG_ADVISE_SYSCALLS=y`
+resolved the syscall probe and screenshot failure; downloaded VM PNGs matched
+the local result byte-for-byte. See its README for exact limits.
+
+## Next experiment
+
+Build one existing editor screenshot case as an ordinary Linux action with a
+fully declared Chromium runtime. Run capture and comparison inside actiond,
+return a PNG through Bazel outputs, and test local baseline application. Verify
+Chromium startup, fonts, isolation, cancellation, and repeated captures. Compare
+ARM64 and amd64 outputs explicitly before considering shared baselines. Keep the
+interface REAPI-compatible so callers can choose actiond or another executor.
diff --git a/experiments/actiond/BUILD.bazel.template b/experiments/actiond/BUILD.bazel.template
new file mode 100644
index 0000000..9f6771f
--- /dev/null
+++ b/experiments/actiond/BUILD.bazel.template
@@ -0,0 +1,19 @@
+load(":capture.bzl", "capture", "kernel_probe")
+
+platform(
+ name = "linux_amd64",
+ constraint_values = ["@platforms//os:linux", "@platforms//cpu:x86_64"],
+)
+
+capture(
+ name = "capture",
+ node = "runtime/bin/node",
+ script = "capture.mjs",
+ runtime = ["ld.so"] + glob(["runtime/**"]),
+ playwright = glob(["playwright-core/**"]),
+)
+
+kernel_probe(
+ name = "kernel_probe",
+ binary = "kernel-probe",
+)
diff --git a/experiments/actiond/README.md b/experiments/actiond/README.md
new file mode 100644
index 0000000..42b1569
--- /dev/null
+++ b/experiments/actiond/README.md
@@ -0,0 +1,81 @@
+# actiond Chromium prototype
+
+Throwaway experiment, not a replacement for the production VRT backend.
+
+The action runs a Node HTTP fixture, Chromium, and screenshot comparison together.
+Node, Playwright, Chromium, shared libraries, fonts, and even the ELF loader are
+ordinary declared inputs. No OCI API, injected glibc runtime, Testcontainers,
+Docker socket, or Ryuk is used inside the action.
+
+## Reproduce
+
+Setup requires Linux amd64, Python 3, curl, Git, Docker, and the repository's
+installed pnpm dependencies. Preload the Playwright image pinned in `prepare.sh`.
+Docker only extracts the image during preparation; the action consumes files.
+
+```sh
+bash experiments/actiond/prepare.sh /tmp/actiond-prototype
+# With an existing amd64 actiond worker:
+bash experiments/actiond/run-actiond.sh /tmp/actiond-prototype grpc://127.0.0.1:8980
+```
+
+`run-actiond.sh` invokes a standalone Bazel action with local fallback disabled.
+It downloads declared PNG outputs into `/tmp/actiond-prototype/results`; source
+baselines are untouched. `ACTIOND_BAZEL` may select the Bazel executable.
+
+Without KVM, test the actual actiond process runner separately:
+
+```sh
+# Also preload the Ubuntu image pinned in run-sandbox.sh.
+bash experiments/actiond/run-sandbox.sh /tmp/actiond-prototype
+```
+
+That diagnostic uses a disposable container to grant namespace/mount privileges.
+Inside it, the unmodified actiond runner applies its own chroot, namespaces,
+uid/gid drop, and seccomp filter. This does **not** exercise the VM, REAPI, CAS
+input filesystem, or Bazel output collection. The Docker helper is a diagnostic,
+not the proposed deployment architecture.
+
+## Local results
+
+- Unmodified actiond runner source at `8a42c3d481df3a1bf1b80e95a9bb991a207fc035`.
+- Chromium `153.0.8010.12` / Playwright `1.63.0`, Linux amd64.
+- `chromiumSandbox: false` and `--no-zygote` produced matching 640x360 PNGs across
+ fresh invocations. The default zygote path failed with child/GPU startup errors;
+ its root cause is not established. `--zygote` reproduces that diagnostic.
+- `--sandbox` failed with “No usable sandbox” in the nested container harness;
+ this does not establish which restriction a real VM would hit first.
+- Fixture HTTP and font loading succeeded; a direct external TCP connection
+ failed with `ENETUNREACH`. No `/dev/shm`, Docker socket, or extra device mounts.
+- PNG: 11,389 bytes, SHA-256
+ `498f5f17cc8af0437eeabf78db5c6079fd1d3f3009012537d5d0dc9ee9244362`.
+- Bazel aquery resolves Linux amd64 and lists the loader, browser, libraries,
+ fonts, and Playwright files as inputs, with PNGs in a declared TreeArtifact.
+
+## VM kernel finding
+
+The released actiond `v0.0.6` VM boots and accepts the Bazel action, but Node aborts
+in V8's `DiscardSystemPages`. The minimal `kernel-probe.c` action independently
+returns `madvise(MADV_DONTNEED): errno=38 (Function not implemented)`.
+[Failing VM run](https://github.com/perplexityai/rules_web_e2e/actions/runs/34775725126).
+
+Both actiond kernel configs use `allnoconfig` and omit `CONFIG_ADVISE_SYSCALLS`.
+`actiond-advice.patch` enables it for ARM64 and amd64. The prototype workflow
+rebuilds the exact `v0.0.6` kernel source with this patch and passes it to the
+released worker through `--kernel`. It performs all compilation locally on the
+GitHub runner; no BuildBuddy upload or remote build service is used.
+
+The patched main-branch kernel also builds locally. The patched release kernel
+passed the syscall probe and screenshot action through real Bazel REAPI execution
+with local fallback disabled. Both downloaded PNGs match the local hash above.
+[Passing VM run](https://github.com/perplexityai/rules_web_e2e/actions/runs/34775967325).
+The local host has no `/dev/kvm`; KVM validation ran on GitHub's Ubuntu runner.
+
+## Decision
+
+The process-level proof requires **no actiond userspace changes**. The VM proof
+requires the memory-advice kernel fix above. Before adding OCI runtime support
+or relaxing seccomp, validate an existing editor fixture on the patched VM. Production integration still needs an optional executor
+backend, reviewed baseline-update handling, amd64 worker selection on Apple
+Silicon, and cleanup/isolation coverage. One stable fixture is not evidence of
+cross-architecture pixel equivalence or full Chromium compatibility.
diff --git a/experiments/actiond/actiond-advice.patch b/experiments/actiond/actiond-advice.patch
new file mode 100644
index 0000000..48c833c
--- /dev/null
+++ b/experiments/actiond/actiond-advice.patch
@@ -0,0 +1,22 @@
+diff --git a/vm/linux.config b/vm/linux.config
+index 69abcd9..1b6bffe 100644
+--- a/vm/linux.config
++++ b/vm/linux.config
+@@ -1,5 +1,6 @@
+ CONFIG_64BIT=y
+ CONFIG_ACTIONDFS_FS=y
++CONFIG_ADVISE_SYSCALLS=y
+ CONFIG_ACPI=y
+ CONFIG_ARM64=y
+ CONFIG_SMP=y
+diff --git a/vm/linux_x86_64.config b/vm/linux_x86_64.config
+index 278a71f..8bcd5a7 100644
+--- a/vm/linux_x86_64.config
++++ b/vm/linux_x86_64.config
+@@ -1,5 +1,6 @@
+ CONFIG_64BIT=y
+ CONFIG_ACTIONDFS_FS=y
++CONFIG_ADVISE_SYSCALLS=y
+ CONFIG_ACPI=y
+ CONFIG_HPET_TIMER=y
+ CONFIG_X86_PM_TIMER=y
diff --git a/experiments/actiond/capture.bzl b/experiments/actiond/capture.bzl
new file mode 100644
index 0000000..8c82d7d
--- /dev/null
+++ b/experiments/actiond/capture.bzl
@@ -0,0 +1,46 @@
+"""Throwaway REAPI-compatible screenshot action; no Docker or Testcontainers."""
+
+def _capture_impl(ctx):
+ out = ctx.actions.declare_directory("screenshots")
+ ctx.actions.run(
+ executable = ctx.file.node,
+ arguments = [ctx.file.script.path],
+ inputs = depset(ctx.files.runtime + ctx.files.playwright + [ctx.file.script]),
+ outputs = [out],
+ env = {
+ "HOME": "/tmp",
+ "TMPDIR": "/tmp",
+ "LANG": "C.UTF-8",
+ "TZ": "UTC",
+ "FONTCONFIG_PATH": "/workspace/runtime/etc/fonts",
+ "LD_LIBRARY_PATH": "/workspace/runtime/lib",
+ "OUTPUT_DIR": out.path,
+ },
+ mnemonic = "ActiondChromiumSmoke",
+ )
+ return [DefaultInfo(files = depset([out]))]
+
+capture = rule(
+ implementation = _capture_impl,
+ attrs = {
+ "node": attr.label(allow_single_file = True),
+ "script": attr.label(allow_single_file = True),
+ "runtime": attr.label_list(allow_files = True),
+ "playwright": attr.label_list(allow_files = True),
+ },
+)
+
+def _kernel_probe_impl(ctx):
+ out = ctx.actions.declare_file("kernel-probe.txt")
+ ctx.actions.run(
+ executable = ctx.file.binary,
+ arguments = [out.path],
+ outputs = [out],
+ mnemonic = "ActiondKernelProbe",
+ )
+ return [DefaultInfo(files = depset([out]))]
+
+kernel_probe = rule(
+ implementation = _kernel_probe_impl,
+ attrs = {"binary": attr.label(allow_single_file = True)},
+)
diff --git a/experiments/actiond/capture.mjs b/experiments/actiond/capture.mjs
new file mode 100644
index 0000000..60b5b70
--- /dev/null
+++ b/experiments/actiond/capture.mjs
@@ -0,0 +1,48 @@
+// Prototype: all HTTP traffic, browser work and screenshot outputs stay in one action.
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import http from 'node:http';
+import path from 'node:path';
+import net from 'node:net';
+import { chromium } from './playwright-core/index.mjs';
+
+assert.equal(process.platform, 'linux');
+assert.equal(process.arch, 'x64');
+assert.equal(fs.existsSync('/dev/shm'), false);
+assert.equal(fs.existsSync('/var/run/docker.sock'), false);
+const output = path.resolve(process.env.OUTPUT_DIR || '/workspace/outputs');
+fs.mkdirSync(output, { recursive: true });
+const html = `