Architecture
Four ideas carry the whole system. Everything else is a consequence of them.
1. The event log is the session
Every interaction appends to an immutable, ordered log. Derived state — the projected message history, pending tool calls, loaded plugins, the context meter — is a pure fold over that log via selectors. Nothing else is authoritative.
prompt → events appended → selectors fold → derived stateThis is why replay debugging costs nothing to build: feed a session's log back through replay() and you get identical derived state. It is also why the desktop mirror, resumable sessions and multi-client attach are possible at all, and why the governance work in the enterprise section was additive rather than a rewrite. Adding a field to an event does not invalidate anything already written.
Each event carries id, seq, ts, sessionId, turnId, causationId, source, and a Principal naming who caused it.
2. Everything is a registry entry
A block is looked up by name at the moment it is used, never imported by the code that depends on it. Fifteen kinds resolve this way:
| Kind | What it decides |
|---|---|
provider | which model answers |
mode | the shape of the loop |
compactor | what happens to old turns when the window fills |
cacheStrategy | where prompt-cache breakpoints land |
isolator | the boundary a tool call runs inside |
eventStore | where the log is written |
auditSink | where the record of what ran goes |
reflector | what carries from one turn into the next |
transcriber | speech in |
synthesizer | speech out |
embedder | how memory is vectorised |
workflowExecutor | how a DAG is walked |
viewRenderer | how an agent-authored view is drawn |
tunnelProvider | how a local listener gets a public URL |
channel | the surface a session is driven from |
The protected floor
Each registry seeds its own default and marks it protected. A discovered plugin may register alongside it; it can never shadow it, and activating an alternative is always an explicit choice. Removing a non-floor entry reverts to the floor rather than to nothing.
That single rule is what makes installing a plugin safe to try. The worst case is an agent that ignores it, never one whose spine has been quietly replaced.
See the swap axis for what ships under each kind.
3. Plugins depend on the contract, never the runtime
@moxxy/sdk the typed public surface. Zero runtime dependencies.
▲
│ plugins may point here, and only here
│
@moxxy/core the runtime: event log, registries, plugin host, permissionspnpm check:deps fails the build when that arrow points the wrong way. It is a machine-enforced invariant rather than a documented aspiration, and it is the reason a block can be substituted at all: nothing above the contract is allowed to reach around it.
Plugins are npm packages, discovered from package.json#moxxy. They contribute tools, providers, modes, compactors, channels, isolators, sinks, bundled skills, and lifecycle hooks (onInit, onEvent, onToolCall, onToolResult, onBeforeProviderCall).
A plugin declares requirements — plugins it needs, an active provider, a registered transcriber, runtime auth facts. The host skips a plugin whose hard requirements are missing before any of its partial contributions land, so a half-registered plugin is not a state the system can be in. See requirements.
4. A channel drives a session; it does not own one
interface Channel<TStartOpts = unknown> {
readonly name: string;
readonly permissionResolver: PermissionResolver;
start(opts: TStartOpts): Promise<ChannelHandle>;
}A channel feeds prompts in, renders events out, and answers permission prompts. The TUI, the desktop app, Slack and an HTTP endpoint are the same kind of object.
moxxy serve is a bare runner: it holds the Session and nothing else. Clients attach over a unix socket (mode 0600 inside a 0700 parent, created before the listen call so there is never a window where another account can reach it) and speak a versioned JSON-RPC protocol. RemoteSession implements the same SessionLike interface the in-process session does, so a channel cannot tell which one it is talking to.
That is what makes a single turn stream to your terminal and your phone at the same time.
How a turn actually runs
- A channel appends
user_prompt. That is the turn boundary. - The active mode drives the loop. All four bundled modes share one
runReactLoopcore with aTurnCheckpointgate between iterations. - Before each provider call the cache strategy places breakpoints, and the compactor runs if the context window demands it.
- The provider streams back text, reasoning and tool calls.
- Each tool call passes the permission engine, then the active isolator, then the handler.
- Results append as events. The audit sink, if enabled, receives a bounded, redacted projection of the tool and permission traffic — never the conversation.
- The reflector decides what carries forward.
Every step above names a block, and every one of those blocks is replaceable.
Skills
Skills are prompt-only: Markdown with YAML frontmatter, Claude Code-compatible, no runtime code. They resolve in precedence order:
./.moxxy/skills/**/*.md— project scope, checked in~/.moxxy/skills/**/*.md— user scope, and where synthesised skills land<plugin>/skills/**/*.md— bundled with a plugin@moxxy/skills-builtin
When nothing matches, the agent can draft one with synthesize_skill, you approve it, the registry hot-reloads, and the next prompt routes through it.
What is deliberately not here
- No hosted control plane. Policy arrives as files in a config scope.
- No sandbox by default. Isolation is opt-in, and
inprocis documented as best-effort rather than sold as containment. See isolation. - No implicit audit forwarding. A discovered sink is registered but never auto-activated: a sink exists to send recorded actions elsewhere, so silent adoption would be an exfiltration path.