RFD 0001: Three-tier plugin architecture¶
Summary¶
Samuel v2 ships plugins in three tiers, each chosen to match a real use case rather than forcing one transport on all plugins:
- Skill plugins — text + assets only. No executable code. Distributed as Git repos / tar archives.
- WASM plugins — sandboxed by an embedded
wazeroruntime in the Samuel binary. No host runtime required. Default for executable plugins. - OCI plugins — full Linux userland in a container. Requires Docker/Podman on host. Used when the plugin needs to invoke an external coding assistant binary (Claude Code, Codex CLI), wrap a language-specific tool, or perform work that can't fit WASI.
All three implement the same Plugin interface. The orchestrator code doesn't know which tier a plugin came from. Tier selection is per-plugin, declared in the manifest, and users see consistent install/uninstall/health UX regardless.
Problem statement¶
v2 is "framework + skills hub" ([[../../wiki/synthesis/positioning-rails-for-coding-assistants]]). The framework is small; capability ships as plugins. This requires a plugin transport and execution model that:
- Supports text-only plugins efficiently. ~80% of v1's content is SKILL.md files — language guides, framework guides, workflow documentation. These need no execution at all. Forcing them through a runtime is wasteful.
- Sandboxes executable plugins by default. Most executable plugins (validators, transformers, hook handlers) need filesystem reads, optional writes, no network. They shouldn't require Docker.
- Enables full-power execution where needed. Some plugins run external binaries (Claude Code is a Node.js CLI; tools like aider need Python). These need real Linux userland.
- Embeds the common-case runtime. If most executable plugins need Docker, "Samuel works out of the box" becomes false the moment a user installs anything.
- Doesn't lock plugin authors into one tool stack. Plugin authors should be able to write in TinyGo, Rust, JavaScript, or ship a Docker image, per what fits their task.
- Stays agnostic. Per [[../../wiki/concepts/agnostic-by-design]], no tier privileges Claude over Codex or vice versa.
Requirements¶
- Text plugins install with zero runtime dependencies on the user's host.
- Executable plugins run sandboxed by default. No filesystem access outside the project workspace unless explicitly granted.
- Container runtime is required only for plugins that genuinely need it (and the user can see in advance which plugins those are).
- One uniform contract via the
Plugininterface ([[0005|RFD 0005]]). - All three tiers support cosign signature verification ([[0003|RFD 0003]]).
- Plugin authors can write plugins in any language that targets one of the three tiers.
Constraints¶
- Samuel ships as a single Go binary. Anything we ship "embedded" goes into that binary.
- The user's
samuel install <plugin>must work without prompting for a runtime install if the plugin doesn't need one. - Cross-platform: Linux + macOS first-class; Windows must work for at least the skill + WASM tiers.
- Plugin authoring should be teachable in a single page per tier.
Background¶
v1's model and why it doesn't scale¶
v1 has no plugin system. Skills are static slices of structs in internal/core/registry.go ([[../../wiki/entities/registry]]). Adding a skill requires editing Go source and rebuilding. The Skills, Languages, Frameworks, Workflows slices are mirrors of the same content with naming gymnastics (go → go-guide). Every component name is hardcoded.
v1 does have a sandbox layer ([[../../wiki/entities/docker-sandbox]]) — five agents (Claude, Codex, Copilot, Gemini, Kiro) wired through Docker / Docker Sandbox modes, with per-agent prompt translation. This is the seed of v2's executable-plugin model, but limited to running coding assistants, not arbitrary plugins.
Industry plugin formats surveyed¶
Hashicorp go-plugin — RPC-over-Unix-socket. Each plugin is a separate process. Used by Terraform, Vault. Mature but heavy: every plugin spawns a process for every invocation. Wrong fit for text-only plugins (~80% of our content) and overlapping with what OCI already gives us.
WebAssembly (WASM) + WASI — sandbox by design, embeddable in-process via runtimes like wazero (pure Go), wasmtime (Rust-based), wasmer. Increasingly mainstream. Used by Envoy filters, Tetragon, Shopify Functions, Spin. Active spec evolution (Component Model, WASI Preview 2). Mature enough for production.
OCI images — Docker / Podman containers. Universal "ship anything Linux" mechanism. Standard tooling (Docker Hub, GHCR), standard distribution. Requires container runtime. Strong sandbox by default (namespaces, cgroups, seccomp). Good for plugins that need full Linux userland; overkill for plugins that just process a text file.
Native plugins — Go's plugin package (.so files), Python imports, Node.js requires. Strong typing, native speed. Brittle in practice: Go's plugin famously requires exact toolchain version matching and is single-OS. Not a real option for a multi-platform CLI.
Tar archive + manifest — Homebrew taps, Cargo crates (binary subset), pip wheels. Lightest possible — just files and a manifest. Zero sandbox; user has to trust the publisher. Right fit for text/static content.
Single-binary plugins via subprocess — invoke a per-platform native binary. Fast, simple, no runtime needed. But: cross-compilation burden, separate distribution per OS/arch, no built-in sandbox. Doesn't scale to many plugin authors.
The agent-execution requirement¶
The single most demanding plugin use case is running an external coding assistant inside a sandbox. v2's flagship workflow is samuel run ([[0006|RFD 0006]]) which invokes Claude Code, Codex, or another assistant in a controlled environment. These binaries are:
- Node.js (Claude Code) → needs Node + npm
- Rust + Python (some Codex setups) → needs whichever runtime they require
- Possibly Docker-only as packaged by the upstream vendor
This can't be a WASM plugin — Node.js doesn't run under WASI cleanly in 2026. It must be OCI or it must shell out to a host-installed binary (which defeats the sandboxing goal).
This single requirement is why OCI is in the model.
The "embeddable runtime" insight¶
Samuel can embed WebAssembly but not Linux containers. Containers need Linux kernel features (cgroups, namespaces, seccomp). On macOS or Windows, a "container" is actually a Linux VM (Docker Desktop, Podman Machine, lima, colima). Samuel can't ship a hundreds-of-MB Linux VM in a Go binary.
WASM is different. wazero is pure Go, BSD-3 licensed, embeds cleanly. The Samuel binary gains WASM sandboxing capability for ~5MB of additional binary size. Plugin authors compile to a .wasm file; the user installs and runs it without any host runtime.
This makes WASM the right default for executable plugins, and OCI the explicit opt-in for "I need real Linux."
Options considered¶
Option A: Three-tier (skill / WASM / OCI) — chosen¶
Three transports, three execution models, one Plugin interface.
| Tier | Transport | Execution | Sandbox | Host dep | Use case |
|---|---|---|---|---|---|
| Skill | Git repo / tar | None (text only) | N/A | None | SKILL.md content, language guides, prompt templates |
| WASM | Git-fetched .wasm blob | wazero (in samuel binary) | wazero (in-process, capability-gated) | None | Validators, transformers, hook handlers, translator plugins |
| OCI | docker pull / podman pull | Container runtime | Container (kernel namespaces) | Docker/Podman | Coding assistant runners, language-specific tools, anything needing Linux userland |
The plugin manifest's kind field declares which tier; the loader picks the matching implementation.
Pros: - Each tier solves a real problem with the right amount of complexity. - Skill tier installs work with zero host dependencies — the common case (text content) stays simple. - WASM tier installs work with zero host dependencies — most executable plugins fit here. - OCI tier is available for the genuinely demanding case (running external coding assistants). - Plugin authors choose per their need: text → skill, code → WASM, complex environment → OCI. - Containerization for the OCI tier brings real Linux sandboxing. - Embedded wazero brings real WASM sandboxing without external dependencies. - All three implement the same Plugin interface ([[0005|RFD 0005]]) — orchestrator code doesn't fork.
Cons: - Three implementations to maintain in internal/plugin/{skill,wasm,oci}/. - Plugin authors have to choose a tier — three docs sets, three "hello world" guides. - Cross-tier mental model: a user installing react (skill) and claude-runner (OCI) sees different post-install behavior (one drops files, one downloads an image).
Effort: Medium. Skill tier is the simplest (~1 week). WASM tier needs wazero host-function bindings and capability enforcement (~2 weeks). OCI tier reuses v1's docker.go substantially (~1.5 weeks). All three land in PRD 0003 (Milestone 3).
Option B: Two-tier — skill + OCI only (drop WASM)¶
Text plugins stay; everything executable goes OCI.
Pros: - Simpler model. One execution sandbox to learn and document. - OCI's tooling ecosystem (Docker Hub, GHCR, cosign, SBOM) is more mature than WASM's. - Container sandboxing is widely understood — namespaces, seccomp, capabilities map to standard mental models.
Cons: - Every executable plugin requires Docker or Podman on the host. This violates the "samuel works out of the box" requirement. A user who installs a single transformer plugin gets prompted to install Docker. - Container startup time (~1-3 seconds even for cached images) is multiplied across plugin invocations. A sync.after hook that runs on every samuel sync call would feel laggy. - Memory overhead: a running container is hundreds of MB; a wazero module is single-digit MB. - Plugin authors must Dockerize trivial tools (a 50-line TypeScript translator becomes a Dockerfile + base image + push). - Loses the "lean default" story.
Effort: Lower (~30% less than three-tier). But the UX regression is real.
Option C: Two-tier — skill + WASM only (drop OCI)¶
Text plugins + WASM-only execution.
Pros: - Cleanest, most embedded model. No host dependencies for any plugin. - One execution sandbox. - WASM cold-start is fast (sub-100ms).
Cons: - Can't run external coding assistants. Claude Code is a Node.js CLI; it doesn't run under WASI in 2026. Codex similarly. The samuel run flagship use case fundamentally needs container-or-equivalent isolation when invoking these. - WASI (especially Preview 1) has gaps — no proper subprocess support, limited socket APIs, no syscalls beyond what WASI declares. Plugins that wrap real CLI tools (go vet, pytest, language servers) can't fit. - Forces plugin authors to compile-to-WASM even for plugins that just shell out to a known tool. Hostile UX. - The agent-sandbox use case ([[../../wiki/entities/docker-sandbox]]) has no alternative — we'd be regressing from v1's existing capability.
Effort: Lower. But the use cases lost are non-negotiable.
Option D: Single-tier OCI — everything is a container¶
Even text-only plugins ship as OCI images. The image is the unit of distribution.
Pros: - One transport, one verification path (cosign + image digest), one place to manage versions. - OCI registries are standard infrastructure. - Tagging, multi-arch, SBOM all come free.
Cons: - Text plugins ship as images? A 10KB SKILL.md becomes a 100MB container image (alpine base + the markdown). Absurd overhead. - Every user must have Docker/Podman to install anything. - Defeats the "framework is small, plugins are lightweight" story. - Skill plugins have nothing to "execute" — building an image around them is a pure waste.
Effort: Lower in code count but higher in user friction. Net negative.
Option E: Single-tier subprocess (Hashicorp go-plugin style)¶
Every plugin is a binary that samuel invokes over RPC. No skill tier (text plugins are just data, fetched per-plugin).
Pros: - Battle-tested in Terraform / Vault. - Plugins can be written in any language with RPC bindings. - Process isolation provides some sandboxing.
Cons: - Process-per-plugin is wasteful for text-only plugins (which need no process at all). - Plugin authors must build per-OS, per-arch binaries. Cross-compilation burden. - Process startup overhead per invocation. - No native sandbox — relies on OS-level controls the framework would need to add. - Doesn't model Detect / Check cleanly (those are local-state queries, not RPC calls). - Misaligned with our three actual use cases.
Effort: Medium. Wrong fit.
Option F: Native plugins (.so / .dll)¶
Plugin authors build shared objects loaded at runtime.
Pros: - Fastest possible execution. - Strong typing.
Cons: - Go's plugin package is famously fragile. Must match toolchain version exactly. Linux + macOS only; no Windows. - No sandbox. A buggy plugin can crash samuel. - Distribution requires per-OS, per-arch builds. - Versioning hell.
Rejected by the community for years. Not a real option.
Decision¶
Adopt Option A: three-tier plugin architecture (skill / WASM / OCI).
The decision rests on five judgments:
-
The three use cases are real and distinct. Text content has no execution needs. Most executable plugins need sandboxing but not full Linux. A small but non-negotiable set (coding assistant runners) needs full Linux. Forcing them all through one tier wastes effort somewhere.
-
WASM as the embeddable default unlocks "samuel works without Docker." wazero is pure Go, BSD-3, embeds in ~5MB of binary. We get sandboxing for free for the common case. This is the design's key insight.
-
OCI stays in scope only where unavoidable. The user who only installs skill plugins and WASM plugins never sees a Docker prompt. The user who installs
claude-runnerdoes — and at that point, they've opted into the heaviest use case explicitly. -
One
Plugininterface across all three keeps the framework small. Per [[0005|RFD 0005]], the orchestrator code doesn't know which tier it's operating on. Adding a fourth tier later (subprocess? gRPC?) is a localized change. -
Plugin authors get to pick. The right amount of complexity for their task. A TinyGo developer writes a WASM plugin in a day. A Docker user wraps an existing CLI in an image. A documentation author drops a SKILL.md and never touches code.
Implementation plan¶
Phase 1 — manifest defines the tier (PRD 0002, week 4)¶
The samuel-plugin.toml schema declares which tier a plugin uses ([[0003|RFD 0003]] specifies the manifest in detail):
name = "go-guide"
version = "1.4.2"
kind = "skill" # "skill" | "wasm" | "oci"
[capabilities]
filesystem = { read = ["/workspace"] }
# kind = "wasm" only:
[wasm]
module = "plugin.wasm"
exports = ["init", "run"]
runtime = { fuel = 100_000_000, memory_pages = 256 }
# kind = "oci" only:
[oci]
image = "ghcr.io/samuelpkg/samuel-runner-claude:1.0.0"
# digest pinned at install time, written to samuel.lock
[provides]
skills = ["go-guide"]
commands = []
hooks = []
The plugin loader reads kind first, dispatches to the appropriate tier implementation.
Phase 2 — skill tier (PRD 0003, week 1)¶
internal/plugin/skill/ implements Plugin for text-only plugins:
- Install:
git clonethe plugin repo at the resolved tag → verify cosign signature on the archive → copySKILL.md+scripts/+references/+assets/to<project>/.samuel/plugins/<name>/→ record mutations insamuel.lock. - Detect: check
<project>/.samuel/plugins/<name>/SKILL.mdexists. - Check: validate SKILL.md frontmatter, confirm
namematches directory. - Uninstall: remove the directory, reverse mutations.
Zero runtime. Trivial.
Phase 3 — WASM tier (PRD 0003, week 2)¶
internal/plugin/wasm/ embeds wazero (already pure Go):
- Install: fetch the
.wasmmodule via Git → verify cosign signature on the blob → store at<project>/.samuel/plugins/<name>/plugin.wasm→ record content hash insamuel.lock. - Detect: check the
.wasmexists. - Check: instantiate the module, call its
health()export, expect0for healthy. - Uninstall: remove the plugin directory, evict any cached module.
Host functions (the wazero "imports" the WASM module can call) are gated by the manifest's [capabilities] block:
samuel.fs.read(path) → if path within capabilities.filesystem.read
samuel.fs.write(path, bytes) → if path within capabilities.filesystem.write
samuel.exec(cmd, args) → if capabilities.exec == true
samuel.net.outbound(host) → if host in capabilities.network.outbound
samuel.log(level, message) → always available
samuel.config.get(key) → reads from samuel.toml
samuel.callback(hook, json) → invokes Samuel's own API
Plugins call these via wazero's import resolution. Unauthorized calls return error codes; the WASM module can choose how to handle.
Cold-start budget: < 50ms per plugin invocation. Module compilation cached per Samuel process (wazero supports compile-then-instantiate).
Blessed toolchain: TinyGo first ([[../../wiki/concepts/plugin-format]]). Document Rust + wasm32-wasi as a secondary path. AssemblyScript or others work but unsupported.
Phase 4 — OCI tier (PRD 0003, week 2-3)¶
internal/plugin/oci/:
- Runtime detection: probe in order Podman (rootless) → Docker →
SAMUEL_RUNTIMEenv override.samuel doctorreports which is being used. - Install:
<runtime> pull <image>→ record image digest insamuel.lock→ optional cosign verification on the image (per[security]config). - Detect:
<runtime> inspect <image>succeeds. - Check: image inspect succeeds + (optional) launch container and run health endpoint.
- Uninstall:
<runtime> rmi <image>(or skip if other plugins reference the same image).
Mount layout when an OCI plugin runs (during hook execution or assistant invocation):
/workspace ← project root (rw by default; ro per capability)
/skills ← installed skill plugins (ro)
/.samuel/run ← methodology runtime state (rw)
/plugin/config ← per-plugin config from samuel.toml (ro)
/samuel-bridge ← Unix socket for gRPC bidirectional calls (PluginService server in samuel)
Plugin invocation protocol: gRPC over the Unix socket. The framework runs a PluginService server; OCI plugins are clients that connect on startup and serve their hook handlers via the protobuf-defined RPCs (Detect, Install, Check, Uninstall, plus hook-specific RPCs from [[0004|RFD 0004]]). Protobuf schema lives at api/proto/plugin/v1/.
Network: deny-by-default; allow per capabilities.network.outbound allowlist via host-side networking config.
User mapping: --user $UID:$GID so files written inside the container are owned by the host user (port from v1's docker.go:206-217).
Image validation: regex check before passing to runtime (port from v1's docker.go:60-75) — defense against shell metacharacter injection via tampered manifest.
Phase 5 — agent runner plugins (PRD 0004-0005)¶
The flagship OCI use case: per-agent runner plugins. Each carries a Docker image with the relevant coding assistant pre-installed. v2 ships five built-in adapters (Claude, Codex, Copilot, Gemini, Kiro) per [[../../wiki/concepts/multi-agent-support]]. The adapter interface is built in; the runner plugins are external (separate repos, separate releases, separate signatures).
Sketch:
ghcr.io/samuelpkg/samuel-runner-claude:1.0.0 ← Node + Claude Code CLI
ghcr.io/samuelpkg/samuel-runner-codex:1.0.0 ← Python + Codex CLI
ghcr.io/samuelpkg/samuel-runner-aider:1.0.0 ← Python + aider (third-party plugin)
The adapter built into Samuel knows the prompt-mode for each agent (file-arg vs content-arg vs stdin); the runner plugin provides the runtime environment.
Phase 6 — translator plugins (PRD 0005)¶
WASM tier proves out via two real plugins:
samuel-claude-translator— mirrors AGENTS.md to CLAUDE.md, installs.claude/settings.jsonhooks. WASM module compiled with TinyGo.samuel-codex-translator— emits Codex-specific files. WASM module.
These exist to demonstrate the agnostic-by-design invariant ([[../../wiki/concepts/agnostic-by-design]]) and to validate the WASM tier in production.
Acceptance criteria (for this RFD's implementation)¶
- Skill plugin install end-to-end:
samuel install go-guidefetches from a Git repo, verifies signature, lands at.samuel/plugins/go-guide/SKILL.md. Uninstall reverses. - WASM plugin install:
samuel install claude-translatorfetches a.wasmmodule, signature verified, wazero loads it,health()export returns 0. - OCI plugin install:
samuel install claude-runnerdetects Podman or Docker, pulls the image, pins digest insamuel.lock. Uninstall removes the image (if no other reference). - Capability gate: a WASM plugin without
network.outboundcapability cannot make outbound calls (host function returns error). - Capability gate: an OCI plugin's container has its network locked down per the allowlist.
- No-runtime mode:
samuel install <skill-or-wasm-plugin>succeeds on a host with no Docker/Podman installed. - Container runtime detection finds Podman before Docker when both installed.
-
SAMUEL_RUNTIME=dockeroverrides Podman detection. - Plugins of different tiers in the same project coexist;
samuel lsshows them with consistent metadata. - Same
Plugininterface satisfied across all three tiers (compile-time check viavar _ plugin.Plugin = (*SkillPlugin)(nil)etc.). - Adding a fourth tier (hypothetical: gRPC plugins) is a localized change — orchestrator code unchanged.
Compatibility and migration¶
v2 is a clean break; v1 has no plugin system to migrate. The 78 v1 skills become v2 skill-tier plugins via the migration script in [[0007|RFD 0007]] / PRD 0005.
For users coming from v1: "Plugins" is a new concept. The migration notice ([[../.samuel/tasks/0006-prd-polish-launch]]) explains:
- v1's
samuel add reactis nowsamuel install react. - The plugin lives at
.samuel/plugins/react/instead of.claude/skills/react/. - The framework binary is smaller because nothing ships pre-bundled except the built-in skills.
Risks¶
| Risk | Likelihood | Mitigation |
|---|---|---|
| WASI Preview 1 gaps break some plugin patterns | Medium | Document the host-function surface explicitly. Plugin authors compile against a known interface. Upgrade to Preview 2 when stable. |
| wazero performance regresses or maintenance lapses | Low | wazero is widely used (Tetrate, Envoy proxy filters, Tetragon). Active maintenance. Fall back to wasmtime via cgo if needed. |
| Container runtime detection fails on user systems with non-standard installs | Medium | SAMUEL_RUNTIME env override. samuel doctor validates runtime availability. |
| Plugin authors confused by three-tier choice | Medium | Decision tree in docs/plugin-authors/ — "Does your plugin execute code? No → skill. Does it need full Linux? No → WASM. Yes → OCI." |
| OCI plugins balloon disk usage | Medium | Layer cache shared at ~/.samuel/cache/oci/. Periodic prune via samuel admin cache clear. |
| Cosign verification adds install latency | Low | Verification is fast (sub-second). Cache verification results per content hash. |
| Cross-tier migration is awkward (text plugin grows code, must "upgrade" to WASM) | Low | Plugin authors can bump major version when changing tier. Users samuel update honors the kind change. |
Resolved decisions (2026-05-12)¶
-
Plugin cache location: per-project copies at
<project>/.samuel/plugins/<name>/+ global content-addressed cache at~/.samuel/cache/plugins/<name>@<version>/for dedup across projects. Hardlink-or-copy from cache to per-project path. -
WASM plugin protocol versioning: exported symbol
samuel_protocol_versionreturningu32. Framework checks at instantiation; mismatch fails install with structured error pointing to the plugin's required range. -
OCI plugin invocation contract: gRPC over Unix socket via
/samuel-bridge. Protobuf schema shipped atapi/proto/plugin/v1/. Plugin authors generate language bindings; Samuel ships a Go server. Higher overhead than stdio JSON but enables streaming, bidirectional capability calls, and strong typing — worth the complexity for the OCI tier specifically. WASM tier still uses direct function calls via wazero exports (no protocol needed for in-process invocation). -
Multi-arch OCI images: linux/amd64 + linux/arm64 via
docker buildx. Reusable plugin-release workflow handles it. -
OCI plugin on macOS / Windows: documented requirement — "OCI plugins need Linux containers; use Docker Desktop or Podman Machine on non-Linux hosts." Standard ecosystem pattern; no framework-side handling required.
-
wazero compile-cache invalidation: key cache by
(plugin name, version, wasm content hash)at~/.samuel/cache/wasm-compiled/. Invalidate on samuel binary update. -
TinyGo without WASI: supported — plugin author guide documents both paths (with WASI for filesystem/log access, without WASI for pure compute).
Outcome¶
To be filled in after v2.0 implementation. Expected outcomes:
- The three-tier model scales to dozens of plugins without architectural strain.
- Most plugins (target ~80%) are skill tier — confirms the "text content dominates" hypothesis.
- WASM tier picks up the executable-but-light cases (target ~15%).
- OCI tier is used by ~5% of plugins but is the most user-visible (runs the coding assistants).
- Plugin authors describe the tier choice as intuitive in survey feedback.
- No user reports "Samuel requires Docker" for skill-only or WASM-only workflows.
Related artifacts¶
- [[0005|RFD 0005]] — Plugin interface (foundation)
- [[0003|RFD 0003]] — Manifest schema, capability model, Sigstore
- [[0004|RFD 0004]] — Methodology hooks (uses WASM plugins extensively)
- [[0007|RFD 0007]] — Plugin migration (skill tier predominantly)
- PRD 0003 (Plugin Loader) — implements all three tier loaders
- [[../../wiki/concepts/plugin-format]] — wiki concept this RFD ports
- [[../../wiki/entities/docker-sandbox]] — v1's reference implementation
- [[../../wiki/concepts/multi-agent-support]] — agent runner pattern (OCI tier)
- [[../../wiki/concepts/agnostic-by-design]] — invariant the tier model upholds