Overview · 7 min read

Architecture

zeno separates a long-lived orchestrator from short-lived agents. One instance is a single process that runs the orchestrator: it holds the secrets and the process logic, talks to the outside world, and spawns an ephemeral agent whenever a piece of the process needs reasoning that no deterministic practice covers yet. The process itself is written in Lisp; the agent is boxed behind its own MCP and dies when its task is done.

The core is a generic substrate — it knows how to hold a gateway, grant a capability, spawn an agent, and supervise a loop. Everything specific (what the process does, which roles exist, what each may touch) lives in the instance and is supplied as data and code.

The design notes in the repo’s docs/vision/ are the earlier, broader sketches this core was distilled from (including a wider set of speculative host primitives). The shipped core is deliberately leaner than those notes; where they disagree, this document and the running code win.

The core primitives

The core is a handful of small namespaces, split into an orchestrator seam (the always-on side) and an agent-body seam (how a spawned agent is built and run).

zeno.gateway — the always-on process + MCP gateway

One long-lived HTTP server. The instance supplies :roles, a map of role-keyword to a zero-arg fn returning that role’s grant-spec, rebuilt per request from current config/code so live edits take effect without a restart. Each role is served at /mcp/<role>. The gateway holds the secrets and privilege; a spawned agent sees only the gateway URL.

zeno.grant — the capability boundary

Builds a deny-by-default SCI context exposing exactly the vocabulary the instance injects: :vocab (name → fn), :docs (name → one-liner shown by (tools)), and :ctx-info (returned by (context)). An agent’s code can call the granted verbs plus (context)/(tools) and nothing else — no fs, no shell, no network, except through a granted fn. Adding a capability is a code edit to the vocab, not prompt engineering.

zeno.spawn — one ephemeral agent = one task

Launches an external coding-agent CLI in print mode, pointed at exactly one role’s MCP endpoint, waits for it, and returns {:exit :out :err}. The session holds no secrets — only the gateway URL — and is gone when the task ends. An optional advisor overlay attaches a reviewer as a quality gate without mutating the operator’s global agent config.

zeno.loop — the supervised loop and scheduler

A step loop is an ordered seq of [label step-fn]; each step takes the context map and returns it. Steps are plain fns, redefinable live in the image. tick runs one pass; a crashing step is isolated (logged, skipped) and the context passes through, so one bad step never kills the process.

On top of that sits a background scheduler. every registers (or replaces) a recurring process — a name, an interval, and a zero-arg fn — and start! runs a daemon-thread scheduler that ticks the due processes on their intervals, supervised, so a throwing process is logged and the others keep running. The engine owns this loop and daemon: zeno.main calls start!, so an instance’s registered processes run in both interactive and --daemon modes. An init.clj only calls every to declare what to run — it never spawns threads or writes its own loop.

zeno.sandbox — the agent body in a microsandbox

Launches an ephemeral body in an msb microVM: an immutable image, a persistent named volume mounted at the working dir (the agent’s durable home — session, repo checkouts, scratch), the env the instance forwards, and a lifetime cap. The body boots sub-second, does one run, and is destroyed; state survives in the volume, not the body. run is the generic primitive (any argv, optional stdin); omp wraps the coding agent (task piped in, headless, answer parsed from its JSON stream). msb is the backend today; a Kubernetes-pod backend lands later.

Secrets a body needs are delivered network-bound: the guest env holds only an $MSB_<ENV> placeholder, and msb releases the real value only toward the secret’s allowed host — even when a tool has base64-encoded it (git’s Basic auth), so a normal git push works with a token that never lands in the box. Egress is deny-by-default with a host allowlist (or a broad profile for agents that must read arbitrary pages); a bound secret still reaches only its host, so broad egress can’t leak it (see ADR-0018).

zeno.oci — an agent image from a Clojure spec

Turns a Clojure {:packages :env :cmd :workdir} spec into an OCI image, using nix only to realise package store paths and their closure, then assembling the docker-save archive directly and loading it into the sandbox. No nix expressions and no dockerTools — the environment is data, nix is just the package source.

zeno.main — the launcher

zeno is both a library and a runnable launcher. It is a runtime that loads your config — the way a shell sources your rc or a runtime loads your program. nix run github:reflection-dev/zeno loads a local config: zeno is the runtime/core, the config at ~/.zeno is a deps.edn project with an init.clj (published like dotfiles as <name>.zeno), and machines are workflow packages resolved on first run. The flake’s zeno/default app composes the classpath at launch — zeno core as a :local/root self plus the config project as a :local/root, bringing its :paths and machine :deps onto one classpath — then runs zeno.main. zeno.main resolves the config dir ($ZENO_HOME, else ~/.zeno), publishes it as the zeno.home system property so init code can find its files regardless of the working directory, and load-files <home>/init.clj, which requires both zeno’s namespaces and the config’s own to wire the instance. Config is read from local disk, so config edits take effect on the next run without a push; only zeno-core changes need one.

zeno always starts. A missing config or an init.clj that throws is reported and you still land in a working REPL, rather than the launcher refusing to boot. The default mode is that interactive REPL, with an nREPL server started alongside (its port written to <home>/.nrepl-port) so a client can connect while you type. --daemon runs headless: no interactive REPL, just the nREPL server staying up for an editor/client to connect to later. In both modes zeno.main calls zeno.loop/start!, so the instance’s registered processes run either way.

Two evaluators, opposite trust

  • The orchestrator runs as full Clojure on the JVM. It is trusted — the operator wrote it — so it requires libraries directly, uses real threads, and redefines its own functions live. It is not sandboxed, and it is not an LLM, so it cannot be talked into misusing what it holds.
  • An agent’s eval runs in SCI, a fresh context per spawn whose visible vocabulary is exactly its grant. Untrusted agent-authored code cannot reach anything that was not injected.

SCI is therefore not the substrate of the system — it is scoped to one job: bounding an agent’s code-mode eval.

The agent boundary is a per-role MCP

When the orchestrator spawns an agent, it hands that agent a single MCP endpoint over HTTP (/mcp/<role>) as its only channel back in. The direction is inverted from a normal editor session: zeno is the orchestrator that serves a curated tool to the agents it commands. The central (and, today, only) tool is code-mode eval, run against that role’s SCI grant. HTTP, not stdio, because the tool must execute inside the orchestrator’s live process against its state.

Per-spawn grants, not always minimal

The instance authors each role’s grant. A grant is whatever that agent needs: a cheap classifier might get narrow read-only access; a coding agent gets real read/write. The invariant is that the grant is per-role and chosen by the orchestrator, not that it is minimal. A spawned coding CLI also has built-in tools that bypass the MCP, so a grant is only as tight as its tool controls.

One instance, many roles, one process

An instance is one process and one live image. It may serve many roles on one gateway and orchestrate a whole unit — a team, a department, a company — with the process described in Lisp. Scaling out means running more instances at the boundaries where a unit is genuinely autonomous or lives in a different trust domain, not running more loops inside one process. Coordination between instances lives a layer above the core.

Communication is a separate seam

Work and communication are meant to be separated: an agent calls something like (ask-user "question"), is interrupted, and resumes when the answer arrives — regardless of whether the channel is chat, GitHub issues, or email. The mechanism (interrupt/resume + a channel interface) is a core concern; the concrete channel is instance config. This seam is designated but not yet implemented — it is written when a second consumer needs it, not retrofitted speculatively.

State and sandboxing

Long-lived work keeps its state of record outside the volatile image; the image is a working context, not the source of truth, and the loop is idempotent so a restart resumes rather than repeats.

The core also owns how a spawned agent is isolated: zeno.sandbox runs each body in an msb microVM (a Kubernetes-pod backend later) with a declared image, egress allowlist, forwarded secrets and a lifetime cap. The body is disposable; its durable state lives in a persistent per-agent volume. What the instance supplies is the profile — the concrete constraints for a role — not the mechanism (see ADR-0012, ADR-0014).

Esc
Start typing to search.