Nexus
Nexus is a modular AI agent harness built on a pure event-driven architecture in Go. The core manages only the event lifecycle and plugin registry — all behavior is delivered through composable plugins.
This means you can assemble exactly the agent you need by choosing which plugins to activate, how to configure them, and optionally writing your own.
Why Nexus?
- Event-driven core — Plugins never call each other directly. All communication flows through a central typed event bus, making the system loosely coupled and easy to extend.
- Composable by design — Pick an agent strategy (ReAct, Plan & Execute, Orchestrator), pair it with tools, memory, and an I/O interface, and you have a working agent.
- Minimal dependencies — Only
gopkg.in/yaml.v3beyond the Go standard library. The Anthropic API is called via raw HTTP — no SDK required. - Configuration-driven — YAML profiles let you define entirely different agent behaviors without changing code.
- Session persistence — Every session captures conversation history, thinking steps, plans, and file artifacts to a structured workspace on disk.
What You Can Build
- Coding assistants with shell access, file I/O, and planning capabilities
- Research agents with large context windows and no tool access
- Multi-agent workflows using the orchestrator to decompose tasks across worker subagents
- Document analysis pipelines with PDF extraction and skill-based instructions
- File-driven multi-stage workflows with the ICM plugin — a folder on disk is the workflow, contracts per stage, loops + fan-out + human gates included
- Custom domain agents by writing your own plugins and skills
How This Documentation is Organized
| Section | What you’ll find |
|---|---|
| Getting Started | Installation, building from source, and creating your first config |
| Architecture | Deep dive into the engine, event bus, plugin system, and session management |
| ICM Workflows | File-driven multi-stage agent workflows — rationale, mental model, end-to-end walkthrough |
| Plugin Reference | Every built-in plugin with its configuration, events, and use cases |
| Reference | Complete event type catalog and configuration reference |
| Eval Harness | Golden-trace replay, baseline diffs, online sampling, Inspect-AI-compatible JSON protocol |
| Guides | Tutorials for writing skills and creating custom plugins |
Quick Start
# Clone and build
git clone https://github.com/frankbardon/nexus.git
cd nexus
make build
# Set your API key
export ANTHROPIC_API_KEY="sk-ant-..."
# Run with the default profile
bin/nexus -config configs/default.yaml
See Installation for full details.
Installation
Prerequisites
- Go 1.21+ — Nexus is written in Go and builds with the standard toolchain
- An API key for at least one LLM provider — Nexus ships with first-party providers for Anthropic (Claude) and OpenAI (GPT / o-series). Bring your own key for whichever provider(s) your config activates.
Optional:
- poppler-utils — Required only if you use the PDF reader plugin (
pdftotext,pdfinfo)
Building from Source
git clone https://github.com/frankbardon/nexus.git
cd nexus
make build
This produces a binary at bin/nexus.
Available Make Targets
| Command | Description |
|---|---|
make build | Build binary to bin/nexus |
make run | Build and run with the default config (configs/default.yaml) |
make test | Run all tests |
make fmt | Format code with gofmt |
make vet | Run go vet |
make lint | Run staticcheck (includes vet) |
Setting Your API Key
Each provider plugin reads its key from an environment variable. The default names match the upstream convention: ANTHROPIC_API_KEY for the Anthropic plugin, OPENAI_API_KEY for the OpenAI plugin. Set whichever your active config needs:
# Using Claude
export ANTHROPIC_API_KEY="sk-ant-your-key-here"
# Or using OpenAI
export OPENAI_API_KEY="sk-your-key-here"
Or place them in a .env file in the project root:
ANTHROPIC_API_KEY=sk-ant-your-key-here
OPENAI_API_KEY=sk-your-key-here
You can also pass the key inline (api_key:) or point at a different env var (api_key_env:) per-provider. See the Anthropic and OpenAI plugin pages for the full options, plus Fallback and Fanout for using multiple providers together.
Running Nexus
Run with a specific configuration file:
bin/nexus -config configs/default.yaml
CLI Flags
| Flag | Default | Description |
|---|---|---|
-config | nexus.yaml | Path to the YAML configuration file |
-recall | (none) | Session ID to recall and resume a previous session |
Resuming a Session
To resume a previous session, pass the session ID:
bin/nexus -recall abc123def456
This loads the session’s config snapshot so the agent starts with the same configuration it had originally.
Your First Configuration
Nexus configuration is a single YAML file with two top-level sections: core (engine settings) and plugins (what to activate and how to configure it).
Minimal Configuration
Here’s the simplest useful configuration — a conversational agent with no tools:
core:
log_level: warn
models:
default: balanced
balanced:
provider: nexus.llm.anthropic
model: claude-sonnet-4-20250514
max_tokens: 8192
plugins:
active:
- nexus.io.tui
- nexus.llm.anthropic
- nexus.agent.react
- nexus.memory.capped
nexus.agent.react:
max_iterations: 10
system_prompt: "You are a helpful assistant."
nexus.memory.capped:
max_messages: 100
persist: true
Save this as my-agent.yaml and run it:
bin/nexus -config my-agent.yaml
Understanding the Structure
Core Section
The core section configures the engine itself:
core:
log_level: warn # debug | info | warn | error
tick_interval: 5s # heartbeat interval
max_concurrent_events: 100
models:
default: balanced # which role to use when none specified
reasoning: # high-capability model for planning
provider: nexus.llm.anthropic
model: claude-opus-4-20250514
max_tokens: 16384
balanced: # general-purpose model
provider: nexus.llm.anthropic
model: claude-sonnet-4-20250514
max_tokens: 8192
quick: # fast model for simple tasks
provider: nexus.llm.anthropic
model: claude-haiku-4-5-20251001
max_tokens: 4096
sessions:
root: ~/.nexus/sessions
retention: 30d
id_format: datetime_short
Plugins Section
The plugins section has two parts:
active— a list of plugin IDs to load (order doesn’t matter; dependencies are resolved automatically)- Per-plugin config — each key matching a plugin ID provides that plugin’s settings
plugins:
active:
- nexus.io.tui
- nexus.llm.anthropic
- nexus.agent.react
- nexus.tool.shell
- nexus.tool.file
nexus.tool.shell:
allowed_commands: ["ls", "cat", "grep", "find"]
timeout: 30s
sandbox: true
Adding Tools
To give your agent capabilities, add tool plugins to the active list and configure them:
plugins:
active:
- nexus.io.tui
- nexus.llm.anthropic
- nexus.agent.react
- nexus.tool.shell # Shell command execution
- nexus.tool.file # File read/write/list
- nexus.control.hitl # ask_user tool + HITL approvals
- nexus.memory.capped
nexus.tool.shell:
allowed_commands: ["go", "git", "ls", "cat", "grep", "make"]
timeout: 30s
sandbox: true
Tools register themselves automatically when the agent starts. The agent discovers available tools through the event bus — no explicit wiring needed.
Adding Planning
To enable a planning phase before the agent acts, add a planner plugin and set planning: true on the agent:
plugins:
active:
- nexus.io.tui
- nexus.llm.anthropic
- nexus.agent.react
- nexus.planner.dynamic
- nexus.observe.thinking
# ... other plugins
nexus.agent.react:
max_iterations: 10
planning: true
system_prompt: |
You are a coding assistant powered by Nexus. You help users write, debug, refactor, and understand code.
## Guidelines
1. Always explain your reasoning before making changes
2. Run tests after modifications to verify correctness
3. Prefer minimal, targeted changes over broad refactors
4. Ask for clarification when requirements are ambiguous
5. Read files in chunks 16kb or less
6. Follow the existing code style and conventions of the project
nexus.planner.dynamic:
approval: auto # always | never | auto
max_steps: 10
model_role: reasoning # use the high-capability model for planning
See Dynamic Planner and Static Planner for details.
Using System Prompts
System prompts can be defined inline or loaded from a file:
# Inline
nexus.agent.react:
system_prompt: "You are a coding assistant. Be concise and precise."
# From file
nexus.agent.react:
system_prompt: |
You are a coding assistant powered by Nexus. You help users write, debug, refactor, and understand code.
## Guidelines
1. Always explain your reasoning before making changes
2. Run tests after modifications to verify correctness
3. Prefer minimal, targeted changes over broad refactors
4. Ask for clarification when requirements are ambiguous
5. Read files in chunks 16kb or less
6. Follow the existing code style and conventions of the project
Next Steps
- Learn about the architecture to understand how plugins communicate
- Browse the plugin reference to see what’s available
Architecture Overview
Nexus follows a strict event-driven architecture. The engine is intentionally minimal — it provides the event bus, plugin registry, lifecycle management, and session workspace. All behavior comes from plugins.
Core Principle
Plugins never call each other directly. Every interaction flows through the central event bus as typed events. This keeps plugins decoupled and makes the system easy to extend or reconfigure.
flowchart TB
subgraph Engine["🛠 Engine (pkg/engine)"]
direction TB
EB[EventBus]
REG[PluginRegistry]
LM[LifecycleManager]
EB --- REG --- LM
DISPATCH{{Event Dispatch}}
EB --> DISPATCH
end
DISPATCH --> IO[IO Plugins<br/>tui · browser · wails]
DISPATCH --> AG[Agent Plugins<br/>react · planexec · orchestrator]
DISPATCH --> LLM[LLM Providers<br/>anthropic · openai · fallback]
DISPATCH --> TL[Tool Plugins<br/>shell · file · web · knowledge_search]
DISPATCH --> MEM[Memory Plugins<br/>capped · summary · longterm · vector]
DISPATCH --> OBS[Observers<br/>logger · otel · thinking]
classDef engine fill:#1e3a5f,stroke:#4a90e2,stroke-width:2px,color:#fff;
classDef plugin fill:#2d4a3e,stroke:#5fb878,stroke-width:1.5px,color:#fff;
classDef dispatch fill:#4a3a5f,stroke:#9b59b6,stroke-width:2px,color:#fff;
class EB,REG,LM engine;
class IO,AG,LLM,TL,MEM,OBS plugin;
class DISPATCH dispatch;
Engine Components
The engine (pkg/engine/) contains these components:
| Component | File | Purpose |
|---|---|---|
| Engine | engine.go | Top-level orchestrator that wires everything together |
| EventBus | bus.go | Central event dispatch with priority ordering and filtering |
| PluginRegistry | registry.go | Stores plugin factories, creates instances on demand |
| LifecycleManager | lifecycle.go | Boots plugins in dependency order, shuts down in reverse |
| SessionWorkspace | session.go | File-based session persistence |
| ModelRegistry | models.go | Resolves model role names to provider/model/token configs |
| PromptRegistry | prompt.go | Dynamic system prompt assembly from plugin sections |
| ContextManager | context.go | Agent context management (placeholder for future windowing) |
| SystemInfo | system.go | Platform detection (OS, architecture, open commands) |
| Config | config.go | YAML configuration loading and merging |
Boot Sequence
When Engine.Run() is called:
- Config loaded — YAML file is parsed, defaults merged, per-plugin configs extracted
- Session created — A new session workspace is set up on disk (or an existing one is resumed)
core.bootemitted — Signals the start of the boot process- Plugins initialized — Topologically sorted by dependencies, then
Init()called serially - Plugins readied —
Ready()called in parallel on all initialized plugins core.readyemitted — All plugins are up and listening- Event loop — The engine listens for events until a shutdown signal arrives
- Shutdown — Plugins shut down in reverse dependency order,
core.shutdownemitted
sequenceDiagram
autonumber
participant Caller as Caller<br/>(CLI / Embedder)
participant Engine
participant Session
participant Bus as EventBus
participant Plugins
Caller->>Engine: Run(ctx)
Engine->>Engine: Load YAML config
Engine->>Session: Create session workspace
Engine->>Bus: emit core.boot
Bus->>Plugins: Init() in dependency order
Plugins-->>Bus: subscriptions registered
Engine->>Plugins: Ready() in parallel
Engine->>Bus: emit core.ready
Note over Bus,Plugins: Event loop —<br/>plugins drive behavior
Caller-->>Engine: SIGINT / Stop()
Engine->>Plugins: Shutdown() in reverse order
Engine->>Bus: emit core.shutdown
Event Flow Example
Here’s a typical request flow through the system:
sequenceDiagram
autonumber
actor User
participant IO as nexus.io.tui
participant Agent as nexus.agent.react
participant LLM as nexus.llm.anthropic
participant Gates as before:* gates
participant Tool as nexus.tool.shell
User->>IO: types message
IO->>Agent: io.input
Agent->>LLM: llm.request
LLM->>LLM: call Claude API
LLM-->>Agent: llm.response
alt response contains tool calls
Agent->>Gates: before:tool.invoke (vetoable)
Gates-->>Agent: pass
Agent->>Tool: tool.invoke
Tool->>Tool: execute
Tool->>Gates: before:tool.result (vetoable)
Gates-->>Tool: pass
Tool-->>Agent: tool.result
Agent->>LLM: llm.request (loop)
else final answer
Agent->>IO: io.output
IO-->>User: display response
end
Key Design Decisions
Synchronous Dispatch
Events are dispatched synchronously — handlers execute one at a time, ordered by priority. This makes the system predictable and avoids race conditions.
Vetoable Events
Events prefixed with before: are vetoable. Any handler can block the action by setting a veto on the payload. This enables approval workflows (e.g., confirming tool execution).
Plugin Dependencies
Plugins declare their dependencies by ID. The lifecycle manager topologically sorts them to ensure correct init order. Circular dependencies cause a boot failure.
Multi-Instance Plugins
Some plugins (like nexus.agent.subagent) support multiple instances via ID suffixes. For example, nexus.agent.subagent/researcher creates an instance with InstanceID set to the full suffixed ID.
Next Steps
- Event Bus — How events are dispatched, filtered, and prioritized
- Plugin System — The plugin interface, lifecycle, and how to write your own
- Sessions — How session data is persisted to disk
Event Bus
The event bus is the central nervous system of Nexus. Every plugin communicates exclusively through it — emitting events when something happens, and subscribing to events it cares about.
Interface
type EventBus interface {
Emit(eventType string, payload any) error
EmitEvent(event Event[any]) error
EmitAsync(eventType string, payload any) <-chan error
Subscribe(eventType string, handler HandlerFunc, opts ...SubscribeOption) (unsubscribe func())
SubscribeAll(handler HandlerFunc) (unsubscribe func())
EmitVetoable(eventType string, payload any) (VetoResult, error)
Drain(ctx context.Context) error
}
Events
Every event is a typed container with metadata and a Causation block that
records its provenance:
type Event[T any] struct {
Type string // Dotted namespace (e.g., "llm.request")
ID string // Random hex identifier
Timestamp time.Time // When the event was created
Source string // Plugin ID that emitted this event
Payload T // The event-specific data
Causation EventCausation // Auto-filled by the bus — see Causation, below
}
See Causation for the full discussion of how ParentID,
SessionID, AgentID, Sequence, and Depth are populated and how
plugins push their own CausationContext. The short version: the bus does
the bookkeeping. Plugin authors don’t have to thread session identity
through every emit site.
Event types follow a dotted namespace convention:
| Prefix | Domain |
|---|---|
core.* | Engine lifecycle (boot, ready, shutdown, tick, error) |
io.* | User input/output, approvals, status |
llm.* | LLM requests, responses, streaming |
tool.* | Tool invocation and results |
agent.* | Agent turns, plans, subagent lifecycle |
memory.* | Conversation storage, queries, compaction |
skill.* | Skill discovery, activation, resources |
session.* | Session file events |
plan.* | Planning requests, results, progress |
cancel.* | Cancellation requests and coordination |
thinking.* | Thinking step persistence |
Subscribing to Events
Plugins declare their subscriptions in the Subscriptions() method:
func (p *MyPlugin) Subscriptions() []engine.EventSubscription {
return []engine.EventSubscription{
{EventType: "io.input", Priority: 50},
{EventType: "tool.result", Priority: 50},
}
}
Or subscribe dynamically during Init():
func (p *MyPlugin) Init(ctx engine.PluginContext) error {
ctx.Bus.Subscribe("some.event", p.handleEvent, engine.WithPriority(10))
return nil
}
Subscribe Options
| Option | Description |
|---|---|
WithPriority(int) | Execution order — lower values run first. Default is 0. |
WithFilter(EventFilter) | Predicate function that must return true for the handler to fire |
WithSource(pluginID) | Tag the subscription with the subscribing plugin’s ID |
Priority Ordering
Handlers for the same event type execute in priority order (ascending). This is how the system ensures, for example, that the LLM provider processes requests before observers log them.
Common conventions:
- 5–10 — High priority (providers, cancellation handlers)
- 50 — Normal priority (most plugins)
- 90 — Low priority (observers, persistence)
Wildcard Subscriptions
SubscribeAll() registers a handler that receives every event, regardless of type. This is used by the event logger to capture all activity:
ctx.Bus.SubscribeAll(func(event engine.Event[any]) {
// Logs every event in the system
})
Emitting Events
Plugins emit events by calling Emit() with a type string and payload:
ctx.Bus.Emit("tool.result", events.ToolResult{
ID: callID,
Name: "shell",
Output: output,
})
Plugins must declare all event types they may emit in the Emissions() method:
func (p *MyPlugin) Emissions() []string {
return []string{"tool.result", "tool.register", "core.error"}
}
Async Emit
EmitAsync() dispatches an event in a separate goroutine, returning immediately with a channel that receives nil on success or an error:
ch := ctx.Bus.EmitAsync("llm.request", request)
// ... do other work ...
if err := <-ch; err != nil {
// handle error
}
Handlers still run synchronously within the goroutine — EmitAsync only makes the dispatch non-blocking relative to the caller. Used by the fanout plugin to send parallel requests to multiple providers.
Vetoable Events
Events prefixed with before: support vetoing. This enables approval workflows — for example, the TUI can present an approval dialog before a tool runs.
result, err := ctx.Bus.EmitVetoable("before:tool.invoke", toolCall)
if result.Vetoed {
// Action was blocked
fmt.Println("Vetoed:", result.Reason)
return
}
// Proceed with the action
ctx.Bus.Emit("tool.invoke", toolCall)
Handlers veto by modifying the payload:
func (p *MyPlugin) handleBeforeToolInvoke(event engine.Event[any]) {
vr := event.Payload.(*engine.VetoResult)
vr.Vetoed = true
vr.Reason = "User denied tool execution"
}
Event Filters
Filters are predicate functions that gate handler execution:
ctx.Bus.Subscribe("llm.response", p.handleResponse,
engine.WithPriority(10),
engine.WithFilter(func(meta engine.EventMeta) bool {
return meta.Source == "nexus.llm.anthropic"
}),
)
Draining
Drain() waits for all in-flight events to complete. This is used during shutdown to ensure no events are lost:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
bus.Drain(ctx)
Thread Safety
The event bus is safe for concurrent use. Handler registration and event dispatch use read-write locks. Handler slices are copied before dispatch to allow concurrent emits without blocking.
Events
The pkg/events package defines every typed payload that flows over the
engine bus. Plugins import these types directly; the journal
(pkg/engine/journal/) writes them to disk; out-of-tree consumers
(replay tools, dashboards, MCP servers, embedders) read those journals
and depend on the same struct shapes.
Because the contract is observable to many parties, every change to a payload struct is a potential compatibility event. Nexus uses a simple per-event-type versioning scheme to make those events explicit and auditable.
Versioning convention
Every top-level event-payload struct carries two things:
- A version constant:
<StructName>Version = 1. - A
SchemaVersion int \json:“_schema_version”`` field as the first declared field on the struct.
Producers stamp SchemaVersion = <StructName>Version on every emitted
literal. The journal records the stamped value verbatim, so downstream
consumers can branch on the contract version without correlating to
build metadata or git revisions.
Example:
const LLMRequestVersion = 1
type LLMRequest struct {
SchemaVersion int `json:"_schema_version"`
Role string
Model string
Messages []Message
// ...
}
// Producer:
_ = bus.Emit("llm.request", events.LLMRequest{
SchemaVersion: events.LLMRequestVersion,
Role: "balanced",
// ...
})
Versions start at 1. Nexus is a fresh project — there is no
historical drift to encode in a 0-baseline. The only place 0
appears is the deserialization rule below.
Why per-type versions, not a global one
Because event payloads churn at very different rates. llm.request may
gain a field every quarter; core.tick is unlikely to ever change.
Coupling them into a single global version forces a cascade of
consumer-side compatibility code that is mostly noise.
The v0 == v1 deserialization rule
When a journal record (or a third-party producer that hasn’t yet
adopted the field) leaves out _schema_version, JSON deserializes it
to Go’s zero value: 0. Consumers MUST treat 0 as v1 — the
running code’s contract — rather than reject the payload.
This keeps:
- Journals written before the field existed replayable. Idea 01 (durable journal) shipped before Phase 4 of Idea 10. Replay must flow through the new code path without rewriting old records.
- Embedders that haven’t pulled the latest Nexus minor interoperable. Producers running an older Nexus emit payloads without the field; the new bus should accept them.
The rule is implicit while v1 is the only shipped version: nothing
special happens during unmarshal. The first v2 to ship will register
a {Type, From: 0, To: 1} no-op in pkg/events/compat/ plus the real
{Type, From: 1, To: 2} migrator chained after it. compat.Apply is
the single entry point.
Compat package
pkg/events/compat/ holds field-level migrations between versions. The
public surface is small:
type Key struct {
EventType string // "llm.request"
From, To int
}
type Migrator func(payload map[string]any) (map[string]any, error)
var Migrations = map[Key]Migrator{}
func Apply(eventType string, from, to int, payload map[string]any) (map[string]any, error)
Apply chains one-step migrators from from up to to. With the
registry empty — today’s state — Apply is a no-op pass-through.
Compat is wired into the journal-replay path in two places:
pkg/engine/engine.go—replayPayloadConvertercallscompat.Applybeforejournal.PayloadAs[T]re-types map payloads back into structs for live re-emission duringengine.ReplaySession.pkg/eval/runner/runner.go— same pattern for the eval harness’s case-driven replay path.
When a future PR ships v2 of an event type, the migrator goes into
pkg/events/compat/ and replay-time data flows through it without
touching the engine. No engine code change required.
Lint rule guarantee
make check-events (alias of scripts/check-event-versions.sh,
backed by internal/cmd/check-event-versions/) compares the working
tree’s pkg/events/*.go against a base git revision (default
HEAD~1, override via CHECK_EVENTS_BASE). It fails the build when:
- A field was removed from an existing struct without bumping the
matching
<Name>Versionconstant. - A field was renamed (heuristic: same position + same type, but different name) without a bump.
- A field’s type changed without a bump.
Additive changes (new fields with sensible zero defaults) pass without a bump because they are forward-compatible — older consumers ignore the unknown field, JSON round-trips preserve it.
The rule wires into make lint so existing Go-quality CI gates
catch schema regressions automatically.
False positives (e.g., reordering fields with identical types) are tolerable — the operator just bumps the version trivially. False negatives (a rename slipping through) are the failure case the rule guards against; the position+type+name comparator catches that class.
Author guide
Adding a new event type
- Define the struct in the appropriate per-domain file
(
core.go,llm.go,agent.go, …) withSchemaVersion int \json:“_schema_version”`` as the first declared field. - Add
<Name>Version = 1to that file’s version-constants block. - List the struct in
pkg/events/version_test.go’sversionedPayloads()table so the round-trip test covers it. - Producers must stamp
SchemaVersion: events.<Name>Versionon every literal they emit.
Mutating an existing event type
- Adding a field with a sensible zero default — go ahead. No bump needed; the lint check passes.
- Removing a field — bump
<Name>Versionand register a{Type, From: oldVer, To: newVer}migrator inpkg/events/compat/that drops the field from old payloads (or rewrites it onto a replacement field). - Renaming a field — same as removal: bump the version, register a migrator that copies the old key to the new key.
- Changing a field’s type — same: bump and register a converter.
The pkg/events/compat/compat_test.go placeholder test demonstrates
the registration pattern; copy it for new migrators.
What NOT to version
Helper structs that exist only as nested fields inside top-level
payloads (Message, Citation, ToolCallRequest, Usage, etc.)
deliberately do not carry SchemaVersion. They have no
independent identity on the wire — their version is the version of
the enclosing payload. Versioning them would double-count migrations
and clutter every literal.
When in doubt, version it; the cost of an extra int is dwarfed by
the cost of an undetected breaking change.
Causation
Every dispatched event in Nexus carries a Causation block that records its
provenance: the parent event that caused it, the session it belongs to, the
agent that produced it, and a monotonic per-session sequence number. The bus
populates these fields automatically — plugin authors don’t have to remember
to set them.
Why
Causation is the substrate the Replay primitive walks, the attribution observability collectors filter on, and the dimension the Sub-agent delegation runtime uses to distinguish a parent’s work from a specialist’s. Once it’s present on every event, you can:
- Walk an entire session’s causation DAG to debug what happened.
- Filter envelopes by
AgentIDto inspect just one specialist’s work. - Branch and fork: replay to a sequence, then continue along a different path.
Schema
type EventCausation struct {
ParentID string // EventID of the event whose handler emitted this one
ParentSeq uint64 // Mirrors ParentID via the per-session monotonic sequence
SessionID string // Session this event belongs to
AgentID string // Agent that produced it (sub-agent identity for delegate work)
Sequence uint64 // Monotonic per session; assigned at dispatch
Depth int // Sub-agent recursion depth at emission time
}
Causation lives on both Event[T] and EventMeta. Wildcard subscribers
and filters see it without unwrapping the payload.
How it’s filled
Three sources, in priority order:
- Caller-set fields win. Replay tools and sub-agent runtimes that need
to override the auto-derived values do so by populating
Causationon theEvent[any]they pass toEmitEvent. The bus respects any non-zero / non-empty field. - Dispatch stack supplies
ParentIDandParentSeq. The bus tracks the in-flight event per goroutine; anything emitted inside that goroutine’s handler chain inherits the in-flight event as its parent. - Causation context supplies
SessionID,AgentID,Depth. Two sources here:PushCausationContext(c) func()— per-goroutine stack pushed by callers that have explicit knowledge of who they’re running for (sub-agent dispatch, IO transports binding to a session).SetDefaultCausationContext(c)— bus-wide fallback applied when the calling goroutine has nothing pushed.Engine.StartSessioninstalls theSessionIDhere so every dispatched event carries session attribution even when emitters never callPushCausationContext.
Pushing context
The typical pattern: push at the start of a scoped operation, defer the pop.
if cc, ok := bus.(engine.CausationController); ok {
pop := cc.PushCausationContext(engine.CausationContext{
AgentID: "delegate/analyst/" + subSessionID,
Depth: parentDepth + 1,
})
defer pop()
}
// Every event emitted from this goroutine until pop() carries the AgentID
// and Depth above.
CausationController is an optional interface — checking the assertion at
call sites keeps embedders using a custom bus implementation untouched.
Journal
The journal.Envelope written by pkg/engine/journal carries Seq,
ParentSeq, ParentID, SessionID, AgentID, and Depth alongside the
payload, so downstream replay (pkg/replay) and projection tools can
reconstruct the full causation DAG without re-deriving anything.
Excluded events
Events on the journal exclusion set (core.tick by default) skip seq
assignment, dispatch-stack tracking, and the replay ring — and therefore
have Sequence = 0 and no ParentSeq / ParentID. They still carry the
default SessionID / AgentID from the causation context so observability
tooling can attribute the heartbeats.
Plugin System
Every piece of behavior in Nexus is delivered through plugins. The engine itself only manages the event bus, plugin lifecycle, and session workspace.
Plugin Interface
All plugins implement the engine.Plugin interface:
type Plugin interface {
ID() string // Unique identifier (e.g., "nexus.tool.shell")
Name() string // Human-readable name
Version() string // Version string
Dependencies() []string // IDs that must ALREADY be active (orders boot)
Requires() []Requirement // IDs to auto-activate if absent (see below)
Init(ctx PluginContext) error // Initialize with engine services
Ready() error // Called after all plugins initialized
Shutdown(ctx context.Context) error // Graceful teardown
Subscriptions() []EventSubscription // Events this plugin listens to
Emissions() []string // Event types this plugin may emit
}
Dependencies() vs Requires()
Two related but distinct methods:
Dependencies()only validates that the listed IDs are already active and orders boot (topological sort). If an ID in the list is missing, boot fails. It never activates anything.Requires()activates missing siblings with default config. At boot, the lifecycle walksRequires()transitively from the user-declared active list and appends any missing IDs before the topological sort runs.
Return Requires() []Requirement { return nil } when a plugin has no hard siblings.
Auto-activation semantics
type Requirement struct {
ID string // plugin to auto-activate
Default map[string]any // config used only when user has not configured ID
Optional bool // true → skip silently with WARN when factory is unregistered
}
Merge rule: whole-object replace. If the user supplies any config for the required ID, the user’s config wins entirely and Default is discarded. There is no field-level merge. This keeps precedence predictable and avoids surprise overrides.
Cycles. A cycle in Requires() is detected the same way as a Dependencies() cycle — boot fails with a clear error.
Visibility. Every auto-activation emits an INFO log at boot:
auto-activating plugin nexus.memory.capped (required by nexus.agent.react); config_source=default
After expansion completes, a single "active plugin set resolved" line lists every entry annotated [user] (declared in config) or [auto: required-by=X,config=default|user-override]. Missing optional requirements log WARN and boot proceeds.
Example: ReAct’s Requires().
func (p *Plugin) Requires() []engine.Requirement {
return []engine.Requirement{
{
ID: "nexus.memory.capped",
Default: map[string]any{"max_messages": 100, "persist": true},
},
{ID: "nexus.control.cancel"},
{ID: "nexus.tool.catalog"},
}
}
When a user’s config lists only nexus.agent.react in plugins.active, the engine automatically brings in the conversation, cancel, and catalog plugins at boot. Users can still override any of them by listing the ID in plugins.active with their own config map.
Plugin Context
During initialization, each plugin receives a PluginContext with access to engine services:
type PluginContext struct {
Config map[string]any // This plugin's config from YAML
Bus EventBus // The central event bus
Logger *slog.Logger // Structured logger scoped to this plugin
DataDir string // Session-scoped directory for this plugin's data
Session *SessionWorkspace // The active session workspace
Models *ModelRegistry // Resolve model roles to concrete configs
Prompts *PromptRegistry // Register dynamic system prompt sections
System *SystemInfo // Platform info (OS, arch, open command)
InstanceID string // Full ID including suffix for multi-instance plugins
}
Plugin ID Convention
Plugin IDs use a dotted namespace: nexus.<category>.<name>
| Category | Examples |
|---|---|
agent | nexus.agent.react, nexus.agent.planexec |
llm | nexus.llm.anthropic |
tool | nexus.tool.shell, nexus.tool.file |
memory | nexus.memory.capped, nexus.memory.compaction |
io | nexus.io.tui, nexus.io.browser |
observe | nexus.observe.thinking, nexus.observe.otel |
planner | nexus.planner.dynamic, nexus.planner.static |
skills | nexus.skills |
system | nexus.system.dynvars |
control | nexus.control.cancel |
Plugin Registration
Plugins are registered as factories in main.go:
eng.Registry.Register("nexus.tool.shell", shell.New)
eng.Registry.Register("nexus.tool.file", fileio.New)
The factory function signature is:
func New() engine.Plugin
Registration makes the plugin available — it won’t be instantiated unless it appears in the config’s active list.
Lifecycle
stateDiagram-v2
direction LR
[*] --> registered: factory registered
registered --> initialized: Init(ctx) — serial
initialized --> ready: Ready() — parallel
ready --> ready: handle events
ready --> shutdown: SIGINT / Stop()
shutdown --> [*]: Shutdown() — reverse order
note left of registered
Plugin appears in config
active list — factory
produces an instance.
end note
note right of ready
Plugins talk only
through the event bus.
end note
Boot Phase
- The lifecycle manager reads the
activelist from config - Plugins are topologically sorted by their declared
Dependencies() Init()is called serially in dependency order — each plugin receives itsPluginContextReady()is called on all plugins (can run in parallel)
Runtime
During runtime, plugins interact exclusively through the event bus. They emit events and handle events they’ve subscribed to.
Shutdown Phase
- A shutdown signal arrives (SIGINT, SIGTERM, or programmatic)
Shutdown()is called on each plugin in reverse dependency order- The event bus drains remaining in-flight events
Dependencies
Plugins declare dependencies on other plugin IDs:
func (p *MyPlugin) Dependencies() []string {
return []string{"nexus.llm.anthropic", "nexus.agent.react"}
}
The lifecycle manager ensures dependencies are initialized before dependents. Circular dependencies cause a boot-time error.
Instance-Aware Dependencies
For multi-instance plugins (e.g., nexus.agent.subagent/researcher), the dependency resolver first tries an exact match, then falls back to the base ID (without the suffix).
Multi-Instance Plugins
Some plugins support running multiple instances. In the config, use a slash suffix:
plugins:
active:
- nexus.agent.subagent/researcher
- nexus.agent.subagent/writer
nexus.agent.subagent/researcher:
system_prompt: "You are a research specialist."
tool_name: spawn_researcher
nexus.agent.subagent/writer:
system_prompt: "You are a writing specialist."
tool_name: spawn_writer
Each instance receives its full ID (including suffix) in PluginContext.InstanceID. The instance should use this as its identity rather than the hardcoded base ID.
Subscriptions and Emissions
Plugins declare what they listen to and what they emit. This serves as documentation and enables future validation:
func (p *MyPlugin) Subscriptions() []engine.EventSubscription {
return []engine.EventSubscription{
{EventType: "io.input", Priority: 50},
{EventType: "tool.result", Priority: 50},
}
}
func (p *MyPlugin) Emissions() []string {
return []string{"io.output", "llm.request"}
}
Subscriptions declared here are automatically registered by the lifecycle manager. Plugins can also subscribe dynamically in Init() or Ready().
Plugin Data Directory
Each plugin gets a session-scoped directory for persisting data:
func (p *MyPlugin) Init(ctx engine.PluginContext) error {
// ctx.DataDir points to: ~/.nexus/sessions/<id>/plugins/<plugin-id>/
// Write plugin-specific data here
return nil
}
This directory is created lazily when accessed via Session.PluginDir(pluginID).
Sessions
Every Nexus run creates a session — a persistent workspace on disk that captures conversation history, thinking steps, plans, and plugin data.
Directory Structure
Sessions are stored under the configured root directory (default: ~/.nexus/sessions/):
~/.nexus/sessions/<session-id>/
├── context/
│ └── conversation.jsonl # Conversation history (from memory plugin)
├── files/ # Files created during the session
├── journal/
│ ├── active.jsonl # Live event journal (every bus event,
│ │ # including thinking.step + plan.progress)
│ └── *.jsonl.zst # Rotated, zstd-compressed segments
├── metadata/
│ ├── session.json # Session metadata
│ └── config-snapshot.yaml # Config used for this session
└── plugins/
└── <plugin-id>/ # Per-plugin data directories
Thinking steps and plan progress are no longer kept in dedicated
thinking.jsonl / plans.jsonl files — they live in the journal
alongside every other event. Read them via
journal.Writer.SubscribeProjection (live) or journal.ProjectFile
(post-mortem).
The journal records every bus event except the types listed in
journal.exclude_events (default ["core.tick"]). Excluded events
still dispatch to bus subscribers — only the durable log skips them,
and their seq is not consumed, so on-disk envelopes stay gap-free.
The default suppresses the engine heartbeat, which replay regenerates
from the live tick goroutine and which otel / eval already treat as
noise. See configuration reference
for the full key.
Session Metadata
Each session tracks metadata in metadata/session.json:
type SessionMeta struct {
ID string // Random hex identifier
StartedAt time.Time // When the session began
EndedAt *time.Time // When the session ended (nil if active)
Profile string // Config profile name
Plugins []string // Active plugin IDs
Labels map[string]string // User-defined labels
TurnCount int // Number of conversation turns
TokensUsed int // Total tokens consumed
PromptTokensUsed int // Input tokens consumed
CompletionTokensUsed int // Output tokens consumed
CostUSD float64 // Accumulated cost in USD
Status string // "active" or "ended"
}
Session Workspace API
Plugins interact with the session through the SessionWorkspace struct:
// Write a file to the session workspace
session.WriteFile("context/mydata.json", data)
// Read a file back
data, err := session.ReadFile("context/mydata.json")
// Append to a file (useful for JSONL logs)
session.AppendFile("context/events.jsonl", line)
// List files in a subdirectory
files, err := session.ListFiles("context")
// Check if a file exists
exists := session.FileExists("context/conversation.jsonl")
Directory Helpers
session.ContextDir() // ~/.nexus/sessions/<id>/context/
session.FilesDir() // ~/.nexus/sessions/<id>/files/
session.MetadataDir() // ~/.nexus/sessions/<id>/metadata/
session.PluginDir("nexus.tool.shell") // ~/.nexus/sessions/<id>/plugins/nexus.tool.shell/
PluginDir() creates the directory lazily on first access.
File Events
When files are written to the session, events are emitted automatically:
| Event | When |
|---|---|
session.file.created | A new file is written |
session.file.updated | An existing file is overwritten |
These events carry the file path, session ID, and file size. The TUI plugin subscribes to these to show file creation notifications.
Session Lifecycle
Creating a Session
When Engine.Run() starts, it calls NewSessionWorkspace() which:
- Generates a random hex session ID
- Creates the directory structure (
context/,files/,metadata/,plugins/) - Writes initial metadata with status
"active"
Resuming a Session
When launched with -recall <sessionID>:
- The engine loads the session’s config snapshot from
metadata/config-snapshot.yaml LoadSessionWorkspace()opens the existing directory- The session metadata is updated back to
"active" - Plugins find their persisted data in their
PluginDir()
Ending a Session
On shutdown, the engine:
- Sets
EndedAton the session metadata - Updates status to
"ended" - Saves a config snapshot for future recall
The snapshot is the original config YAML bytes verbatim, not a re-serialization
of the typed Config struct. core.models and per-plugin configs are parsed
via a second-pass raw map (yaml:"-" on the typed fields), so re-marshaling
would silently drop them and break recall. Configs constructed in-memory via
DefaultConfig() (no source bytes) fall back to yaml.Marshal of the typed
struct.
Configuration
Session behavior is configured in the core.sessions section:
core:
sessions:
root: ~/.nexus/sessions # Where sessions are stored
retention: 30d # How long to keep old sessions
id_format: datetime_short # ID generation format
| Field | Default | Description |
|---|---|---|
root | ~/.nexus/sessions | Base directory for all sessions |
retention | 30d | Retention period for old sessions |
id_format | timestamp | Format for generating session IDs |
Per-Plugin Storage
Every plugin can request a SQLite-backed storage handle scoped at session,
agent, or application level. The storage primitive is engine-native (no plugin
needs to be activated) and is exposed through PluginContext.Storage.
The backend is modernc.org/sqlite — pure Go, no CGO, FTS5 included. WAL
mode and a 5-second busy timeout are on by default.
Scopes
| Scope | Path | Lifetime |
|---|---|---|
ScopeSession | <session.RootDir>/plugins/<pluginID>/store.db | Disappears when the session is archived. |
ScopeAgent | ~/.nexus/agents/<agent_id>/plugins/<pluginID>/store.db | Persists across sessions for one agent. Collapses to ScopeApp when no core.agent_id is configured. |
ScopeApp | ~/.nexus/plugins/<pluginID>/store.db | Machine-wide, survives across sessions and agents. |
Multi-agent embedders (the desktop shell) set core.agent_id per engine
instance so each agent gets its own ScopeAgent partition. CLI and
single-agent embedders leave it empty, which collapses agent scope to app
scope so plugins do not end up with two separate connection pools pointing
at the same file.
The data root can be overridden via core.storage.root (defaults to
~/.nexus).
Plugin API
func (p *Plugin) Init(ctx engine.PluginContext) error {
st, err := ctx.Storage(storage.ScopeSession)
if err != nil {
return err
}
// KV sugar — convenient for trivial put/get cases.
if err := st.Put("last_run", []byte(time.Now().String())); err != nil {
return err
}
val, ok, err := st.Get("last_run")
// Raw SQL — for joins, transactions, virtual tables (FTS5).
if _, err := st.DB().Exec(`CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY, payload TEXT
)`); err != nil {
return err
}
// Transactions.
return st.Tx(func(tx *sql.Tx) error {
_, err := tx.Exec(`INSERT INTO jobs(payload) VALUES(?)`, "work")
return err
})
}
Handles are pooled — repeated calls to ctx.Storage(scope) return the same
underlying *sql.DB for that (scope, pluginID) pair. The handle lives for
the lifetime of the engine; do not call Close on the returned *sql.DB.
The kv table is created lazily on the first KV-method call. Plugins that
only use DB() never see it.
Configuration
See Configuration Reference for the authoritative list. The relevant block:
core:
agent_id: "" # set by multi-agent embedders
storage:
root: ~/.nexus # data root for app + agent scope
busy_timeout_ms: 5000
cache_size_kb: 2048
pool_max_idle: 2
pool_max_open: 4
Concurrency
App-scope storage is shared across every session on the machine. SQLite WAL mode handles concurrent readers cleanly, and writers serialize behind the busy timeout. Multiple processes (two CLIs sharing the same app-scope DB file) work but are not the design target — prefer agent or session scope for concurrent independent workloads.
Within a single process, Storage is safe for concurrent use across
goroutines.
Model Registry
The model registry maps abstract role names to concrete model configurations. This lets plugins request a model by capability (e.g., “reasoning”, “quick”) without hardcoding specific model IDs.
Role-Based Model Selection
Define model roles in the core.models config section:
core:
models:
default: balanced # Role to use when none specified
reasoning: # High-capability model for complex tasks
provider: nexus.llm.anthropic
model: claude-opus-4-20250514
max_tokens: 16384
balanced: # General-purpose model
provider: nexus.llm.anthropic
model: claude-sonnet-4-20250514
max_tokens: 8192
quick: # Fast, cost-effective model
provider: nexus.llm.anthropic
model: claude-haiku-4-5-20251001
max_tokens: 4096
How Roles Are Used
Plugins reference roles by name in their configuration:
nexus.planner.dynamic:
model_role: reasoning # Use the high-capability model for planning
nexus.memory.compaction:
model_role: quick # Use the fast model for summarization
nexus.agent.react:
model_role: balanced # Default agent model (optional, uses default role)
When a plugin emits an llm.request, the LLM provider resolves the role to a concrete model:
// Plugin requests by role
config, found := models.Resolve("reasoning")
// Returns: ModelConfig{Provider: "nexus.llm.anthropic", Model: "claude-opus-4-20250514", MaxTokens: 16384}
Resolution Rules
- If the role name matches a defined role, return that config
- If the role is empty, use the
defaultrole - If the role is not found but contains a hyphen (e.g.,
claude-sonnet-4-20250514), treat it as a raw model ID for backward compatibility - Otherwise, return not found
Provider Fallback Chains
Role values can be ordered arrays instead of single maps. First entry = primary, subsequent entries are tried in order when the primary fails with a non-retryable error or exhausts its retry budget.
core:
models:
balanced:
- provider: nexus.llm.anthropic
model: claude-sonnet-4-20250514
max_tokens: 8192
- provider: nexus.llm.openai
model: gpt-4o
max_tokens: 8192
quick:
provider: nexus.llm.anthropic # single entry = no fallback
model: claude-haiku-4-5-20251001
Single-map format is backward compatible — parsed as a chain of length 1.
Requires: nexus.provider.fallback in plugins.active + both provider plugins active.
Trigger conditions: Fallback occurs when a provider error is non-retryable (4xx except 429, auth failures), or when the provider’s own retry logic (429, 5xx backoff) has exhausted max_retries.
Streaming partial failure: If a provider fails mid-stream, the fallback plugin emits io.output.clear to wipe partial content, then provider.fallback notification, then re-emits llm.request targeting the next provider. Clean restart — no spliced output from two models.
Provider Fanout
Roles with fanout: true send requests to all listed providers in parallel instead of sequential fallback. The fanout plugin collects responses and returns them as a single LLMResponse with Alternatives.
core:
models:
compare:
fanout: true
providers:
- provider: nexus.llm.anthropic
model: claude-sonnet-4-20250514
max_tokens: 4096
- provider: nexus.llm.openai
model: gpt-4o
max_tokens: 4096
Requires: nexus.provider.fanout in plugins.active + all listed provider plugins active.
Deadline: Configurable via nexus.provider.fanout.deadline_ms (default 30s). If a provider doesn’t respond in time, the fanout proceeds with available responses.
No per-leg fallback: A fanout provider that fails is simply marked as failed. Fallback chains and fanout are separate concepts — a role is either a fallback chain or a fanout group, not both.
API
type ModelConfig struct {
Provider string // Plugin ID of the LLM provider
Model string // Model identifier string
MaxTokens int // Maximum tokens for this model
}
type ModelRegistry struct {
Resolve(role string) (ModelConfig, bool) // Primary model for a role (index 0)
Fallback(role string, attempt int) (ModelConfig, bool) // Model at chain index
ChainLen(role string) int // Number of entries in fallback chain
IsFanout(role string) bool // Whether a role uses parallel fanout
FanoutProviders(role string) []ModelConfig // All providers in a fanout role
Default() ModelConfig // Get the default model
Roles() []string // List all registered role names
}
Default Role
The default key in the models config is a string alias pointing to another role:
models:
default: balanced # When no role specified, use "balanced"
This means models.Resolve("") and models.Default() both return the balanced config.
Prompt Registry
The prompt registry allows plugins to inject dynamic sections into the system prompt at runtime. This is how skills catalogs, dynamic variables, and other context get appended to the agent’s system prompt without hardcoding.
How It Works
- Plugins register prompt sections during initialization, each with a name and priority
- When the LLM provider builds a request, it calls
prompts.Apply(systemPrompt)to assemble the final prompt - Each registered section function is called — if it returns a non-empty string, that content is wrapped in an XML
<prompt_section>tag and appended - Sections are appended in priority order (lower priority numbers first)
- If a base system prompt is provided, it’s wrapped in
<system_instructions>tags
XML Structure
All prompt content uses XML tag boundaries for clean structural separation. The assembled prompt looks like:
<system_instructions>
You are a helpful assistant.
</system_instructions>
<prompt_section name="skill-catalog">
<available_skills>
<skill name="code-review" scope="project">
<description>Review code for quality, bugs, security issues, and style.</description>
</skill>
</available_skills>
</prompt_section>
<prompt_section name="dynvars">
- Date: 2026-04-08
- OS: darwin
- CWD: /Users/frank/projects/myapp
</prompt_section>
Registering a Section
func (p *MyPlugin) Init(ctx engine.PluginContext) error {
ctx.Prompts.Register("my-context", 50, func() string {
return "Some dynamic information here."
})
return nil
}
The function is called every time a prompt is assembled, so it can return different content based on current state. The returned content is automatically wrapped in <prompt_section name="my-context"> tags by the registry.
Built-in Sections
| Plugin | Section Name | Priority | Content |
|---|---|---|---|
nexus.system.dynvars | dynvars | 90 | Current date, time, timezone, CWD, OS |
nexus.skills | skill-catalog | 80 | XML-formatted list of available skills |
Agent-Level Semantic Tags
Beyond the structural <prompt_section> wrapping, each agent type uses semantic XML tags for its dynamic content:
| Tag | Content | Agents |
|---|---|---|
<skill_context> | Grouped loaded skill bodies | ReAct, PlanExec, Orchestrator |
<execution_plan> | Plan summary + step list | ReAct |
<current_task> | Current step instructions | ReAct, PlanExec, Orchestrator workers |
<prior_results> | Completed step/dependency outputs | PlanExec, Orchestrator workers |
<user_request> | Original user input (CDATA-wrapped) | PlanExec, Orchestrator |
<subtask_results> | Worker outputs in synthesis prompts | Orchestrator |
User-provided content and LLM outputs are wrapped in CDATA blocks to prevent parsing conflicts.
XML Helpers
Shared XML utilities in pkg/engine/xml.go:
engine.XMLWrap("tag", content, "attr", "value") // wrap content in <tag attr="value">...</tag>
engine.XMLTag(&builder, "tag", "attr", "value") // write opening tag
engine.XMLClose(&builder, "tag") // write closing tag
engine.XMLCDATA(content) // wrap in <![CDATA[...]]>
engine.XMLEscape(s) // escape &, <, >, "
API
type PromptSectionFunc func() string
type PromptRegistry struct {
Register(name string, priority int, fn PromptSectionFunc)
Unregister(name string)
Apply(systemPrompt string) string
}
Register— Adds a named section. If a section with the same name already exists, it is replaced.Unregister— Removes a named section.Apply— Takes the base system prompt, wraps it in<system_instructions>, and appends all registered sections wrapped in<prompt_section>tags, in priority order.
Use Cases
- Skill catalogs — The skills plugin registers available skills so the agent knows what’s available
- Dynamic variables — The dynvars plugin injects current date/time and system info
- Custom context — Your plugins can inject any context the agent should be aware of
Hot Reload
Engine.ReloadConfig(newConfig *Config) error applies a config change to a
running engine without restarting unaffected plugins. Phase 5 of Idea 10
(engine resilience) introduced it; the design is intentionally cautious so
operators can adjust gate thresholds, model assignments, and tool
allowlists in production without dropping every active session.
Architecture
The reload runs in two phases.
1. Validate (atomic)
- The new config is run through the same JSON-schema validation pass that
Bootperforms. Every active plugin’sConfigSchemaProvideris re-checked against the new config map; engine-level fields (engine.shutdown.drain_timeout,engine.config_watch) are validated against the engine schema (pkg/engine/engine_schema.json). - Capability provider identity is pinned. If a capability bound at
boot (e.g.
memory.history) would resolve to a different concrete plugin under the new active set, the reload is rejected. The session has in-flight state bound to the existing provider; a silent swap would strip the operator’s history. - The active-set diff is computed: which IDs are added, which removed, which kept with a config change.
Any error here returns immediately. The engine is unchanged.
2. Apply (best-effort)
The diff is walked:
| Delta | Action |
|---|---|
| Plugin added | Init → Ready (subscriptions registered by Init) |
| Plugin removed | Shutdown (subscriptions released by Shutdown) |
Config change w/ ConfigReloader | ReloadConfig(old, new) in place; subscriptions preserved |
Config change w/o ConfigReloader | Shutdown → fresh factory → Init → Ready |
| Engine-only field | Swapped before per-plugin work; takes effect on next read |
Atomicity caveat
The validate phase is atomic — failures here leave the engine state
untouched. The apply phase is best-effort. If a per-plugin
ReloadConfig or Init / Ready fails partway through, prior changes
have already taken effect (a restarted plugin has already re-subscribed
to the bus and may have written to journals or storage). The engine logs
the failure and surfaces it to the caller; we do not attempt to roll
back. “Undoing” a Shutdown is not generally possible — the plugin’s
in-memory state is gone.
If a partial reload leaves the engine in a state the operator dislikes,
re-issue ReloadConfig with the previous config to revert.
ConfigReloader opt-in
// pkg/engine/plugin.go
type ConfigReloader interface {
ReloadConfig(old, new map[string]any) error
}
A plugin that implements ConfigReloader receives the in-place hook on a
config-only change instead of going through the restart path. Both paths
are supported; the hook is purely an optimization for plugins where a
full restart would drop in-progress work (e.g. an HTTP listener with
established WebSocket connections, an MCP client mid-stream).
Implementations must be transactional from the bus’s perspective: returning an error must leave the plugin in its prior state — bus subscriptions, in-memory data, persisted scratch — unchanged. The engine makes no attempt to restart on top of a failed in-place reload.
Capability pinning
Capability provider identity is pinned for the lifetime of a session.
The constraint is enforced in the validate phase: if a capability that
the running engine has resolved (e.g. memory.history →
nexus.memory.capped) would resolve to a different provider in the new
config, the reload is rejected with the error:
capability provider "memory.history" cannot change at runtime (nexus.memory.capped -> nexus.memory.summary_buffer); restart required to rebind session state
Restart the engine to change capability providers — the new session boots with the new provider from clean state.
Triggers
Three triggers feed ReloadConfig:
SIGHUP (CLI)
The cmd/nexus binary’s main loop intercepts SIGHUP, re-reads the
original -config path, and calls ReloadConfig. SIGINT and SIGTERM
continue to terminate the engine.
kill -HUP $(pgrep -f 'nexus -config')
POST /admin/reload-config (browser plugin)
When nexus.io.browser is active, its HTTP server exposes
POST /admin/reload-config. Body is empty (re-read the original path)
or {"path": "/abs/path/to/new.yaml"} for an ad-hoc reload. The endpoint
returns 200 OK with {"ok": true} on success or 400 with
{"ok": false, "error": "..."} on a validation failure; a stuck reload
returns 504 after 30s.
No auth layer yet — alpha-only; front with a reverse proxy if exposed.
Implementation lives in plugins/io/browser/server.go.
fsnotify watcher
Off by default. Opt in via:
engine:
config_watch:
enabled: true
debounce: 1s
The CLI starts a watcher on the -config path and fires ReloadConfig
after each debounced edit. The watcher lives in
pkg/engine/configwatch/. It watches the parent directory rather than
the file itself because editors that swap-on-save (Vim’s default) replace
the file’s inode — a watcher on the original path would miss the swap.
The debounce window collapses bursts of Write/Create events on the
same path into a single reload. Editors commonly fire two or three Write
events when saving; reading a half-written YAML through the validator
would surface a confusing schema error. 1s is well above the typical
storm and short enough that the operator perceives the reload as
instant. Tune downward for fast feedback during dev; leave at the
default (or higher) in production where rapid re-saves rarely happen.
Bus events
External triggers can also dispatch core.config.reload.request on the
event bus and listen for core.config.reload.result. The browser admin
endpoint uses this internally — the engine subscribes to the request
event during Boot and emits the result back. Custom plugins or
embedders that don’t want to hold an *Engine reference can use the
same path.
Code locations
| Concern | File |
|---|---|
ReloadConfig API | pkg/engine/reload.go |
ConfigReloader | pkg/engine/plugin.go |
| Engine schema | pkg/engine/engine_schema.json |
fsnotify watcher | pkg/engine/configwatch/watcher.go |
SIGHUP / CLI hooks | cmd/nexus/main.go |
| Admin HTTP endpoint | plugins/io/browser/server.go |
| Bus event types | pkg/events/core.go |
| Tests | pkg/engine/reload_test.go |
Context Engineering
The conversation history is the LLM’s working memory. Every byte costs tokens, slows the model, and competes with other content for attention. Nexus exposes a layered context-curation stack so operators can shed weight aggressively without losing the reasoning chain.
Layers
The stack runs in cost order on every before:llm.request. Cheaper
deterministic layers fire first; LLM-touching layers run only when the
prior layers leave the request still over budget.
| # | Layer | Plugin | Cost | Cache Impact |
|---|---|---|---|---|
| 1 | Tool-result clearing | nexus.memory.tool_result_clear | None (heuristic) | volatile |
| 2 | Tool-def pruning | nexus.memory.tool_def_pruner | None (heuristic) | session (re-cache once) |
| 3 | Topic-shift detection | nexus.memory.topic_pruner | One classifier or phrase match | volatile |
| 4 | Reasoning-preserving summary | nexus.memory.summary_buffer | One LLM call | session (re-cache once) |
| 5 | Compaction-and-restart | nexus.memory.compaction | One LLM call (full reset) | session (re-cache once) |
Curators never edit the static section — system prompt and operator-set content are off-limits.
Stability Descriptor
Every layer emits a memory.curated envelope event carrying a
stability-impact descriptor:
type MemoryCurated struct {
Layer string // which layer ran
SectionsTouched []CurationSection // section_id, kind, tokens_delta
CacheInvalidates bool // does any touched section cross the cache prefix?
AtTurn int // turn boundary
}
CurationSection.Kind is one of:
volatile— recent turns; no cache impact.session— session-long content (compaction summary, tool definitions); controlled re-cache, charged once and amortised.static— system prompt / tool defs (forbidden — curator must not touch).
A future cache-aware prompt builder (Idea 05) consumes this descriptor to scope re-cache cost. Until that lands, curations batch at turn boundaries to keep cache invalidations predictable.
Replay Determinism
Curation is heuristic and classifier-driven, so curators emit one
event per decision (memory.tool_result_cleared,
memory.tool_def_pruned, memory.topic_shift_detected,
memory.summary_replaced). The durable journal (Idea 01) records every
envelope so replay reproduces curation by replaying decisions, not by
re-running heuristics.
Provider-side vs Harness-side
Anthropic’s server-side tool_result_clear and system_message_edit
primitives, and OpenAI Responses API truncation policies, do similar
work in-provider. Nexus defaults to harness-side curation for
portability — every layer works the same regardless of which provider
the request lands on. Provider-native primitives can be enabled as an
opt-in optimisation when the configured provider supports them.
Eval-Driven Tuning
The eval harness (Idea 07) supports curation-on/off pivots so operators can compare task-success-rate against the cost savings of an aggressive preset. Keep defaults conservative until eval data justifies tightening.
Composing the Stack
A typical full stack:
plugins:
active:
- nexus.agent.react
- nexus.memory.summary_buffer # base history with reasoning-preserving summary
- nexus.memory.tool_result_clear # layer 1
- nexus.memory.tool_def_pruner # layer 2
- nexus.memory.topic_pruner # layer 3
- nexus.discovery.progressive # complements layer 2 (class-level scoping)
- nexus.gate.context_window # last-resort compaction trigger
Layer 5 (compaction-and-restart) is implicit when the context-window
gate fires the existing nexus.memory.compaction coordinator.
Postures
A posture is a registered, named, versioned configuration that describes how a sub-agent should run: which system prompt, which subset of tools, which model, and what default resource budget. Postures are the contract that the delegate runtime resolves at invocation time, and the value operators tune in production to change agent behavior without code changes.
Schema
type AgentPosture struct {
Name string // Registry key parent agents reference
Description string // Human-facing copy (introspection prompts)
SystemPrompt string // The posture's prompt
AllowedTools []string // Closed list of permitted tool names
OutputSchema string // Named schema validated against final output (optional)
Model ModelConfig // Model tier / explicit Provider+Model override
DefaultBudget ResourceBudget // Timeout, MaxTokens, MaxToolCalls
MaxRecursionDepth int // Per-posture depth cap (0 falls back to runtime MaxDepth)
Version string // Content hash, assigned by the loader / registry
}
type ResourceBudget struct {
Timeout time.Duration
MaxTokens int
MaxToolCalls int
}
pkg/posture defines the type and an in-memory Registry. The
nexus.agent.postures plugin loads YAML from disk and exposes the
posture.registry capability.
YAML
Postures live in a directory of *.yaml files. The filename (minus
extension) supplies a fallback name if the YAML omits one. Example:
name: analyst
description: deep reader; quotes sources verbatim
system_prompt: |
You are a careful analyst. Cite sources by URL. Be concise.
allowed_tools:
- web_search
- web_fetch
- read_pdf
output_schema: analyst_report
model:
model_role: reasoning
max_tokens: 4000
default_budget:
timeout: 60s
max_tokens: 50000
max_tool_calls: 20
max_recursion_depth: 2
Versioning
The registry hashes each posture’s content (name, system prompt, allowed
tools, output schema, model selectors) into a 16-character Version string.
Two postures with the same Name but different content are not “different
versions” — the new content replaces the old, but the Version change flows
into the delegate result cache key, invalidating
any stale entries automatically.
Hot reload
nexus.agent.postures watches every configured scan_dirs entry with
fsnotify. Edits and adds re-load the affected file after a small debounce
(debounce_ms, default 250ms); deletes drop the posture from the registry.
Active sub-sessions keep their old configuration; new invocations resolve
the new one. This is how operators tune prompts in production without
restarts.
The watcher swallows individual parse errors with a WARN log — a single
malformed file does not block the rest from registering.
Capability resolution
The plugin advertises:
Capabilities: posture.registry
The delegate plugin requires this
capability, so the lifecycle manager pins the active provider at boot and
the delegate runtime resolves the registry through LookupPlugin without
plugin-to-plugin imports or bus handshake races.
Watching change events
Operators that want to react to posture edits (warm caches, alert on
removals) can subscribe to the posture.registered / posture.removed
bus events; see the events reference.
The in-process posture.Registry.Watch(ctx) channel provides the same
notifications inside the process for plugins that need them.
Configuration
See nexus.agent.postures
in the configuration reference.
Sub-agent Delegation
Delegation is the first-class operation a parent agent uses to call another
agent with a different reasoning posture, system prompt, allowed-tools
subset, and resource budget. From the parent’s perspective a delegate call
is a single tool invocation; underneath, the runtime spawns a sub-session
that has its own context window, its own envelope identity
(Causation.AgentID and Depth), and its own budget.
The runtime lives at pkg/delegate.Runtime; the tool surface is the
nexus.agent.delegate plugin.
Lifecycle of a call
- The parent’s LLM emits a
delegatetool call. - The plugin resolves the posture by name through the posture registry.
- The runtime checks recursion depth (per-posture
max_recursion_depthfirst, then the globalMaxDepth). - The runtime computes a cache key. On a hit, the cached
Outputreturns immediately asStatusCacheHit— no model calls, no tool calls, no budget consumption. - The runtime pushes a
CausationContextcarrying the sub-agent’sAgentIDandDepth, then enters the isolated LLM loop. - Each iteration emits an
llm.requesttagged with the sub-session’s source, collects the response, runs any tool calls (filtered to the posture’sAllowedTools), and appends results to the sub-session’s history. - The loop exits on a tool-call-free response (
StatusSuccess), budget exhaustion (StatusPartial), error (StatusError), timeout (StatusTimeout), or ctx cancel (StatusCancel). - Successful and partial outputs are cached. The final
tool.resultreturns theOutputto the parent agent as JSON.
Budgets
Budgets are non-negotiable, enforced by the runtime, and resolved per-call:
type Overrides struct {
MaxTokens int
MaxToolCalls int
Timeout time.Duration
}
Per-call overrides win when non-zero; otherwise the posture’s
DefaultBudget applies. Timeout becomes a context.WithTimeout around
the loop. MaxTokens is checked after each LLM response and short-circuits
with StatusPartial. MaxToolCalls is checked before dispatching each
tool batch and likewise short-circuits with StatusPartial.
The parent receives the Output.Status and decides whether to retry with a
larger budget, fall back to handling the task itself, or surface the
partial result.
Recursion
Sub-agents can themselves call delegate. Two caps gate the depth:
- The runtime’s
MaxDepth(default 3, configurable via the plugin’smax_depth) is the global ceiling. - Each posture may set
max_recursion_depthto tighten the ceiling for itself.
Exceeding either cap returns ErrRecursionLimit (StatusError) without
spinning up a sub-session.
Caching
The runtime’s Cache interface (default: MemoryCache, an in-process LRU)
keys results on the SHA-256 of:
- Posture
Name - Posture
Version(the content hash; edits invalidate cached results) - The
Taskstring - The canonicalized
Contextmap (keys sorted, values JSON-marshaled) - The sorted
AllowedToolslist
Cache hits return Status = cache_hit and a fresh SubSessionID; Elapsed
reflects only the lookup time. Operators can plug in a Redis-backed cache
by implementing the Cache interface and assigning it on the runtime.
The cache is bypassed for errors and timeouts — operators want a retry to re-execute, not replay a transient failure.
Tool filtering
AllowedTools is a closed list. The runtime snapshots the live tool
catalog on every invocation, intersects it with AllowedTools, and offers
only the intersection to the sub-agent’s LLM. An empty list means “all
tools the catalog currently advertises” — useful for trusted postures.
Observability
Every call emits a delegate.start / delegate.complete pair on the bus;
see the events reference. Every
LLM and tool envelope from inside the sub-session carries the sub-agent’s
AgentID and Depth so observability tooling (otel spans, log shipping)
can attribute the work.
Public API
type Input struct {
Posture string
Task string
Context map[string]any
ParentTurn string
ParentDepth int
Overrides Overrides
}
type Output struct {
Result string
Status Status // success / partial / error / timeout / cancelled / cache_hit
Error string
TokensUsed int
ToolCallsUsed int
Elapsed time.Duration
SubSessionID string
PostureName string
PostureVer string
Depth int
}
func (r *Runtime) Run(ctx context.Context, in Input) (Output, error)
Configuration
See nexus.agent.delegate
in the configuration reference.
Scenes
A Scene is a named, structured, mutable entity that lives for the lifetime of a session. Agents use scenes to construct durable visual output (charts, dashboards, multi-section documents) that is addressable across tool calls, patchable over time, and persisted to disk.
The runtime is schema-agnostic — Nexus stores the content blob and journals patches; downstream renderers (UIs, exporters) interpret the schema-specific content.
Schema
type SceneHandle struct {
ID string // Stable, session-scoped — "scene_<hex>"
SessionID string
Schema string // Names the schema the content conforms to
Version int // Incremented on each patch
}
type Scene struct {
Handle SceneHandle
Content any // Current state
CreatedAt time.Time
UpdatedAt time.Time
History []SceneEvent
}
type SceneEvent struct {
Sequence int
Timestamp time.Time
AgentID string
Patch any
Initial bool
}
pkg/scene defines these types plus the Store interface that
nexus.scene implements with a goroutine-safe in-memory backend.
Behavior
- Stable IDs. Scene IDs are session-scoped and never change after creation. Agents reference them by ID in subsequent tool calls.
- Patches are journaled. Every patch appends a
SceneEventto the scene’s in-memory history and a JSONL line to<session>/plugins/nexus.scene/scenes.jsonl. This is the substrate the replay primitive reads to reconstruct historical state. - Bus events. Creation, patching, and deletion each emit a
scene.*event — see the events reference.agent_idflows fromEvent.Causation.AgentIDso a sub-agent’s contribution is attributable. - Schema is advisory. The runtime does not validate content against the named schema; renderers do.
- Linearization. Concurrent patches (parent + sub-agent, two parallel sub-agents) serialize through the store mutex. First patch at a given key wins; later patches see post-first-patch state.
Patcher
Patches merge through a Patcher implementation. The default is
ShallowMerge:
- Map patch + map content → key-by-key merge, patch keys overwrite.
- Anything else → patch replaces content entirely.
Schema-specific renderers that need richer semantics can swap in their own
Patcher via MemoryStore.WithPatcher.
Tool surface
nexus.scene registers five tools the LLM uses to manipulate scenes:
| Tool | Arguments | Output |
|---|---|---|
scene_create | schema, content | SceneHandle JSON |
scene_patch | scene_id, patch | SceneHandle JSON |
scene_get | scene_id | full Scene JSON (handle + content + history) |
scene_list | (none) | array of SceneHandle |
scene_delete | scene_id | {"deleted":true} |
Persistence
- Per-patch JSONL append to
scenes.jsonl— the durable source of truth for time-travel reconstruction. - Full state snapshot to
scenes.jsononShutdown— what a clean restart loads to pick up where the prior run left off.
Sessions configured to drop scene history can compact scenes.jsonl to
the current state only; correctness does not depend on a complete log.
Configuration
See nexus.scene in the
configuration reference. No config keys today — activate the plugin and
the default tools register at boot.
Streaming Tools
A standard Nexus tool is request-response: an agent emits tool.invoke,
the tool runs, the tool emits tool.result. For long-running operations
that produce incremental output, the agent has to wait for the whole call
to complete — UIs and observability collectors see nothing in between.
pkg/streamtool defines the contract a tool implements when it wants to
publish intermediate output while it runs.
When to use it
A tool should be channel-aware when:
- The work takes more than a few seconds.
- It produces meaningful intermediate output a consumer might render (token stream, report sections, file-by-file progress).
- It can be cancelled cleanly.
For quick request-response operations, the standard tool interface is fine.
Contract
type ChannelTool interface {
Name() string
Stream(ctx context.Context, input map[string]any) (<-chan ToolEvent, error)
}
type ToolEvent struct {
Kind Kind // Progress / Partial / Complete / Error
Sequence int // Monotonic per Stream invocation, starts at 1
Payload any
Progress float64 // 0.0–1.0 if known, -1 otherwise
Err error // Set on KindError
}
The tool owns the channel’s lifetime: it must close the channel when work
completes or ctx cancels, and it must end the stream with KindComplete
or KindError.
Bridge
streamtool.Bridge(ctx, bus, tool, call) drains the channel and projects
each event onto the bus:
KindProgress→tool.stream.progressKindPartial→tool.stream.partialKindComplete→ finaltool.resultcarrying the payloadKindError→ finaltool.resultwith the error
All projected envelopes inherit the originating tool.invoke’s ID as
their Causation.ParentID automatically — the bus’s
per-goroutine dispatch context handles the propagation. UIs subscribed to
tool.stream.partial for live rendering, observability collectors
shipping the stream to Otel, and the parent agent’s tool.result handler
all see the same call linked through causation.
Bridge blocks until the channel closes or ctx cancels and returns nil
on graceful completion, the stream’s error on KindError.
Wiring it into a plugin
A tool plugin keeps its Init and tool registration unchanged. In the
tool.invoke handler it instantiates a ChannelTool, hands it to
Bridge, and lets Bridge emit the final tool.result instead of doing
that itself:
func (p *Plugin) onToolInvoke(ev engine.Event[any]) {
call, _ := ev.Payload.(events.ToolCall)
if call.Name != p.toolName {
return
}
go func() {
_ = streamtool.Bridge(context.Background(), p.bus, &myStreamingTool{...}, call)
}()
}
The plugin still declares tool.stream.progress, tool.stream.partial,
and tool.result in its Emissions().
Events
See tool.stream.* events in
the events reference.
Replay
Given a session ID, pkg/replay reconstructs the full causation DAG and
walks it. Replay is read-only and deterministic — it reads from the
durable journal and the scene patch journal; it does not re-run agents,
LLMs, or tools.
This is the foundation for debugging, audit trails, time-travel, and reproducibility.
API
type Replay struct {
SessionID string
Events []Event // In seq order
Scenes []SceneSnap // Scene state at the requested point in time
LastSeq uint64
}
type Event struct {
Seq uint64
ParentSeq uint64
ParentID string
EventID string
Type string
AgentID string
Depth int
Vetoed bool
Payload any
}
type Options struct {
SessionsRoot string // Engine session root (typically ~/.nexus/sessions)
AtSeq uint64 // Stop after this seq; zero = full journal
IncludeVetoed bool // Keep vetoed before:* envelopes (default true)
}
func Session(ctx context.Context, sessionID string, opts Options) (Replay, error)
func SessionAt(ctx context.Context, sessionID string, atSeq uint64, opts Options) (Replay, error)
Walking the DAG
The Replay value exposes three convenience walkers:
| Walker | Returns |
|---|---|
Roots() | Events with ParentSeq == 0 — operator-driven entry points like io.session.start and io.input arrivals. |
Children(seq) | Events whose ParentSeq matches — the next layer of the DAG below a given node. |
ByAgent(id) | Events whose AgentID matches — the value the sub-agent Causation.AgentID buys: filter the DAG to one specialist’s work. |
For richer traversal, the flat Events slice is in seq order — most
custom walks are a single loop over it.
Use cases
- Debugging. A user reports an issue with a session;
Session(ctx, id, opts)reconstructs the events to read what happened. - Audit. Compliance review walks the DAG to verify what the session
did.
ByAgentanswers “what did the analyst posture decide?” - Time-travel.
SessionAt(ctx, id, atSeq, opts)rebuilds state as it looked atatSeq. Renderers consume the returnedScenesto show historical visual state. - Branch and fork. Replay to a point, then resume the session along a different path with a new agent message. (The engine’s existing rewind primitive consumes the DAG produced here.)
Scene reconstruction
For every session whose <session>/plugins/nexus.scene/scenes.jsonl
exists, replay folds the JSONL stream through scene.ShallowMerge and
returns a SceneSnap per scene at the requested point in time. Sessions
without scenes return events without error.
The scene journal currently records its own per-scene mutation order, not
the bus seq — AtSeq filters by the number of scene-journal lines read,
not the bus dispatch seq. Improving this is a follow-up that adds a bus
seq to each scene journal line.
Performance
For long sessions (thousands of events), full replay can be slow. The
engine’s journal supports periodic snapshots in pkg/engine/replay.go
that replay can start from; integrating snapshot recovery into
pkg/replay.Session is a future optimization. Correctness does not
depend on snapshots.
Desktop Shell
The desktop shell framework (pkg/desktop/) provides everything needed
to embed one or more Nexus agents inside a Wails desktop application.
Your application supplies agent definitions, config YAML, and a
frontend — the framework handles engine lifecycle, event bridging,
settings persistence, session management, and OS integration.
The framework lives inside the Nexus repository. Desktop applications
that use it are built as separate Go modules that import
github.com/frankbardon/nexus/pkg/desktop. A reference implementation
ships at cmd/desktop/ to demonstrate the full feature set.
Architecture
flowchart TB
subgraph App["📦 Your Wails App (cmd/your-app/main.go)"]
direction LR
Run["desktop.Run(&desktop.Shell{ Agents, Assets })"]
end
subgraph FW["🧩 pkg/desktop (framework)"]
direction LR
Shell["Shell<br/><sub>orchestrator</sub>"]
Store["Store<br/><sub>settings + keyring</sub>"]
Sess["Sessions<br/><sub>per-agent index</sub>"]
subgraph EngA["Engine (agent-a)"]
direction TB
IOA["nexus.io.wails<br/>+ scopedRuntime"]
PluginsA["your plugins"]
end
subgraph EngB["Engine (agent-b)"]
direction TB
IOB["nexus.io.wails<br/>+ scopedRuntime"]
PluginsB["your plugins"]
end
Shell --> EngA
Shell --> EngB
end
subgraph Web["🖥 Wails webview (single process, shared)"]
Front["Frontend JS<br/>scoped events:<br/><code>agent-a:nexus</code> · <code>agent-b:nexus</code>"]
end
App --> FW
FW --> Web
EngA <-. namespaced events .-> Front
EngB <-. namespaced events .-> Front
classDef app fill:#3a2d4a,stroke:#9b59b6,stroke-width:2px,color:#fff;
classDef framework fill:#1e3a5f,stroke:#4a90e2,stroke-width:1.5px,color:#fff;
classDef engine fill:#2d4a3e,stroke:#5fb878,stroke-width:1.5px,color:#fff;
classDef web fill:#5f3a1e,stroke:#e2904a,stroke-width:1.5px,color:#fff;
class Run app;
class Shell,Store,Sess framework;
class IOA,IOB,PluginsA,PluginsB engine;
class Front web;
Key concepts
One engine per agent. Each agent gets its own engine.Engine
instance with its own bus, plugin set, session workspace, and config.
Agents never share an engine or bus — isolation is structural.
Lazy boot. Engines are created on demand when the frontend selects
an agent (EnsureAgentRunning). No engine runs until the user
navigates to it.
Scoped runtimes. Each agent’s nexus.io.wails plugin receives a
scopedRuntime that namespaces Wails event channels by agent ID.
The plugin itself is unaware of multi-agent — it talks to its
Runtime interface, and the scoped wrapper handles the namespace.
This means outbound events go to "{agentID}:nexus" and inbound
events come from "{agentID}:nexus.input".
Config-driven event bridging. Domain events flow through the bus,
not through Wails-bound Go methods. The nexus.io.wails plugin
config declares exactly which events cross the bus-to-frontend
boundary via subscribe (outbound) and accept (inbound) lists.
No eng.Run(). Desktop apps must use Boot/Stop directly.
Run installs its own SIGINT/SIGTERM handler, which conflicts
with Wails owning the process lifecycle.
Components
| File | Role |
|---|---|
shell.go | Core orchestrator. Manages per-agent engine lifecycles, Wails app setup, all Wails-bound methods. |
settings.go | Settings schema types (SettingsField, FieldType, SettingsSchema). |
store.go | Persistent settings store. Plaintext JSON at ~/.nexus/desktop/settings.json, secrets in OS keychain via go-keyring. |
resolve.go | ${var} placeholder resolution in config YAML from settings store with scope fallback (agent then shell). |
sessions.go | Session metadata index (SessionMeta). Persists to ~/.nexus/desktop/sessions.json. Cleanup and reconciliation on startup. |
runtime.go | Scoped Runtime adapter for multi-agent event isolation. Enriches file dialog DefaultDirectory from settings. |
watcher.go | Filesystem watcher (fsnotify) for file browser panel. Watches one directory at a time with debounced change notifications. |
Lifecycle
desktop.Run(shell)— Configures and starts the Wails app. Blocks until the app exits.onStartup— Initializes the settings store, session index, file watcher, and agent state entries. Runs session maintenance (cleanup expired, reconcile orphans).- Frontend selects agent — Calls
EnsureAgentRunning(agentID). bootAgent— Resolves${var}placeholders in the agent’s config YAML, creates the engine viaengine.NewFromBytes, registers plugin factories, installs the scoped runtime on the wails plugin, callseng.Boot(ctx), installs bus subscriptions for session metadata and UI state, creates the session index entry.- Agent runs — Domain events flow between plugins and frontend through the bus bridge.
- New session / recall —
StopAgenttears down the current engine (unsubs,eng.Stop, marks session completed), thenbootAgentcreates a fresh engine (or one withRecallSessionIDset for history replay). onShutdown— Stops all running engines, closes the file watcher.
What the framework does NOT do
- Own your frontend. The framework ships a minimal base template
in
frontend/dist/, but you embed your ownassetsviaShell.Assets. Your frontend is yours — Alpine.js, React, vanilla JS, whatever fits. - Define your domain plugins. All agent behavior comes from
plugins you register in
Agent.Factories. The framework only manages thenexus.io.wailsplugin lifecycle. - Restrict agent count. One agent, five agents — the framework scales. Each gets its own engine and scoped runtime.
Building Your Desktop App
This guide walks through creating a Nexus desktop application from scratch. By the end you will have a Wails app hosting a custom agent with settings, session management, and a frontend that communicates with the agent through the bus bridge.
The reference implementation at cmd/desktop/ demonstrates every
feature covered here. When in doubt, consult it as the living example.
Prerequisites
- Go 1.22+
- Wails v2 CLI
(
go install github.com/wailsapp/wails/v2/cmd/wails@latest) - Nexus as a Go module dependency
Project structure
A typical desktop app looks like this:
cmd/my-app/
main.go # Entry point — registers agents, calls desktop.Run
config.yaml # Embedded Nexus config for your agent
internal/
myplugin/ # Your domain plugin(s)
plugin.go
events.go
frontend/
dist/ # Your frontend assets (HTML/CSS/JS)
index.html
build/ # Wails build artifacts (icons, Info.plist)
Step 1: Create your domain plugin
Your agent’s behavior lives in a standard Nexus plugin. It subscribes to events from the frontend, does work, and emits results back.
package myplugin
import (
"github.com/frankbardon/nexus/pkg/engine"
)
type Plugin struct {
bus engine.EventBus
logger engine.Logger
}
func New() engine.Plugin { return &Plugin{} }
func (p *Plugin) ID() string { return "myapp.agent.worker" }
func (p *Plugin) Init(ctx engine.PluginContext) error {
p.bus = ctx.Bus
p.logger = ctx.Logger
return nil
}
func (p *Plugin) Ready() error { return nil }
func (p *Plugin) Shutdown(_ context.Context) error { return nil }
func (p *Plugin) Subscriptions() []engine.EventSubscription {
return []engine.EventSubscription{
{EventType: "work.request", Handler: p.handleRequest},
}
}
func (p *Plugin) Emissions() []string {
return []string{"work.result", "session.meta.title"}
}
func (p *Plugin) handleRequest(event engine.Event[any]) {
payload, _ := event.Payload.(map[string]any)
input, _ := payload["input"].(string)
// Do your work here...
p.bus.Emit("work.result", map[string]any{
"output": "Processed: " + input,
})
// Contribute session metadata so the session list shows
// a meaningful title instead of "Untitled".
p.bus.Emit("session.meta.title", map[string]any{
"title": "Work: " + input,
})
}
Step 2: Write your agent config
The config YAML declares which plugins are active and how the
nexus.io.wails plugin bridges events. Use ${var} placeholders for
values that come from user settings.
# config.yaml
core:
log_level: info
tick_interval: 5s
sessions:
root: ~/.nexus/sessions
retention: 30d
id_format: datetime_short
plugins:
active:
- nexus.io.wails
- myapp.agent.worker
nexus.io.wails:
# Events bridged outbound: bus -> frontend
subscribe:
- "work.result"
- "ui.state.restore"
# Events accepted inbound: frontend -> bus
accept:
- "work.request"
- "ui.state.save"
myapp.agent.worker:
api_key: "${shell.api_key}"
data_dir: "${data_dir}"
Config-driven bridging is explicit. Only events listed in
subscribe and accept cross the bus-to-frontend boundary. If your
frontend isn’t receiving an event, check these lists first.
Step 3: Write your entry point
The entry point registers agents and calls desktop.Run. Config YAML
is embedded at compile time — the shipped binary has no filesystem
dependency on config files.
package main
import (
"embed"
"log"
"github.com/frankbardon/nexus/pkg/desktop"
"github.com/frankbardon/nexus/pkg/engine"
wailsio "github.com/frankbardon/nexus/plugins/io/wails"
"my-module/cmd/my-app/internal/myplugin"
)
//go:embed all:frontend/dist
var assets embed.FS
//go:embed config.yaml
var agentConfig []byte
func main() {
if err := desktop.Run(&desktop.Shell{
Title: "My App",
Width: 1024,
Height: 768,
Assets: assets,
Agents: []desktop.Agent{
{
ID: "my-agent",
Name: "My Agent",
Description: "Does useful work",
Icon: "fa-solid fa-gear",
ConfigYAML: agentConfig,
Factories: map[string]func() engine.Plugin{
"nexus.io.wails": wailsio.New,
"myapp.agent.worker": myplugin.New,
},
Settings: []desktop.SettingsField{
{
Key: "shell.api_key",
Display: "API Key",
Type: desktop.FieldString,
Secret: true,
Required: true,
},
{
Key: "data_dir",
Display: "Data Folder",
Type: desktop.FieldPath,
Required: true,
},
},
},
},
}); err != nil {
log.Fatalf("app: %v", err)
}
}
Key registration details
nexus.io.wailsmust be inFactories. The framework needs to intercept this plugin to install the scoped runtime before boot. Always register it via the factories map.- Every plugin in
plugins.activeneeds a factory. Either throughAgent.Factories(for custom and wails plugins) or the engine’s built-in registry (for stock Nexus plugins likenexus.llm.anthropic). Assetsis optional. If omitted (zero-valueembed.FS), the framework uses its built-in base template. For anything beyond a demo, provide your own.
Step 4: Build your frontend
Your frontend communicates with the agent through scoped Wails events. The pattern:
// Create a bus helper scoped to your agent ID.
function createBus(agentID) {
return {
// Listen for events from the Go side.
on(eventType, callback) {
window.runtime.EventsOn(
`${agentID}:nexus`,
(envelopeJSON) => {
const envelope = JSON.parse(envelopeJSON);
if (envelope.type === eventType) {
callback(envelope.payload);
}
}
);
},
// Send an event to the Go side.
emit(eventType, payload) {
window.runtime.EventsEmit(
`${agentID}:nexus.input`,
JSON.stringify({ type: eventType, payload })
);
},
// Request-response pattern: emit a request, wait for
// a specific response event type.
call(requestType, responseType, payload) {
return new Promise((resolve) => {
const off = window.runtime.EventsOnce(
`${agentID}:nexus`,
(envelopeJSON) => {
const envelope = JSON.parse(envelopeJSON);
if (envelope.type === responseType) {
resolve(envelope.payload);
}
}
);
this.emit(requestType, payload);
});
}
};
}
Usage in your UI:
const bus = createBus('my-agent');
// Listen for results.
bus.on('work.result', (data) => {
document.getElementById('output').textContent = data.output;
});
// Send a request.
document.getElementById('submit').addEventListener('click', () => {
const input = document.getElementById('input').value;
bus.emit('work.request', { input });
});
Calling shell methods
Shell services (settings, sessions, file dialogs) are Wails-bound Go
methods on the Shell struct. Call them from JavaScript via the
generated Wails bindings:
// List available agents.
const agents = await go.desktop.Shell.ListAgents();
// Boot an agent.
await go.desktop.Shell.EnsureAgentRunning('my-agent');
// Open a native file dialog.
const path = await go.desktop.Shell.PickFile('my-agent', 'Select File', '*.pdf');
// Manage sessions.
const sessions = await go.desktop.Shell.ListSessions('my-agent');
await go.desktop.Shell.NewSession('my-agent');
await go.desktop.Shell.RecallSession('my-agent', sessionID);
await go.desktop.Shell.DeleteSession('my-agent', sessionID);
Session list updates
Session list changes arrive as Wails events (not bus events):
window.runtime.EventsOn('my-agent:sessions.updated', (jsonStr) => {
const sessions = JSON.parse(jsonStr);
renderSessionList(sessions);
});
Step 5: Settings
Agents declare settings via Settings []SettingsField on the Agent
struct. The framework handles persistence, UI rendering, and config
injection.
Field types
| Type | Constant | UI Control |
|---|---|---|
| Single-line text | FieldString | Text input |
| File/folder path | FieldPath | Text input + Browse button |
| Multi-line text | FieldText | Textarea |
| Number | FieldNumber | Number input (optional min/max) |
| Boolean | FieldBool | Toggle switch |
| Dropdown | FieldSelect | Select with Options |
Scope and sharing
Settings keys prefixed with shell. are stored in shell scope and
shared across all agents. During config resolution, the framework
checks agent scope first, then falls back to shell scope.
Common pattern: declare shell.api_key as a required secret on every
agent that needs it. The user enters it once — the shell-scoped value
resolves for all agents.
Settings: []desktop.SettingsField{
{
Key: "shell.api_key", // shell-scoped: shared
Display: "API Key",
Type: desktop.FieldString,
Secret: true,
Required: true,
},
{
Key: "data_dir", // agent-scoped: per-agent
Display: "Data Folder",
Type: desktop.FieldPath,
Required: true,
},
},
Secrets
Fields with Secret: true are stored in the OS keychain (macOS
Keychain, Windows Credential Manager, Linux Secret Service) via
go-keyring. The JSON settings file stores the sentinel
"__keychain__" so the frontend knows a value exists without
exposing it.
Required field gating
If an agent has required settings with no value and no default, the
framework refuses to boot the engine. The frontend should check
HasMissingRequired() and redirect to the settings page.
Config injection
${var} placeholders in config YAML are replaced with resolved
settings values before the engine is created. The resolution order:
- Agent-scoped value for the key
- Shell-scoped value for the key (fallback)
Defaultfrom theSettingsFielddefinition- If still unresolved and
Required: true— boot fails with an error listing the missing keys
Step 6: Session management
The framework tracks session history per agent automatically.
How it works
Every bootAgent call creates a session index entry in
~/.nexus/desktop/sessions.json. Your agent contributes metadata
via bus events:
| Event | Payload | Purpose |
|---|---|---|
session.meta.title | { "title": "..." } | Human-readable title for the session list |
session.meta.preview | { ... } | Agent-specific summary data (opaque) |
session.meta.status | { "status": "..." } | Explicit status change |
The shell subscribes to these events on each engine’s bus and updates
the index. The frontend receives updates via
{agentID}:sessions.updated Wails events.
Session lifecycle
| Action | Method | What happens |
|---|---|---|
| First select | EnsureAgentRunning(id) | Creates engine, boots, creates session entry |
| New session | NewSession(id) | Stops current engine, boots fresh |
| Recall | RecallSession(id, sid) | Stops current, boots with RecallSessionID for history replay |
| Delete | DeleteSession(id, sid) | Removes index entry + engine session dir |
Cleanup
On startup, the framework removes sessions older than the configured
retention period (default 30 days, configurable via
session_retention_days shell setting) and reconciles orphaned engine
directories.
Step 7: UI state persistence
The framework provides a mechanism for frontends to persist and restore UI state across sessions.
Save state
Emit ui.state.save from your frontend with an opaque state object:
bus.emit('ui.state.save', {
state: {
selectedTab: 'results',
scrollPosition: 450,
formData: { name: 'Jane', role: 'Engineer' }
}
});
The shell writes this to ui-state.json in the engine session
directory. Call this after meaningful UI interactions — not on every
keystroke.
Restore state
On session recall, the shell emits ui.state.restore with the saved
payload:
bus.on('ui.state.restore', (data) => {
if (data.state) {
applyUIState(data.state);
}
});
Both events must be in the wails IO plugin’s accept/subscribe
config:
nexus.io.wails:
subscribe:
- "ui.state.restore"
accept:
- "ui.state.save"
Step 8: File portal (optional)
If your agent works with files, the framework provides a standardized file access layer.
Declare directories
Add input_dir and optionally output_dir to your agent’s settings:
Settings: []desktop.SettingsField{
{Key: "input_dir", Display: "Input Folder", Type: desktop.FieldPath, Required: true},
{Key: "output_dir", Display: "Output Folder", Type: desktop.FieldPath},
},
Use shell methods
// List files in the agent's input directory.
const files = await go.desktop.Shell.ListFiles('my-agent', '*.pdf');
// Get the output directory (creates if needed).
const outDir = await go.desktop.Shell.OutputDir('my-agent');
// Copy a file into the input directory (drag-and-drop).
const dest = await go.desktop.Shell.CopyFileToInputDir('my-agent', '/path/to/file.pdf');
// Start watching for file changes.
go.desktop.Shell.WatchInputDir('my-agent');
window.runtime.EventsOn('my-agent:files.changed', () => {
refreshFileList();
});
Bus events for plugins
Plugins that need file access use bus events instead of shell methods:
| Event | Direction | Purpose |
|---|---|---|
io.file.output_dir.request | Plugin to shell | Ask where to write outputs |
io.file.output_dir.response | Shell to plugin | Output directory path |
io.file.selected | Shell to plugin | User selected a file in the browser panel |
session.file.created | Plugin to shell | Agent wrote an output file |
Adding multiple agents
Register additional agents in the Agents slice. Each gets its own
engine, config, plugins, settings, and sessions:
Agents: []desktop.Agent{
{
ID: "agent-a",
Name: "Agent A",
ConfigYAML: configA,
Factories: factoriesA,
Settings: settingsA,
},
{
ID: "agent-b",
Name: "Agent B",
ConfigYAML: configB,
Factories: factoriesB,
Settings: settingsB,
},
},
The frontend receives scoped events per agent ("agent-a:nexus",
"agent-b:nexus"). Create a bus helper per agent and switch the
active one when the user navigates.
Using LLM providers
If your agent needs an LLM provider (like Anthropic), register it in the factories and reference the model in your config:
import "github.com/frankbardon/nexus/plugins/providers/anthropic"
Factories: map[string]func() engine.Plugin{
"nexus.io.wails": wailsio.New,
"nexus.llm.anthropic": anthropic.New,
"myapp.agent.worker": myplugin.New,
},
core:
models:
default: quick
quick:
provider: nexus.llm.anthropic
model: claude-sonnet-4-6
max_tokens: 4096
plugins:
active:
- nexus.llm.anthropic
- nexus.io.wails
- myapp.agent.worker
nexus.llm.anthropic:
api_key: "${shell.api_key}"
Your plugin requests LLM completions via the bus using the model registry — see the Model Registry documentation.
Building and running
# Development mode (live reload).
cd cmd/my-app
wails dev
# Production build.
wails build
The wails dev command watches for Go and frontend changes and
rebuilds automatically. Production builds produce a single binary
with embedded frontend assets.
Common patterns
Session metadata from plugins
Emit session.meta.title after completing meaningful work so the
session list shows useful titles:
p.bus.Emit("session.meta.title", map[string]any{
"title": "Analysis: Q4 Revenue Report",
})
For richer session list entries, emit session.meta.preview with
agent-specific summary data:
p.bus.Emit("session.meta.preview", map[string]any{
"itemCount": 5,
"topResult": "Jane Smith",
"score": 0.95,
})
Output files
When your plugin writes a file, emit session.file.created so the
shell and frontend can track it:
p.bus.Emit("session.file.created", map[string]any{
"path": outputPath,
"filename": filepath.Base(outputPath),
"type": "application/json",
})
Error handling in boot
If EnsureAgentRunning returns an error, the agent status is set to
"error". Common causes:
- Missing required settings — the framework lists which keys are missing. Redirect to the settings page.
- Invalid config YAML — check
${var}placeholder names match theSettingsField.Keyvalues. - Plugin init failure — check the plugin’s
Initmethod for errors.
Next steps
- Desktop Shell Overview — Architecture and component details
- API Reference — All shell methods, types, and events
- Wails IO Plugin — Plugin-level configuration and manual embedding
Desktop Shell API Reference
Complete reference for the pkg/desktop/ framework types, shell
methods, settings system, and event contracts.
Types
Shell
Top-level orchestrator. Pass to desktop.Run() to start the app.
type Shell struct {
Title string // Window title
Width int // Window width (default 900)
Height int // Window height (default 720)
Agents []Agent // Registered agents
Assets embed.FS // Frontend assets; zero value uses built-in base template
}
Agent
Registration data for a single agent hosted by the shell.
type Agent struct {
ID string // Unique key: "my-agent"
Name string // Display name: "My Agent"
Description string // Short blurb for agent selector
Icon string // Font Awesome class: "fa-solid fa-gear"
ConfigYAML []byte // Embedded Nexus config YAML
Factories map[string]func() engine.Plugin // Custom plugin factories
Settings []SettingsField // User-configurable fields
}
AgentInfo
JSON-serializable projection of Agent returned by ListAgents().
type AgentInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Icon string `json:"icon"`
Status string `json:"status"` // "idle", "booting", "running", "error"
}
AgentStatus
const (
AgentStatusIdle AgentStatus = "idle"
AgentStatusBooting AgentStatus = "booting"
AgentStatusRunning AgentStatus = "running"
AgentStatusError AgentStatus = "error"
)
SessionMeta
Shell-level metadata for a single engine session.
type SessionMeta struct {
ID string `json:"id"`
AgentID string `json:"agent_id"`
Title string `json:"title"`
Status string `json:"status"` // "running", "completed", "failed"
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Preview any `json:"preview,omitempty"` // Agent-specific summary data
}
FileInfo
File entry returned by ListFiles().
type FileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
IsDir bool `json:"is_dir"`
}
Settings types
SettingsField
Declares a single configurable value for the settings UI.
type SettingsField struct {
Key string // Machine key: "data_dir"
Display string // Human label: "Data Folder"
Description string // Help text shown below the field
Type FieldType // UI control type
Secret bool // Stored in OS keychain, masked in UI
Default any // Default value if unconfigured
Required bool // Agent refuses to boot without this
Validation *FieldValidation // Optional constraints
ConfigPath string // Template variable in config YAML
Options []SelectOption // For FieldSelect only
}
FieldType
const (
FieldString FieldType = iota // Single-line text input
FieldPath // Text input + Browse button
FieldText // Multiline textarea
FieldNumber // Number input (optional min/max)
FieldBool // Toggle switch
FieldSelect // Dropdown
)
FieldValidation
type FieldValidation struct {
Regex string `json:"regex,omitempty"`
Min *float64 `json:"min,omitempty"`
Max *float64 `json:"max,omitempty"`
Message string `json:"message,omitempty"` // Validation error message
}
SelectOption
type SelectOption struct {
Value string `json:"value"`
Display string `json:"display"`
}
SettingsSchema
Top-level schema sent to the frontend for dynamic UI rendering.
type SettingsSchema struct {
Shell []SettingsFieldInfo `json:"shell"`
Agents map[string][]SettingsFieldInfo `json:"agents"`
}
SettingsFieldInfo
JSON projection of SettingsField sent to the frontend.
type SettingsFieldInfo struct {
Key string `json:"key"`
Display string `json:"display"`
Description string `json:"description,omitempty"`
Type string `json:"type"` // "string", "path", "text", "number", "bool", "select"
Secret bool `json:"secret,omitempty"`
Required bool `json:"required,omitempty"`
Default any `json:"default,omitempty"`
Validation *FieldValidation `json:"validation,omitempty"`
Options []SelectOption `json:"options,omitempty"`
}
Shell methods (Wails-bound)
All methods below are exposed to the frontend via Wails bindings.
Call them from JavaScript as go.desktop.Shell.MethodName(args).
Agent lifecycle
| Method | Signature | Description |
|---|---|---|
ListAgents | () []AgentInfo | All registered agents with current status |
EnsureAgentRunning | (agentID string) error | Lazy boot — creates and boots engine on first call, no-op if already running |
StopAgent | (agentID string) error | Stop engine, tear down bus subs, mark idle |
Session management
| Method | Signature | Description |
|---|---|---|
NewSession | (agentID string) error | Stop current engine, boot fresh with new session |
RecallSession | (agentID, sessionID string) error | Stop current, boot with RecallSessionID for history replay |
ListSessions | (agentID string) []SessionMeta | Session metadata for agent, sorted most-recent first |
DeleteSession | (agentID, sessionID string) error | Remove from index + delete engine session dir. Cannot delete active session |
OS integration
| Method | Signature | Description |
|---|---|---|
PickFile | (agentID, title, filter string) (string, error) | Native file open dialog, rooted in agent’s input_dir |
PickFolder | (agentID, title string) (string, error) | Native folder selection dialog |
OpenExternal | (target string) error | Open URL in system browser or file in default app |
RevealInFinder | (path string) error | Open file manager at path (Finder/Explorer/xdg-open) |
Notify | (title, body string) error | OS notification (placeholder — logs for now) |
File portal
| Method | Signature | Description |
|---|---|---|
ListFiles | (agentID, filter string) ([]FileInfo, error) | Non-recursive listing of agent’s input_dir, glob filter (e.g. "*.pdf") |
OutputDir | (agentID string) (string, error) | Resolve agent’s output_dir, create if needed |
CopyFileToInputDir | (agentID, sourcePath string) (string, error) | Copy file into input_dir (drag-and-drop). Returns dest path |
WriteFileToInputDir | (agentID, name, base64Data string) (string, error) | Write base64-encoded file to input_dir (webview fallback for drag-and-drop) |
WatchInputDir | (agentID string) | Start fsnotify watcher on input_dir. Emits {agentID}:files.changed on changes |
RAG ingestion
| Method | Signature | Description |
|---|---|---|
GetIngestState | (agentID string) IngestState | Active + recent ingestion entries for an agent. Frontends call this on load, then subscribe to {agentID}:ingest.updated for pushes |
IngestState has Active []IngestEntry and Recent []IngestEntry.
Each IngestEntry carries Path, Namespace, Status (active /
completed / failed), Chunks, SkippedCached, and Error. The
shell tracks ingest activity by subscribing to rag.ingest (priority
10, before the ingest plugin runs the synchronous work) and
rag.ingest.result. In-memory only — recent history resets on shell
restart.
Settings
| Method | Signature | Description |
|---|---|---|
GetSettingsSchema | () SettingsSchema | Full schema for frontend rendering (shell + per-agent) |
GetSettings | () map[string]map[string]any | All current values. Secrets show "__keychain__" |
UpdateSetting | (scope, key string, value any) error | Write plaintext setting. Scope = agent ID or "shell" |
UpdateSecret | (scope, key, value string) error | Write secret to OS keychain |
DeleteSetting | (scope, key string, secret bool) error | Remove plaintext setting or secret |
HasMissingRequired | () map[string][]string | Map of agentID to missing required setting keys |
Event contracts
Bus events (plugin to shell)
Events emitted by agent plugins that the shell subscribes to
internally. These do not need to be in the wails IO plugin’s
subscribe/accept config — the shell installs its own bus
subscriptions.
| Event | Payload | Purpose |
|---|---|---|
session.meta.title | { "title": string } | Set human-readable session title |
session.meta.preview | { ... } (opaque) | Agent-specific summary for session list |
session.meta.status | { "status": string } | Explicit status change ("completed", "failed", etc.) |
io.session.end | — | Signals session end, marks session as completed |
io.file.output_dir.request | { "requestID": string } | Plugin asks shell for the output directory path |
session.file.created | { "path": string, "filename": string, ... } | Plugin notifies that an output file was written |
Bus events (shell to plugin)
| Event | Payload | Purpose |
|---|---|---|
io.file.output_dir.response | { "requestID": string, "path": string, "error": string } | Output directory path response |
Bus events (frontend bridge)
These events cross the bus-to-frontend boundary and must be listed
in the wails IO plugin’s subscribe/accept config.
| Event | Direction | Config key | Purpose |
|---|---|---|---|
ui.state.save | Frontend to bus | accept | Frontend persists UI state |
ui.state.restore | Bus to frontend | subscribe | Shell restores UI state on recall |
| Domain events | Either | subscribe/accept | Agent-specific events (e.g. work.request, work.result) |
Wails events (shell direct)
Events emitted by the shell directly via wailsruntime.EventsEmit,
bypassing the bus bridge. Listen with window.runtime.EventsOn.
| Event | Payload | Purpose |
|---|---|---|
{agentID}:sessions.updated | JSON string of []SessionMeta | Session list changed for agent |
{agentID}:files.changed | — | Files added/removed in watched input_dir |
{agentID}:ingest.updated | JSON string of IngestState | RAG ingestion started or completed (active + recent history) |
Settings store
Persistence
- Plaintext:
~/.nexus/desktop/settings.json— JSON file with{ version, shell: {}, agents: {} }structure. - Secrets: OS keychain via
go-keyring, service name"nexus-desktop", account"{scope}.{key}". - Sentinel: Secret fields store
"__keychain__"in the JSON file so the frontend knows a value exists without exposing it.
Scope resolution
When resolving a ${var} placeholder in config YAML:
- Check agent scope for the key
- Fall back to shell scope
- Fall back to
SettingsField.Default - If still unresolved and
Required: true, boot fails
Keys prefixed with shell. (e.g. shell.api_key) are always looked
up in shell scope directly.
Built-in shell settings
| Key | Type | Default | Description |
|---|---|---|---|
session_root | path | ~/.nexus/sessions | Session storage directory |
session_retention_days | number | 30 | Days to keep sessions before cleanup (1–365) |
shared_data_dir | path | — | Shared directory accessible to all agents |
Session index
Persisted at ~/.nexus/desktop/sessions.json.
Maintenance (runs on startup)
- Cleanup: Removes sessions older than
session_retention_daysfrom both the index and disk. - Reconcile: Adopts orphaned engine directories (on disk but not in index) if newer than the retention cutoff. Removes stale index entries whose directories no longer exist on disk.
File watcher
Single-directory watcher using fsnotify. Debounced at 200ms.
- Only fires on
Create,Remove, andRenameoperations. - Calling
Watch(newDir)automatically unwatches the previous directory. - Calling
Watch("")stops watching without starting a new watch. - Notifications arrive as
{agentID}:files.changedWails events.
Config resolution
resolveConfig() performs ${key} placeholder substitution on raw
YAML bytes before engine creation. For each SettingsField:
- If
${field.Key}appears in the YAML, resolve it - Shell-prefixed keys (
shell.xxx) look up in shell scope directly - Other keys check agent scope, then fall back to shell scope
- Unresolved required fields are collected and returned as an error
- Unresolved optional fields are left as literal
${key}— the plugin sees the placeholder string and can handle or ignore it
Nexus Desktop Design System — Priming Brief
Paste this whole document into Claude Design as a system/priming prompt. It describes the existing desktop UI system so any new screens or components remain consistent with what’s already shipped.
What you are working on
You are designing UI for Nexus desktop apps — Go/Wails-based native apps that embed AI agents. Each desktop app hosts one or more “agents” (domain-specific AI workflows) in a consistent shell. Examples in the repo: a multi-agent reference app (hello-world + staffing-match) and a single-agent phased workflow app (tech lead).
Your job is to design UI that slots into this existing system without reinventing it. Prefer existing primitives and class recipes over new ones.
Tech stack (non-negotiable)
- Tailwind CSS via CDN (
https://cdn.tailwindcss.com) - DaisyUI 4 as the component layer (
https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css) - Font Awesome 6.5.1 (free, solid style by default:
fa-solid) - AlpineJS 3 for state and reactivity (
Alpine.store,x-data,x-show,x-for,x-transition,x-model) - No build step. Everything ships as a single
index.htmlper app with inline<script>and<style>. - No other libraries. No React, Vue, Svelte, or component kits. No CSS-in-JS.
Always use DaisyUI utility classes first (btn, card, input, alert, badge, collapse, loading, textarea, toggle, select). Fall back to raw Tailwind only for layout, spacing, and DaisyUI gaps.
Theme
- Dark theme is default and primary.
<html data-theme="dark">. Light mode is not currently supported — don’t design light-first mockups. - Semantic color tokens come from DaisyUI:
primary,secondary,accent,success,warning,error,info,base-100(app bg),base-200(panel bg),base-300(borders/hover),base-content(text). - Opacity is the workhorse for hierarchy. Use
text-base-contentat/40/50/60/80instead of introducing new grays. Example: section headers useopacity-50, body meta usesopacity-40. - Primary color is used sparingly — reserved for active nav items, primary CTAs, and “this is the focused thing” accents. Common active-state pattern:
bg-primary/15 text-primary, icon slotbg-primary/20. - Status colors:
success= done/running-healthy,warning= booting/attention,error= failed/destructive,info= neutral context,primary= active/pulsing.
Typography
- Base font = Tailwind default sans-serif. Monospace (
font-mono) is used for file paths, IDs, token counts, and code/specs. - Section headers:
text-xs font-semibold uppercase tracking-wider opacity-50— used for “SESSIONS”, “ARTIFACTS”, “FILES”, “GENERAL”, etc. - Page title:
font-semibold text-base leading-tightin headers,text-xl font-boldfor phase/view titles in main content. - Labels:
text-sm font-mediumfor form labels;text-xs opacity-50for field descriptions/helper text. - Body text:
text-smdefault;text-xsfor meta, timestamps, counts. - Never use custom font sizes in px. Stick to Tailwind’s
text-[10px],text-xs,text-sm,text-base,text-lg,text-xl.
Layout archetypes
Two shell archetypes exist. Pick the one that matches the app and keep within it.
Archetype A — Multi-agent shell (horizontal)
┌─────┬──────────┬───────────────────┬────────┐
│ Nav │ Sessions │ Main │ Files │
│ 64↔ │ 256px │ flex-1 │ 288px │
│220px│ (cond.) │ │(toggle)│
└─────┴──────────┴───────────────────┴────────┘
- Left nav (
w-16collapsed ↔w-[220px]expanded,bg-base-200 border-r border-base-300): logo + agent list + settings. Each agent row is an icon with a status dot and an optional label. Active agent usesbg-primary/15 text-primary. - Sessions panel (
w-64,bg-base-200/50 border-r border-base-300): visible when an agent is active. Conditional viax-show. - Main (
flex-1): per-agent content. Header on top with title, description, and inline action buttons. The header is draggable (wails-drag). - File browser (
w-72,bg-base-200/50 border-l border-base-300): toggleable right panel rooted in the agent’sinput_dir.
Archetype B — Single-agent phased shell (vertical)
┌──────────────────────────────────────────┐
│ ① ━ ② ━ ③ ━ ④ ━ ⑤ [sessions] [⚙] │ ← phase stepper bar
├──────────┬─────────────────┬─────────────┤
│ Sessions │ Main content │ (optional) │
│ 224px │ flex-1 │ │
│ (toggle) │ │ │
├──────────┴─────────────────┴─────────────┤
│ Artifact tree (224px, left) │
└──────────────────────────────────────────┘
- Phase stepper bar on top (
bg-base-200 border-b border-base-300 px-6 py-3, alsowails-drag): numbered step chips with connector lines. Active step =bg-primary text-primary-content, completed =bg-success/20 text-successwith ✓, future =text-base-content/40 cursor-default. - Artifact tree (
w-56): grouped list of generated artifacts (documents, epics → stories nested, notes, timeline, comms). Each group has an uppercase section header. - Main switches content per active phase via
x-show="$store.reqflow.phase === N".
Shell chrome (apply to both archetypes)
- Dragging the window uses the class
wails-drag(with CSS--wails-draggable: drag;). Put it on the topmost header bar. - Body:
bg-base-100 text-base-content,overflow-hidden, root ish-screen flex(horizontal shells) orh-screen flex flex-col(vertical shells). - Panels:
bg-base-200/50, borders viaborder-base-300. Don’t invent new panel backgrounds. - Transitions for panel enter/exit:
150ms ease-out, 8px slide + fade. Usex-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 -translate-x-2" x-transition:enter-end="opacity-100 translate-x-0"translate-x-2for right-side panels entering from the right.
Component recipes (memorize these)
Buttons
- Primary action:
btn btn-primary btn-sm - Secondary/tertiary:
btn btn-ghost btn-sm(common) orbtn btn-outline btn-sm - Icon-only square:
btn btn-ghost btn-sm btn-square(toolbar),btn btn-ghost btn-xs btn-square(in-row) - Destructive inline:
btn btn-ghost btn-xs btn-square hover:text-erroror... text-error - Inside a button: icon first (
<i class="fa-solid fa-... text-xs">), then a label span. Loading state swaps icon for<span class="loading loading-spinner loading-xs">. - Size ladder:
btn-xs(dense toolbars, in-row actions) →btn-sm(default) →btn(rare, page-level CTA).
Nav/list items (active/hover pattern)
Reused across the agent nav, session list, artifact tree, and file list:
<button
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors text-left group"
:class="isActive
? 'bg-primary/15 text-primary'
: 'hover:bg-base-300 text-base-content/70 hover:text-base-content'">
<div class="w-8 h-8 rounded-lg flex items-center justify-center"
:class="isActive ? 'bg-primary/20' : 'bg-base-300'">
<i class="fa-solid fa-icon text-sm"></i>
</div>
<div class="min-w-0 flex-1">
<div class="text-sm font-medium truncate">Label</div>
<div class="text-xs opacity-50 truncate">Sub-label</div>
</div>
</button>
Status dots
<div class="w-2.5 h-2.5 rounded-full border-2 border-base-200"
:class="{
'bg-success': status === 'running',
'bg-warning animate-pulse': status === 'booting',
'bg-error': status === 'error'
}"></div>
Small-size variant for list rows: w-1.5 h-1.5 rounded-full.
Cards
<div class="card bg-base-200 border border-base-300 hover:border-primary/40 transition-colors">
<div class="card-body p-4">...</div>
</div>
Badges
- Count/label:
badge badge-sm - Semantic:
badge-success,badge-warning,badge-error,badge-primary,badge-ghost - Tiny inline:
badge badge-xs
Inputs
- Text:
input input-bordered input-sm w-full - Textarea:
textarea textarea-bordered w-full text-sm(addmin-h-[7rem]for multi-line prompts) - Select:
select select-bordered select-sm w-full - Toggle:
input type="checkbox" class="toggle toggle-primary toggle-sm" - Secret (password): same input, add an eye-toggle button absolutely positioned right (
absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle). - Invalid/required: add
input-warningclass when the field is required and empty.
Alerts
- Error:
alert alert-error py-2 text-smwith leading<i class="fa-solid fa-triangle-exclamation"></i> - Warning/missing-config:
alert alert-warning - Inline error under a form:
text-xs text-warningwith<i class="fa-solid fa-circle-exclamation">
Empty states
<div class="flex items-center justify-center h-full">
<div class="text-center max-w-sm">
<div class="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto mb-4">
<i class="fa-solid fa-icon text-primary text-2xl"></i>
</div>
<h2 class="text-lg font-semibold mb-1">Short headline</h2>
<p class="text-sm opacity-60">One-sentence explainer with a call to action.</p>
</div>
</div>
Compact variant for narrow panels: drop the icon tile to text-2xl opacity-20, text to text-xs opacity-40, center vertically in the panel.
Loading states
- Inline (inside a button):
loading loading-spinner loading-xs - Panel-level:
loading loading-spinner loading-sm opacity-40 - Hero / boot overlay:
loading loading-spinner loading-lg text-primarycentered with a subtitle - “Thinking…” dots:
loading loading-dots loading-sm - Full-screen boot overlay:
absolute inset-0 flex items-center justify-center bg-base-100/80 z-10
Collapsible sections (settings)
DaisyUI collapse: collapse collapse-arrow bg-base-200 border border-base-300, with <input type="checkbox" checked> inside to drive state. Title row: collapse-title font-semibold flex items-center gap-2 with a leading icon.
Drag-and-drop
Drop overlay (appears while dragging):
<div class="absolute inset-0 z-20 bg-primary/10 border-2 border-dashed border-primary rounded-lg flex items-center justify-center pointer-events-none">
<div class="text-center">
<i class="fa-solid fa-cloud-arrow-up text-primary text-2xl mb-2"></i>
<p class="text-sm font-medium text-primary">Drop files here</p>
</div>
</div>
Persistent drop zone (phase 1 import style):
<div class="border-2 border-dashed border-base-300 rounded-xl p-8 text-center transition-all"
:class="{ 'drop-active': dragOver }">
<i class="fa-solid fa-cloud-arrow-up text-4xl text-base-content/20 mb-4 block"></i>
<p class="text-base-content/40 mb-3">Drop markdown files here</p>
<button class="btn btn-sm btn-primary">Browse Files</button>
</div>
Chat messages (agent conversation)
- User: right-aligned,
bg-primary text-primary-contentbubble, no avatar or avatar on the right - Assistant: left-aligned,
bg-base-200bubble, avatar isw-8 h-8 rounded-full bg-primary/20withfa-roboticon - Note/annotation:
bg-warning/10 border border-warning/20bubble,fa-sticky-noteavatar - Max-width bubble:
max-w-[75%] rounded-lg px-4 py-3 - Auto-scroll on new message via
x-effect="$refs.chatScroll && ($refs.chatScroll.scrollTop = $refs.chatScroll.scrollHeight)"
Interaction rules
- Hover reveal: destructive or secondary actions inside list rows use
opacity-0 group-hover:opacity-100 transition-opacity. Never show delete buttons unconditionally. - Keyboard: chat textareas support
@keydown.enter.meta="send()"and@keydown.enter.ctrl="send()"(Cmd/Ctrl+Enter). Show the hint astext-xs opacity-30below the input. - Required-field gating: when required settings are missing, show a
alert alert-warningbanner at the top of settings, and mark each missing field with an inlinetext-xs text-warning“Required” hint andinput-warningclass. - Restart nudge: when a running agent has dirty settings, show
badge badge-warning badge-xs“restart required” on the section and abtn btn-warning btn-smat the bottom.
Data flow (so designs match reality)
- All agent domain communication flows through a scoped event bus bridge (
createBus(agentID)), not Wails-bound methods. Your designs should assume async, event-driven updates — never blocking waits. - The shell owns: agent lifecycle (boot/stop), sessions, settings, file dialogs, file browser, drag-and-drop.
- The agent owns: its own UI section, its own Alpine store, its own input/output events.
- State that survives session recall lives in
ui.state.save/ui.state.restoreevents — designs should gracefully rehydrate partial state.
What NOT to do
- Don’t introduce a new CSS framework, component library, or JS framework. Alpine + Tailwind + DaisyUI is the entire toolbox.
- Don’t add light mode without checking first — everything assumes dark.
- Don’t invent new named colors. Stick to the DaisyUI semantic tokens.
- Don’t use raw gray scales (
text-gray-500). Useopacity-*ontext-base-content. - Don’t add routing. Views switch via
x-showagainst a single activeView string. - Don’t design multi-page flows with URLs. Everything is SPA-style, single
index.html. - Don’t add modals/dialogs casually — the existing apps don’t use them. Use inline collapsibles, side panels, or dedicated views instead. If you need a confirm, use inline “Are you sure?” patterns.
- Don’t design for mobile or narrow widths. Desktop only, min width roughly 900×720.
- Don’t add animations beyond DaisyUI
animate-pulseand the existing 150ms panel transitions. No hero scroll effects, no motion beyond utility. - Don’t add icons from other sets — Font Awesome 6 solid only.
When proposing something new
If your design needs a component not listed above, say so explicitly and propose either (a) a DaisyUI component that isn’t yet used, or (b) a raw-Tailwind recipe that matches the existing visual weight. Flag it as a net-new primitive so we can decide whether to adopt it.
Quick reference — the 10 classes you’ll use constantly
| Purpose | Class |
|---|---|
| Active list item | bg-primary/15 text-primary |
| Hover list item | hover:bg-base-300 text-base-content/70 hover:text-base-content |
| Panel background | bg-base-200/50 |
| Panel border | border-base-300 |
| Section header | text-xs font-semibold uppercase tracking-wider opacity-50 |
| Meta text | text-xs opacity-50 |
| Primary CTA | btn btn-primary btn-sm |
| Icon button | btn btn-ghost btn-sm btn-square |
| Card | card bg-base-200 border border-base-300 hover:border-primary/40 |
| Empty state icon tile | w-16 h-16 rounded-2xl bg-primary/10 |
Nexus Desktop Design System — Full Reference
Comprehensive reference for the Nexus desktop UI system. Derived from the shipped frontends at cmd/desktop/frontend/dist/index.html and cmd/techlead/frontend/dist/index.html, plus the shell framework at pkg/desktop/. Pair with design-system-brief.md for a shorter priming version.
1. Scope and context
Nexus ships desktop apps via Wails v2 — each app is a Go binary wrapping a webview. The webview loads a single index.html containing the entire UI. Two kinds of apps exist today:
- Multi-agent shells (reference:
cmd/desktop) — host several domain-specific agents under one roof, each with its own UI section. - Single-agent workflow apps (reference:
cmd/techlead) — one agent, one workflow, typically organized as a phased progression.
Both share the same visual language, component vocabulary, and tech stack. A designer working on either should be able to port patterns between them without friction.
Integration with the engine
The UI does not directly “call” the agent. All domain communication flows through a scoped event bus bridge (createBus(agentID)) that wraps Wails event APIs. Every design should assume:
- Actions emit bus events; results arrive as inbound bus events (asynchronous).
- The shell (not the agent) owns file dialogs, the file browser, settings, and session management — these appear in UI via Wails-bound methods on the shell.
- UI state that must survive session recall is saved via
ui.state.saveevents and rehydrated viaui.state.restore.
What doesn’t exist yet
- No light theme. Everything assumes
data-theme="dark". - No modals or dialog components are in use. New screens should avoid them unless explicitly requested.
- No routing library. Views switch via Alpine stores against
activeView/phase number. - No component library beyond DaisyUI. No icon set beyond Font Awesome 6 solid.
- No responsive/mobile layouts. Min window size ~900×720.
2. Tech stack
| Layer | Choice | Source |
|---|---|---|
| CSS framework | Tailwind CSS | https://cdn.tailwindcss.com |
| Component layer | DaisyUI 4 | https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css |
| Icons | Font Awesome 6.5.1 Free | https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css |
| JS reactivity | AlpineJS 3 | https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js |
| Shell runtime | Wails v2 | Go module github.com/wailsapp/wails/v2 |
| Desktop chrome | System native (macOS/Windows/Linux) via Wails |
Everything is CDN-loaded; there is no bundler, no npm install, no TypeScript, no build step. A new frontend is one self-contained index.html.
3. Design tokens
3.1 Color tokens (DaisyUI semantic)
Never introduce raw gray scales. Use DaisyUI’s semantic tokens.
| Token | Purpose | Typical use |
|---|---|---|
primary | Brand / active / focus | Active nav, primary CTA, selected item |
secondary | Alt accent | Story IDs, tertiary badges |
accent | Third accent | Epic IDs |
success | Healthy / complete | Status dots (running healthy), completed phase checks |
warning | Attention / boot | Status dot (booting), unsaved marker, required-field highlight |
error | Failure / destructive | Error alerts, failed status, destructive hover |
info | Neutral context | Document icons in artifact tree |
base-100 | App background | bg-base-100 on body and main content |
base-200 | Panel background | Nav sidebars, session panel, file panel, cards |
base-300 | Borders, hover surfaces | border-base-300, hover:bg-base-300 |
base-content | Primary text | Body copy, labels |
Opacity for hierarchy — the conventional ladder:
| Token | Where used |
|---|---|
text-base-content (100%) | Primary body text, button labels |
text-base-content/80 | Body text one step de-emphasized |
text-base-content/70 | Unfocused nav/list item labels |
text-base-content/60 | Secondary prose, helper text |
opacity-50 | Meta text, section headers, non-active icons |
opacity-40 | Timestamps, sub-counts |
opacity-30 | Path hints, “not configured” text |
opacity-20 | Empty state background icons, disabled glyphs |
Common /N accent patterns:
| Pattern | Meaning |
|---|---|
bg-primary/10 | Background wash for empty state icon tiles |
bg-primary/15 text-primary | Active nav/list row state |
bg-primary/20 | Active icon slot background, avatars |
border-primary/40 | Hover border on cards |
bg-primary/5 | Drag-over wash (very subtle) |
bg-warning/10 border-warning/20 | Note/annotation bubbles |
bg-warning/30 | Completed-phase step marker circle |
bg-success/20 text-success | Completed-phase step button |
3.2 Spacing scale
Follows Tailwind’s default 4px-based scale. Conventions observed:
| Context | Padding |
|---|---|
| Panel headers | px-3 py-3 (256-column panels) or px-6 py-3 (main-content headers) |
| List item | px-2.5 py-2 to px-3 py-2.5 |
| Card body | card-body p-4 |
| Form sections | space-y-4 between fields, space-y-6 between major sections |
| Section top padding | py-5 for content areas, py-3 for toolbars |
| Collapsed nav | w-16 / w-[64px] |
| Expanded nav | w-[220px] |
| Session panel | w-64 (256px) in multi-agent, w-56 (224px) in single-agent |
| File panel | w-72 (288px) |
| Artifact tree | w-56 (224px) |
3.3 Typography scale
All sizes come from Tailwind defaults. Do not use custom px.
| Class | Pixel | Use |
|---|---|---|
text-[10px] | 10 | Micro-labels inside nested artifact trees |
text-xs | 12 | Meta, timestamps, sub-labels, section headers |
text-sm | 14 | Default body, form inputs, list item labels |
text-base | 16 | Content title in header |
text-lg | 18 | Empty-state headline, page title in detail view |
text-xl | 20 | Phase title (h2 for active phase/view) |
text-2xl | 24 | Rare — hero icons, boot screen |
text-4xl | 36 | Empty-state background icon |
Weight conventions: font-medium for item labels, font-semibold for headers, font-bold for h2 page titles.
Font family: Tailwind’s default sans-serif. font-mono for: file paths, artifact IDs (epic/story IDs like E-001), token counts, USD cost, code, raw markdown, session preview paths.
3.4 Radii
| Class | Pixel | Use |
|---|---|---|
rounded | 4 | Tight micro-chips |
rounded-md / rounded | 4-6 | Tight list rows inside trees |
rounded-lg | 8 | Default for nav items, cards, message bubbles, panels inside content |
rounded-xl | 12 | Drop zones, larger panels |
rounded-2xl | 16 | Empty-state icon tiles (w-16 h-16 rounded-2xl) |
rounded-full | 999 | Status dots, avatars |
3.5 Shadows, transitions, motion
- Default transition on interactive surfaces:
transition-colors. - Panel enter/exit:
transition ease-out duration-150with an 8px slide + fade. - Hover card:
transition-shadow+hover:shadow-mdon epic cards,hover:border-primary/40elsewhere. - Status pulse:
animate-pulseon booting/running indicator dots. - Loading spinner: DaisyUI
loading loading-spinner, never custom keyframes. loading-dotsfor chat “thinking” indicator.- No parallax, no hero scroll effects, no Framer Motion.
4. Shell archetypes
4.1 Multi-agent horizontal shell
┌─────┬──────────┬─────────────────────────┬────────┐
│ Nav │ Sessions │ Header (wails-drag) │ Files │
│ │ ├─────────────────────────┤ │
│ │ │ Main content │ │
│ │ │ │ │
│ │ │ │ │
└─────┴──────────┴─────────────────────────┴────────┘
64px 256px flex-1 288px
(coll.) (cond.) (toggle)
Skeleton:
<body class="bg-base-100 text-base-content">
<div x-data class="h-screen flex" x-init="$store.shell.init()">
<nav class="nav-sidebar flex-shrink-0 bg-base-200 border-r border-base-300 flex flex-col h-full"
:style="{ width: $store.shell.collapsed ? '64px' : '220px' }">...</nav>
<aside x-show="activeAgent" class="flex-shrink-0 w-64 bg-base-200/50 border-r border-base-300 flex flex-col h-full">
<!-- Sessions panel -->
</aside>
<div class="flex-1 flex flex-col min-w-0 h-full">
<header class="px-6 py-3 border-b border-base-300 flex items-center gap-3 bg-base-100 wails-drag">...</header>
<div class="flex-1 flex overflow-hidden relative">
<main class="flex-1 overflow-hidden relative">...</main>
<aside x-show="filesOpen" class="flex-shrink-0 w-72 bg-base-200/50 border-l border-base-300 flex flex-col h-full">
<!-- File panel -->
</aside>
</div>
</div>
</div>
</body>
Key properties:
body { overflow: hidden; }— the UI is not scrolled; individual panels manage their own overflow.- Nav width animates via inline CSS transition:
.nav-sidebar { transition: width 200ms ease; }. - Nav collapse state is persisted in
Alpine.store('shell').collapsed. - Active-agent switch triggers lazy engine boot via
window.go.desktop.Shell.EnsureAgentRunning(agentID).
4.2 Single-agent phased vertical shell
┌──────────────────────────────────────────────────┐
│ ①━②━③━④━⑤━⑥━⑦ [sessions] [⚙] │ wails-drag
├──────────┬──────────┬────────────────────────────┤
│ Sessions │ Artifacts│ Main content │
│ (toggle) │ tree │ │
│ 224px │ 224px │ flex-1 │
│ │ │ │
│ │ ├────────────────────────────┤
│ │ │ Bottom bar │
└──────────┴──────────┴────────────────────────────┘
Skeleton:
<div x-data class="h-screen flex flex-col" x-init="$store.reqflow.init()">
<!-- Top phase stepper bar (wails-drag) -->
<div class="wails-drag flex-shrink-0 bg-base-200 border-b border-base-300 px-6 py-3">
<!-- phase chips + toolbar -->
</div>
<!-- Body -->
<div class="flex-1 flex overflow-hidden">
<aside x-show="showSessions" class="w-56 flex-shrink-0 bg-base-200/50 border-r border-base-300 p-3 overflow-y-auto flex flex-col">...</aside>
<aside class="w-56 flex-shrink-0 bg-base-200/50 border-r border-base-300 p-3 overflow-y-auto">...</aside>
<main class="flex-1 flex flex-col overflow-hidden">
<div class="flex-1 overflow-y-auto p-6">...</div>
<!-- Bottom action bar -->
<div class="flex-shrink-0 border-t border-base-300 bg-base-200/50 px-6 py-3 flex items-center justify-between">...</div>
</main>
</div>
</div>
4.3 Window dragging
Wails exposes --wails-draggable: drag; as a CSS custom property. Apply via:
.wails-drag { --wails-draggable: drag; }
Put this on the topmost chrome surface only (header bar in horizontal shell, phase stepper bar in vertical shell). Don’t apply it to anything interactive — dragging steals click events.
5. Component catalog
5.1 Buttons
| Variant | Class | Use |
|---|---|---|
| Primary action | btn btn-primary btn-sm | “Find matches”, “Generate”, “Send”, “Save” |
| Outline secondary | btn btn-sm btn-outline | “Add Note”, “Next Phase” |
| Ghost | btn btn-ghost btn-sm | Tertiary actions in toolbars |
| Icon-only square (sm) | btn btn-ghost btn-sm btn-square | Header toolbar icons (sessions, settings, file toggle) |
| Icon-only square (xs) | btn btn-ghost btn-xs btn-square | In-row actions (delete, refresh) |
| Icon-only circle (xs) | btn btn-ghost btn-xs btn-circle | Show/hide secret toggle |
| Warning | btn btn-warning btn-sm | “Restart agent” when settings are dirty |
| Destructive hint | add text-error or hover:text-error | Delete buttons |
| Inside input group | size matched to the input, e.g. btn btn-ghost btn-sm alongside input-sm |
Button size ladder: btn-xs (toolbars, in-row) → btn-sm (default) → btn (rare; used only for page-level CTAs inside empty-state cards like hello-world’s “Say hello”).
Tab/segment group for mode switches (editor/preview):
<div class="btn-group">
<button class="btn btn-xs" :class="mode === 'split' ? 'btn-active' : ''">
<i class="fa-solid fa-columns"></i>
</button>
<button class="btn btn-xs" :class="mode === 'edit' ? 'btn-active' : ''">
<i class="fa-solid fa-code"></i>
</button>
<button class="btn btn-xs" :class="mode === 'preview' ? 'btn-active' : ''">
<i class="fa-solid fa-eye"></i>
</button>
</div>
Button loading state: Swap icon for <span class="loading loading-spinner loading-xs mr-1"></span> while still showing the label.
5.2 Nav / list item
The single most reused pattern in the app. Same shape serves agent nav, session list, file list, artifact rows.
<button
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors text-left group"
:class="isActive
? 'bg-primary/15 text-primary'
: 'hover:bg-base-300 text-base-content/70 hover:text-base-content'">
<!-- Icon slot with optional status dot -->
<div class="relative flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center"
:class="isActive ? 'bg-primary/20' : 'bg-base-300 group-hover:bg-base-100'">
<i class="fa-solid fa-icon text-sm"></i>
<div x-show="status !== 'idle'"
class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-base-200"
:class="{
'bg-success': status === 'running',
'bg-warning animate-pulse': status === 'booting',
'bg-error': status === 'error'
}"></div>
</div>
<!-- Labels -->
<div class="min-w-0 flex-1">
<div class="text-sm font-medium truncate">Primary label</div>
<div class="text-xs opacity-50 truncate">Secondary label</div>
</div>
</button>
Density variants:
- Artifact tree rows (single-agent):
px-2 py-1 rounded text-sm— smaller, no icon slot background. - Nested list rows (stories under epics):
pl-6 pr-2 py-0.5 rounded text-xs. - Session list in multi-agent: uses the
grouppattern so a delete button appears on hover (opacity-0 group-hover:opacity-50 hover:!opacity-100).
5.3 Cards
Basic container card:
<div class="card bg-base-200 border border-base-300 hover:border-primary/40 transition-colors">
<div class="card-body p-4">
<!-- content -->
</div>
</div>
Grid of cards (epics):
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="card bg-base-200 shadow-sm cursor-pointer hover:shadow-md transition-shadow">
<div class="card-body p-4">
<div class="flex items-center gap-2 mb-1">
<span class="badge badge-primary badge-sm font-mono">E-001</span>
<h3 class="card-title text-sm">Epic title</h3>
</div>
<p class="text-xs text-base-content/50 line-clamp-3">Description excerpt...</p>
<div class="card-actions justify-end mt-2">
<span class="text-xs text-base-content/30">5 stories</span>
</div>
</div>
</div>
</div>
Candidate/ranked-result card:
<div class="card bg-base-200 border border-base-300 hover:border-primary/40 transition-colors">
<div class="card-body p-4">
<div class="flex items-start gap-3">
<div class="shrink-0 w-10 h-10 rounded-full bg-primary/10 text-primary flex items-center justify-center font-semibold text-sm">1</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="font-semibold text-base truncate">Jane Doe</span>
<span class="badge badge-sm badge-success">0.92</span>
</div>
<p class="text-sm opacity-80">Reasoning...</p>
<div class="mt-2 text-xs opacity-40 font-mono">/path/to/resume.pdf</div>
</div>
</div>
</div>
</div>
5.4 Badges
| Use | Class |
|---|---|
| Count/meta | badge badge-sm |
| Status good | badge badge-success |
| Status warning | badge badge-warning |
| Tiny inline meta | badge badge-xs |
| Epic ID | badge badge-primary font-mono (or badge-sm) |
| Story ID | badge badge-secondary badge-sm font-mono |
| Outline reference | badge badge-outline badge-xs |
| Restart-required nudge | badge badge-warning badge-xs |
| PRD marker | badge badge-primary badge-xs |
| Ghost/neutral | badge badge-ghost |
Score badges in candidate cards are threshold-driven:
c.Score >= 0.85 ? 'badge-success' : c.Score >= 0.7 ? 'badge-warning' : 'badge-ghost'
5.5 Forms
Text input (small default):
<input type="text" class="input input-bordered input-sm w-full">
Required + empty: add input-warning, with inline hint:
<div x-show="field.required && !hasValue()" class="text-xs text-warning mb-1">
<i class="fa-solid fa-circle-exclamation"></i> Required
</div>
Number:
<input type="number" class="input input-bordered input-sm w-full"
:min="field.validation?.min" :max="field.validation?.max">
Textarea:
<textarea class="textarea textarea-bordered w-full min-h-[7rem] text-sm leading-snug" rows="5"></textarea>
Variants: textarea-sm for dense settings; add font-mono text-sm leading-relaxed resize-none for markdown/spec editors.
Select:
<select class="select select-bordered select-sm w-full">
<option value="...">Label</option>
</select>
Toggle:
<input type="checkbox" class="toggle toggle-primary toggle-sm">
Secret (password) with visibility toggle:
<div class="relative flex-1">
<input :type="showSecret ? 'text' : 'password'"
class="input input-bordered input-sm w-full pr-10"
:placeholder="hasValue() ? '********' : 'Enter value...'"
@change="saveSecret($event.target.value); $event.target.value = ''">
<button class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle"
@click="showSecret = !showSecret">
<i class="fa-solid text-xs" :class="showSecret ? 'fa-eye-slash' : 'fa-eye'"></i>
</button>
</div>
Secret inputs never bind :value — they show placeholder ******** when a value exists, and only commit on @change, clearing the DOM input after save so the raw value never lingers in the DOM.
Path input + browse:
<div class="flex gap-2">
<input type="text" class="input input-bordered input-sm flex-1"
:value="currentValue()" @change="save($event.target.value)">
<button class="btn btn-ghost btn-sm" @click="browse()">
<i class="fa-solid fa-folder-open text-xs"></i> Browse
</button>
</div>
The browse() handler calls window.go.desktop.Shell.PickFolder(...) or PickFile(...).
Form label + description:
<label class="block text-sm font-medium mb-1">Display name</label>
<div class="text-xs opacity-50 mb-2">Optional description.</div>
5.6 Alerts
| Kind | Class | Use |
|---|---|---|
| Error | alert alert-error py-2 text-sm | Runtime errors, validation failures |
| Warning | alert alert-warning | Missing required settings banner |
| Success / info / neutral | alert alert-success / alert alert-info | Not currently used — reserve for future |
Standard error shape:
<div class="alert alert-error py-2 text-sm">
<i class="fa-solid fa-triangle-exclamation"></i>
<span>Error message</span>
</div>
5.7 Collapsible sections (settings)
DaisyUI collapse with arrow, open by default:
<div class="collapse collapse-arrow bg-base-200 border border-base-300">
<input type="checkbox" checked>
<div class="collapse-title font-semibold flex items-center gap-2">
<i class="fa-solid fa-sliders text-sm opacity-60"></i>
Section title
<span class="badge badge-warning badge-xs ml-2">restart required</span>
</div>
<div class="collapse-content space-y-4">
<!-- fields -->
</div>
</div>
Highlight a collapse when it has missing required fields: :class="missingRequired ? 'border-warning' : ''".
5.8 Empty states
Primary pattern (main content area):
<div class="flex items-center justify-center h-full">
<div class="text-center max-w-sm">
<div class="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto mb-4">
<i class="fa-solid fa-magnifying-glass text-primary text-xl"></i>
</div>
<h2 class="text-lg font-semibold mb-1">No matches yet</h2>
<p class="text-sm opacity-60">Upload a PDF and click Find matches.</p>
</div>
</div>
Muted variant (used in phases 1–7 of single-agent shell): drops the tinted icon tile and uses a large low-opacity glyph:
<div class="flex-1 flex items-center justify-center">
<div class="text-center text-base-content/30 max-w-md">
<i class="fa-solid fa-file-code text-4xl mb-4 block"></i>
<p class="font-medium mb-2">No specification yet</p>
<p class="text-sm mb-4">Generate from your PRD and discussion notes.</p>
<button class="btn btn-primary btn-sm"><i class="fa-solid fa-wand-magic-sparkles mr-1"></i> Generate Spec</button>
</div>
</div>
Compact (narrow panels, file list empty):
<div class="text-center">
<i class="fa-solid fa-file-circle-plus text-2xl opacity-20 mb-2"></i>
<p class="text-xs opacity-40">No files found</p>
<p class="text-xs opacity-30 mt-1">Drop files here or add to input folder.</p>
</div>
5.9 Loading states
| Use | Class |
|---|---|
| Inline spinner in button | loading loading-spinner loading-xs mr-1 |
| Panel-level spinner | loading loading-spinner loading-sm opacity-40 |
| Hero / full-screen spinner | loading loading-spinner loading-lg text-primary |
| Chat “thinking…” | loading loading-dots loading-sm |
Boot overlay (covers a section while the engine is starting):
<div x-show="status === 'booting'"
x-transition
class="absolute inset-0 flex items-center justify-center bg-base-100/80 z-10">
<div class="text-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="mt-3 text-sm opacity-60">Starting engine...</p>
</div>
</div>
Generation overlay (long-running content creation):
<div class="flex-1 flex items-center justify-center">
<div class="text-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="mt-4 text-base-content/60">Generating timeline estimates...</p>
<p class="text-sm text-base-content/30 mt-1">Analyzing epics and stories</p>
</div>
</div>
5.10 Chat / conversation
Layout:
<div class="flex gap-3" :class="msg.role === 'user' ? 'justify-end' : ''">
<!-- Avatar (non-user only) -->
<div x-show="msg.role !== 'user'"
class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0"
:class="msg.role === 'note' ? 'bg-warning/20' : 'bg-primary/20'">
<i class="text-xs" :class="msg.role === 'note'
? 'fa-solid fa-sticky-note text-warning'
: 'fa-solid fa-robot text-primary'"></i>
</div>
<!-- Bubble -->
<div class="max-w-[75%] rounded-lg px-4 py-3"
:class="{
'bg-primary text-primary-content': msg.role === 'user',
'bg-base-200': msg.role === 'assistant',
'bg-warning/10 border border-warning/20': msg.role === 'note'
}">
<div x-show="msg.role === 'note'" class="text-xs font-semibold text-warning/70 mb-1">Technical Note</div>
<div class="text-sm whitespace-pre-wrap doc-preview" x-text="msg.content"></div>
</div>
<!-- Avatar (user) -->
<div x-show="msg.role === 'user'"
class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center flex-shrink-0">
<i class="fa-solid fa-user text-xs text-primary"></i>
</div>
</div>
Auto-scroll to bottom:
<div x-ref="chatScroll"
x-effect="$refs.chatScroll && ($refs.chatScroll.scrollTop = $refs.chatScroll.scrollHeight)"
class="flex-1 overflow-y-auto space-y-4 mb-4 pr-2">...</div>
Chat input with Cmd/Ctrl+Enter send:
<div class="flex gap-2">
<textarea class="textarea textarea-bordered flex-1 text-sm" rows="2"
placeholder="..."
@keydown.enter.meta="send()" @keydown.enter.ctrl="send()"></textarea>
<button class="btn btn-primary btn-sm self-end">
<i class="fa-solid fa-paper-plane"></i>
</button>
</div>
<div class="text-xs text-base-content/30 mt-1">Cmd+Enter to send</div>
5.11 Phase stepper
<div class="flex items-center gap-1">
<template x-for="phase in phases" :key="phase.number">
<div class="flex items-center">
<button class="phase-step flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
:class="{
'active bg-primary text-primary-content': phase.number === current,
'bg-success/20 text-success': phase.number < current,
'text-base-content/40 cursor-default': phase.number > current
}"
:disabled="phase.number > current"
@click="phase.number < current && goToPhase(phase.number)">
<span class="w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold"
:class="{
'bg-primary-content/20': phase.number === current,
'bg-success/30': phase.number < current,
'bg-base-300': phase.number > current
}"
x-text="phase.number < current ? '✓' : phase.number">
</span>
<span x-text="phase.label" class="hidden sm:inline"></span>
</button>
<div x-show="phase.number < phases.length"
class="w-4 h-0.5 mx-0.5"
:class="phase.number < current ? 'bg-success/40' : 'bg-base-300'"></div>
</div>
</template>
</div>
Supporting CSS:
.phase-step { transition: all 200ms ease; }
.phase-step.active { transform: scale(1.05); }
Rules:
- Future phases are disabled, cursor-default, at 40% opacity.
- Completed phases are green (
bg-success/20 text-success) with a ✓. - Active phase is primary-filled and slightly scaled up.
- Connectors between steps mirror step state.
5.12 Drag-and-drop
Overlay on a panel while dragging:
<aside @dragover.prevent="dragOver = true"
@dragleave.prevent="dragOver = false"
@drop.prevent="handleDrop($event)"
class="relative ...">
<div x-show="dragOver" x-transition
class="absolute inset-0 z-20 bg-primary/10 border-2 border-dashed border-primary rounded-lg flex items-center justify-center pointer-events-none">
<div class="text-center">
<i class="fa-solid fa-cloud-arrow-up text-primary text-2xl mb-2"></i>
<p class="text-sm font-medium text-primary">Drop files here</p>
</div>
</div>
</aside>
Persistent drop zone (centerpiece of a page):
<div x-data="{ dragOver: false }"
class="border-2 border-dashed border-base-300 rounded-xl p-8 text-center transition-all"
:class="{ 'drop-active': dragOver }"
@dragover.prevent="dragOver = true"
@dragleave.prevent="dragOver = false"
@drop.prevent="dragOver = false; handleDrop($event)">
<i class="fa-solid fa-cloud-arrow-up text-4xl text-base-content/20 mb-4 block"></i>
<p class="text-base-content/40 mb-3">Drop markdown files here</p>
<button class="btn btn-sm btn-primary"><i class="fa-solid fa-folder-open mr-1"></i> Browse Files</button>
<p class="text-xs text-base-content/30 mt-3">Supports .md, .markdown, .txt</p>
</div>
Supporting CSS (drop-active emphasis):
.drop-active {
border-color: oklch(var(--p)) !important;
background-color: oklch(var(--p) / 0.05);
}
5.13 Markdown preview
Content rendered via doc-preview class gets basic markdown styling:
.doc-preview h1 { font-size: 1.5rem; font-weight: 700; margin: 1rem 0 0.5rem; }
.doc-preview h2 { font-size: 1.25rem; font-weight: 600; margin: 0.75rem 0 0.5rem; }
.doc-preview h3 { font-size: 1.1rem; font-weight: 600; margin: 0.5rem 0 0.25rem; }
.doc-preview p { margin: 0.25rem 0; }
.doc-preview ul, .doc-preview ol { padding-left: 1.5rem; margin: 0.25rem 0; }
.doc-preview li { margin: 0.1rem 0; }
.doc-preview code { background: oklch(var(--b3)); padding: 0.1rem 0.3rem; border-radius: 0.25rem; font-size: 0.875rem; }
.doc-preview pre { background: oklch(var(--b3)); padding: 0.75rem; border-radius: 0.5rem; overflow-x: auto; }
.doc-preview pre code { background: none; padding: 0; }
.doc-preview hr { border-color: oklch(var(--bc) / 0.1); margin: 1rem 0; }
.doc-preview blockquote { border-left: 3px solid oklch(var(--bc) / 0.2); padding-left: 1rem; opacity: 0.8; }
Use oklch(var(--*)) to reference DaisyUI theme variables directly: --p (primary), --b3 (base-300), --bc (base-content), etc. This is the only place in the system where CSS custom property access is routine.
5.14 Split editor / preview
Three-mode toggle (split/edit/preview) + synchronized textarea and preview pane:
<div class="flex-1 flex gap-4 min-h-0 overflow-hidden">
<div x-show="viewMode !== 'preview'"
class="flex-1 flex flex-col min-h-0"
:class="viewMode === 'split' ? 'max-w-[50%]' : ''">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-semibold text-base-content/50 uppercase tracking-wider">Editor</span>
<span x-show="dirty" class="text-xs text-warning">
<i class="fa-solid fa-circle text-[6px] mr-1"></i>Unsaved
</span>
</div>
<textarea class="textarea textarea-bordered flex-1 font-mono text-sm leading-relaxed resize-none"
x-model="editContent"
@input="dirty = true"
@blur="saveEdit()"
@keydown.meta.s.prevent="saveEdit()"
@keydown.ctrl.s.prevent="saveEdit()"></textarea>
</div>
<div x-show="viewMode !== 'edit'"
class="flex-1 flex flex-col min-h-0"
:class="viewMode === 'split' ? 'max-w-[50%]' : ''">
<div class="flex items-center mb-2">
<span class="text-xs font-semibold text-base-content/50 uppercase tracking-wider">Preview</span>
</div>
<div class="flex-1 overflow-y-auto bg-base-200 rounded-lg p-4 doc-preview">
<div x-html="renderPreview()"></div>
</div>
</div>
</div>
5.15 Bottom action bar (single-agent only)
<div class="flex-shrink-0 border-t border-base-300 bg-base-200/50 px-6 py-3 flex items-center justify-between">
<span x-show="status" x-text="status" class="text-sm text-base-content/60"></span>
<button class="btn btn-sm btn-outline" :disabled="phase >= phases.length" @click="advance()">
Next Phase <i class="fa-solid fa-chevron-right ml-1"></i>
</button>
</div>
5.16 Copy-to-clipboard button
<button class="btn btn-ghost btn-xs" @click="copyToClipboard()">
<i class="fa-solid fa-copy mr-1"></i>
<span x-text="copied ? 'Copied!' : 'Copy'"></span>
</button>
Toggle the copied flag to true for ~1.5s after success, then reset.
5.17 Tabs (in-content)
The comms phase uses a button-row “tabs” pattern rather than DaisyUI’s tabs component:
<div class="flex gap-1 mb-3 flex-shrink-0">
<template x-for="item in items" :key="item.template">
<button class="btn btn-xs"
:class="selected === item.template ? 'btn-primary' : 'btn-ghost'"
@click="selected = item.template"
x-text="item.title"></button>
</template>
</div>
6. Font Awesome icon inventory
Every icon that appears in the shipped apps, grouped by role. Use these first before reaching for alternatives.
App/brand: fa-bolt (Nexus logo)
Nav/chrome: fa-gear, fa-angles-left, fa-angles-right, fa-xmark, fa-plus, fa-chevron-right, fa-arrow-left, fa-rotate-right, fa-arrows-rotate
Files: fa-folder-open, fa-folder, fa-folder-plus, fa-file, fa-file-lines, fa-file-code, fa-file-circle-plus, fa-paperclip, fa-cloud-arrow-up, fa-arrow-up-right-from-square (open external)
Chat / communication: fa-comments, fa-paper-plane, fa-robot, fa-user, fa-sticky-note, fa-bullhorn
Generation / action: fa-wand-magic-sparkles, fa-magnifying-glass, fa-copy
Workflow: fa-layer-group (epics), fa-list-check (stories), fa-calendar-days (timeline)
Settings/fields: fa-sliders, fa-eye, fa-eye-slash, fa-trash-can, fa-circle-exclamation, fa-triangle-exclamation, fa-circle (tiny marker dot)
Edit/view mode toggle: fa-code, fa-columns, fa-eye
Session / history: fa-clock-rotate-left
Always fa-solid (solid style). All icons are the free tier — don’t introduce Pro-only glyphs. Sizing is driven by the wrapping element, not fa-* size classes; use text-xs, text-sm, text-lg, text-2xl, text-4xl on the <i>.
7. State management patterns
Alpine stores are the state spine. No Redux, no Pinia, no context API.
7.1 Store declaration
document.addEventListener('alpine:init', () => {
Alpine.store('storename', {
// reactive state
items: [],
loading: false,
// methods
async init() { ... },
async load() { ... },
});
});
7.2 Scoped bus bridge
Every agent’s UI talks to its engine via createBus(agentID) — a thin wrapper over Wails events with scoping:
const bus = createBus('agent-id');
bus.emit('domain.request', { data });
bus.on('domain.result', (payload) => { /* handle */ });
bus.off('domain.result', handler);
await bus.call('req.request', 'req.response', payload, timeoutMs);
Outbound channel: {agentID}:nexus (engine → UI).
Inbound channel: {agentID}:nexus.input (UI → engine).
Envelope: { type: "event.name", payload: {...}, timestamp: "ISO" }.
7.3 Wails-bound shell services
The shell (pkg/desktop/shell.go) exposes Wails methods directly on window.go.desktop.Shell.* — these are the only places UI calls non-bus APIs:
| Method | Purpose |
|---|---|
ListAgents() | Enumerate agents with current status |
EnsureAgentRunning(agentID) | Lazy boot |
StopAgent(agentID) | Stop engine |
NewSession(agentID) / RecallSession(agentID, sessionID) / ListSessions(agentID) / DeleteSession(agentID, sessionID) | Session ops |
PickFile(agentID, title, filter) / PickFolder(agentID, title) | Native dialogs |
OpenExternal(url) / RevealInFinder(path) / Notify(title, body) | OS integration |
ListFiles(agentID, filter) / OutputDir(agentID) / CopyFileToInputDir(agentID, sourcePath) / WatchInputDir(agentID) | File portal |
GetSettingsSchema() / GetSettings() / UpdateSetting(scope, key, value) / UpdateSecret(scope, key, value) / DeleteSetting(scope, key, secret) / HasMissingRequired() | Settings |
Rule: shell services are only called from the UI layer; agents communicate with the shell via bus events, not by invoking these methods.
7.4 Settings schema → UI rendering
The shell returns a settings schema with fields like:
{
"shell": [{ "key": "shared_data_dir", "display": "Shared data folder", "type": "path" }],
"agents": {
"my-agent": [
{ "key": "input_dir", "display": "Input folder", "type": "path", "required": true },
{ "key": "api_key", "display": "API key", "type": "string", "secret": true, "required": true }
]
}
}
Types and their rendering:
type | Recipe |
|---|---|
string | input input-bordered input-sm (or secret variant if secret: true) |
path | input + browse button calling PickFolder/PickFile |
number | input type=number with optional min/max from validation |
bool | toggle toggle-primary toggle-sm |
select | select select-bordered select-sm with field.options |
text | textarea textarea-bordered textarea-sm min-h-[6rem] |
Secrets never render a current value — just ******** placeholder when present.
7.5 Session list
Sessions are tracked per agent. The shell emits {agentID}:sessions.updated via Wails events (not through the bus bridge) when the list changes. Session metadata includes:
id,title(fromsession.meta.titleevent),created_at,status,preview(arbitrary JSON fromsession.meta.previewevent)
Session-list rendering uses the standard nav/list-item pattern (§5.2) with a status dot, title, timestamp, and optional preview line.
7.6 UI state persistence
Two bus events drive cross-session UI state:
ui.state.save— UI emits with opaque{ state: {...} }payload. Shell writes toui-state.jsonin the engine session dir.ui.state.restore— Shell emits on session recall. UI rehydrates.
Designs that have non-trivial UI state (partially filled forms, scroll positions, expanded panels, active tab) should persist + rehydrate via these events. Keep the payload JSON-serializable and opaque to the shell.
8. Interaction and accessibility patterns
8.1 Keyboard
Cmd+Enter/Ctrl+Enter— submit in chat textareas (@keydown.enter.meta,@keydown.enter.ctrl)Cmd+S/Ctrl+S— save in spec/timeline editors (@keydown.meta.s.prevent,@keydown.ctrl.s.prevent)Enter— submit in single-line inputs (hello world)- No global shortcuts currently.
Show hints inline as text-xs opacity-30 or text-xs text-base-content/30 mt-1.
8.2 Hover reveal
Destructive or secondary in-row actions hide by default, appear on hover of the parent .group:
<div class="group ...">
<span>Content</span>
<button class="btn btn-ghost btn-xs btn-square opacity-0 group-hover:opacity-50 hover:!opacity-100 hover:text-error">
<i class="fa-solid fa-xmark text-xs"></i>
</button>
</div>
8.3 Transitions
Side panel enter/exit (left panel):
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 -translate-x-2"
x-transition:enter-end="opacity-100 translate-x-0"
For right-side panels, reverse the translate (translate-x-2). For vertical reveals (add-note input), use default x-transition for a simple fade/slide.
8.4 Titles and tooltips
Native title attribute is the standard tooltip. No tooltip library is used.
<button title="Refresh file list">...</button>
8.5 Required-field gating
Shell’s HasMissingRequired() returns a map of agentID → [missingKeys]. Pattern:
- Top-of-settings banner:
alert alert-warningsummarizing “Required settings missing”. - On the offending collapse:
:class="missing ? 'border-warning' : ''". - On the field:
input-warningclass + inline “Required” hint. - Agents with missing required settings cannot boot — nav-click routes to settings instead.
8.6 Dirty / unsaved
- Textarea-based editors track a
dirtyflag set on@input, cleared on@blur/ save. - Display inline
text-xs text-warningwith a<i class="fa-solid fa-circle text-[6px] mr-1"></i>dot. - Show a Save button (
btn btn-xs btn-primary) only when dirty.
8.7 Restart required
When a running agent has changed settings:
- Badge in section title:
badge badge-warning badge-xs ml-2“restart required” - Restart button at bottom of section:
btn btn-warning btn-smwithfa-rotate-righticon
9. Multi-agent vs single-agent — differences at a glance
| Concern | Multi-agent (cmd/desktop) | Single-agent (cmd/techlead) |
|---|---|---|
| Primary navigation | Left nav, lazy engine boot | Top phase stepper bar |
| Session list | Dedicated left panel, always visible when agent active | Toggleable panel, opt-in |
| File browser | Right toggleable panel (shell feature) | Integrated into phase 1 import UI |
| Settings | Dedicated view in main, triggered from nav | Modal-ish overlay on main, triggered from toolbar |
| Agent boot | Multiple engines, one per agent, lazy | Single engine, boots on init |
| Next action | User-driven per agent | “Next Phase” button in bottom action bar |
| Progression | Not ordered — user picks agent | Ordered — phase N unlocks when N-1 complete |
| Artifacts | Opaque — agents render their own sections | Shared artifact tree in left sidebar |
When extending multi-agent: check whether the feature is in-session (both kinds of shell) or wrapper (desktop shell only). See the CLAUDE.md parity rule.
When extending single-agent: new phases should follow the existing N-ary pattern — empty state → generating state → content → edit mode → unsaved indicator.
10. Opinionated “what NOT to do” list
- No new frameworks. Alpine + Tailwind + DaisyUI is the full toolbox. No React, Vue, Svelte, jQuery, Lodash, etc.
- No light theme unless explicitly requested — dark theme is the reference experience.
- No raw gray scales. Use
opacity-*ontext-base-content/border-base-300etc. - No modals or dialogs in the absence of a strong reason. Prefer inline reveals, collapsibles, side panels, or dedicated views.
- No routing. Views switch via
x-showdriven by a store property. - No animations beyond utility. DaisyUI
animate-pulse+ 150ms panel slides are the entire vocabulary. - No custom icon sets. Font Awesome 6 solid free only.
- No responsive / mobile layouts. Desktop-only, min ~900×720.
- No font imports. Stick to the system/Tailwind default font stack.
- No toasts/snackbars. Errors go in
alertor inline. Success is silent or a tiny inline “Copied!”. - No component abstractions until a pattern is used three times. Inline the classes.
11. Extensibility hooks
When Claude Design proposes something new, validate against these hooks so it slots into the real system:
- Is it a bus event or a Wails-bound shell method? If it changes agent state, design around an event round-trip (request → response). If it asks the OS or shell, call a
window.go.desktop.Shell.*method. - Does it need session-recall persistence? If yes, design the UI state payload and plan on
ui.state.save/ui.state.restore. - Does it touch files? Use the shell’s file portal (
input_dir,output_dir,ListFiles,PickFile). Don’t design a bespoke file picker. - Does it share with another agent? If yes, it probably belongs in
pkg/ui/shared code, not a single app. - Is it a multi-session concern? If yes, it only belongs in multi-agent / wrapper scopes — don’t back-port to single-agent shells that are session-scoped.
12. Reference quick-lookup
| Situation | Reach for |
|---|---|
| Add a list panel | §5.2 nav/list item + §3.2 spacing (256px for sessions, 224px for trees) |
| Add a new form field | §5.5 forms + §7.4 settings schema |
| Show async progress | §5.9 loading states |
| Signal something’s happening in background | animate-pulse on a status dot, loading-dots in chat |
| A destination that doesn’t exist yet | §5.8 empty states |
| Destructive action | §5.1 buttons + §8.2 hover reveal |
| Confirm a change | Don’t add a modal; use inline “Unsaved” + blur-to-save |
| Show a generated document | §5.13 markdown preview + §5.14 split editor |
| Show ranked results | §5.3 candidate card |
| Offer drag-and-drop | §5.12 drag-and-drop |
| Switch views/modes | §5.1 btn-group (three-way), or single primary/ghost toggle |
13. Glossary
| Term | Meaning in this system |
|---|---|
| Agent | A domain-specific AI workflow embedded in the desktop app. Has its own engine, UI section, and (optionally) settings schema. |
| Shell | The desktop wrapper (pkg/desktop/shell.go) that manages agents, sessions, settings, file portal. |
| Engine | The Nexus core that runs an agent’s plugins. Each agent has its own. |
| Session | One run of an agent. Can be recalled, deleted, listed per agent. |
| Bus | The event bus inside an engine. createBus(agentID) wraps Wails events into a scoped JS adapter. |
| Plugin | Composable behavior inside an engine. UI never calls plugins directly — only via bus events. |
| Input dir / output dir | Per-agent folders for file I/O, declared as settings and managed by the shell’s file portal. |
| Phase | One step in a single-agent workflow app. Sequential, gated by prior completion. |
| Artifact | A piece of output from a phase — document, epic, story, timeline, comms entry. |
| Scoped event channel | {agentID}:nexus and {agentID}:nexus.input — Wails events namespaced by agent for multi-agent isolation. |
wails-drag | CSS class applied to window chrome to make it OS-draggable. |
doc-preview | CSS class applied to rendered markdown containers. |
ICM Workflows — Overview
Nexus ships an agent loop for file-driven multi-stage workflows:
nexus.workflows.icm. The shorthand for what it does is short — a folder on
disk is your workflow — but the consequences are worth a section of its own.
This page makes the case for the feature and gives you the mental model
needed to read the walkthrough and the
plugin reference without getting lost.
What is ICM?
ICM is short for Interpretable Context Methodology (Van Clief & McDermott, arXiv:2603.16021). The methodology argues that production LLM workflows should be assembled out of small, human-reviewable contracts rather than handed off to one large, opinionated agent loop. Each step has a written brief, declared inputs, a single declared output, and validators that decide whether the step is done. The methodology is paper-shaped — the Nexus plugin gives it a runtime.
The plugin reduces the methodology to a single rule: the workflow is a folder on disk. Stages are subfolders. Contracts are markdown files with YAML frontmatter. Predicates are inline. Skills are subfolders. Artifacts land in the session directory at run time, never in the workspace.
~/work/screenplay-pipeline/
workspace.md # the brief — required, non-empty
stages/
01_outline/contract.md # YAML frontmatter + body
02_script/contract.md
03_review/contract.md
shared/skills/... # optional reusable bundles
scripts/... # optional command predicates
rubrics/... # optional LLM-judge rubrics
That folder is the entire workflow. The plugin loads + validates the
workspace at boot, derives one AgentPosture per stage, and dispatches each
stage as a sub-agent through a private delegate.Runtime whenever an
io.input arrives.
Why this deserves its own surface
A normal ReAct agent is excellent at exploratory work — give it a goal and some tools and let it churn. But three pressures push real workflows past what a single ReAct loop can hold:
- Repeatability. A research pipeline that runs every Monday morning needs the same shape every time. Free-form ReAct drift makes “the same shape” hard to guarantee.
- Reviewability. Stakeholders need to read what the agent will do before it runs, not infer it from a transcript afterward.
- Surgical iteration. When stage 3 is wrong, you want to fix stage 3 in isolation — not re-prompt a system message that drives all 7 stages.
The nexus.workflows.icm plugin solves all three by making the workflow a
literal folder you can read, diff, and ship. It also proves a deeper claim
about Nexus’s design:
A non-trivial multi-stage agent system can be expressed without writing Go. Posture registration, sub-agent dispatch, schema-validated outputs, HITL gates, loop convergence, and fan-out parallelism are already in the engine — you compose them with YAML and markdown.
This is the first plugin to combine all of them. The walkthrough exists because doing so end-to-end is a worked example for everything Nexus offers, not because the ICM plugin itself is unusually complex.
Mental model
Three vocabulary boundaries decide whether the docs read clearly.
Workspace, run, session
| Concept | Lives at | Lifespan |
|---|---|---|
| Workspace | <your workspace dir>/ (anywhere on disk) | Authored once; edits land in source control. |
| Run | <engine session>/plugins/<instance>/<runID>/ | One per io.input; ephemeral. |
| Session | ~/.nexus/sessions/<id>/ | One engine boot. |
The workspace is the source of truth for the workflow; the run directory is the source of truth for the artifacts. Edits to the workspace are authoring; edits to a run directory are tampering. Treating them as different objects is the single biggest reason the plugin’s surface stays simple.
Stage, iteration, turn, item
A stage is one folder with one contract.md. Inside a stage’s lifetime
three nested loops are possible:
- Turns — the inner conversation.
turns.policy: fixed | until_valid | until_human_approves. A turn is one LLM call within a single stage invocation. - Iterations — convergence loops.
loop.max_iterations: Nwithuntilpredicates. The entire stage runs as a fresh invocation each iteration, with the prior iteration’s exit failures included in the next payload. - Items — data-driven fan-out.
fan_out.source: ...points at a JSON array produced by an earlier stage; the stage runs once per item, up tomax_parallelconcurrently.
Turns sit inside iterations, iterations sit inside the stage, and fan-out is the stage being dispatched once per item. You almost never mix all three in a single stage — but the orchestrator supports it because fan-out + loop composes cleanly (each item independently iterates).
Posture, delegate, predicate
These are reused engine concepts; ICM does not reinvent any of them.
- Posture —
pkg/posture.AgentPosturefromnexus.agent.postures. ICM derives one per stage atReady(), layering operator prompt + body- overlay + tools + budget on top of a base posture, then registers it.
- Delegate —
pkg/delegate.Runtime. The same primitivedelegateplugin uses to dispatch sub-agents. ICM keeps its own privatedelegate.Runtimeso workflow stages don’t pollute caller tool budgets. - Predicate — the unified shape used by
output.validators,loop.until, and verifier outputs. Six types:schema,regex,native,command,llm,human. The loader compiles each at boot.
If you’ve configured postures + delegate + HITL + the schema registry before, ICM should feel like wiring those four together into a single user surface.
What ICM is not
It is not a replacement for nexus.agent.react or
nexus.agent.orchestrator. They solve different problems:
| Need | Use |
|---|---|
| Open-ended exploration with tools | nexus.agent.react |
| Decompose-then-parallelize one big request | nexus.agent.orchestrator |
| Plan first, then execute the plan | nexus.agent.planexec |
| Same N-step shape every time, reviewable in source control | nexus.workflows.icm |
| Embed a workflow inside a desktop app | ICM as the agent loop, your shell as the chrome |
It is not a new abstraction over LLM providers. Every model call routes
through delegate (sub-agents) or through the configured judge posture
(LLM predicates). It does not mutate the workspace — artifacts live in the
session. It does not auto-discover skills from nexus.skills.scan_paths —
skills live under the workspace and are loaded only when a stage’s
inputs.skills references them.
What you get out of the box
The plugin is fully implemented today. The walkthrough exercises everything in this list:
- Workspace + contract loader with aggregated validation errors (boot once to see every problem, fix in one pass).
- One derived
AgentPostureper stage, registered before the engine finishes boot. Tools, model role, budget, and operator prompt are baked in. - Stage-level
plan.created+plan.progresssurface so generic UIs render the workflow without ICM-specific knowledge, plus a richericm.*event family for iteration / turn / item detail. - Six predicate types with four shipped native handlers
(
word_count_under,word_count_over,contains_required_ids,json_path_exists). - Loop convergence with
on_exhausted: human_gate | errorand a configurable restart ceiling. - Fan-out with optional parallelism, per-item folders, and an aggregate artifact downstream stages can reference.
- Per-workspace skill resolution (
stages/<NN>/skills/<name>wins overshared/skills/<name>) plus an LLM-facingread_skill_referencetool that surfaces only when a stage actually uses skills. - Multi-instance support via
nexus.workflows.icm/<suffix>so two workspaces can coexist in one engine.
Authoring tools
You do not need to write contracts by hand. The repo ships a Claude Code skill that interviews you about the workflow you want, validates each answer against the loader’s rules, and writes a workspace that loads on the first try:
.claude/skills/icm-workspace-builder/SKILL.md— invoke with/icm-workspace-builderonce the skills plugin is configured to scan.claude/skills/. The skill enforces the same rules the loader does (folder regex, reserved00_input, predicate args, fan-out sources).
The walkthrough is hand-authored so you can read every file; the skill is the on-ramp for everything else.
Where to go next
- End-to-end walkthrough — build a real workspace from an empty folder, run it, watch the events, inspect the artifacts.
- Plugin reference — every field on every block, every event, every troubleshooting case.
- Configuration reference — every plugin config key with defaults.
- Postures — the base postures stages inherit from.
- Sub-agent delegation — the dispatch primitive ICM stands on.
- HITL — where every human gate is routed.
ICM Workflows — End-to-End Walkthrough
This page builds a working ICM workspace from an empty folder, runs it through Nexus, and shows you exactly what to expect at each step: the events that fire, the files that land on disk, the human gates that pause the run, and the failure modes that bite first-time authors.
The example is a two-stage research brief pipeline. Stage 1 produces an outline from a topic; stage 2 expands the outline into a brief, looping until the brief passes a word-count rubric. It is deliberately small enough to fit on one screen and deliberately rich enough to exercise loops, predicates, human gates, and the full event surface.
By the end of this walkthrough you will have:
- A working workspace under
~/work/research-brief/. - A Nexus config that wires posture + delegate + HITL + ICM.
- A
bin/nexussession that runs the workflow end-to-end against a real LLM provider. - Familiarity with the exact
icm.*events that drive any UI you build on top.
If you only want to read the surface area, the overview is the conceptual map and the plugin reference is the field-by-field manual. This page sits between them.
Prerequisites
You need:
- A built
bin/nexus. From the repo root:make build. - An LLM provider API key in env or a
.envfile. The walkthrough usesANTHROPIC_API_KEY; switch toOPENAI_API_KEYorGEMINI_API_KEYif you prefer — the workspace itself is provider-agnostic because every stage names amodel_role, not a raw model. - A place to put the workspace. The walkthrough uses
~/work/research-brief/; adjust paths as needed.
You do not need an existing posture library. Step 1 below ships a minimal two-posture file you can copy into place.
Step 1 — Provision postures
ICM derives one posture per stage at boot, but each derived posture inherits
from a base posture registered with nexus.agent.postures. Wire a
minimal two-posture library so the workflow has somewhere to stand:
mkdir -p ~/.nexus/postures
~/.nexus/postures/writer_base.yaml:
name: writer_base
description: Long-form writing posture for ICM stages.
system_prompt: |
You are a writer. Follow the stage contract exactly. Produce one
artifact in the format the contract specifies. Do not add
commentary outside the artifact.
allowed_tools: []
model:
model_role: writer
default_budget:
timeout: 180s
max_tokens: 8000
~/.nexus/postures/judge_basic.yaml:
name: judge_basic
description: JSON-verdict judge for ICM `type: llm` predicates.
system_prompt: |
You judge an artifact against a rubric and return a JSON verdict.
Output only JSON conforming to the icm.judge.response schema.
allowed_tools: []
model:
model_role: judge
default_budget:
timeout: 60s
max_tokens: 2000
The names matter — the workspace references them by name. The model_role
fields route to whatever your core.models config maps writer and
judge to. A single-provider setup with one model is fine; map both roles
to the same model entry.
Step 2 — Author the workspace
Create the workspace folder. Every path inside is required unless flagged optional.
mkdir -p ~/work/research-brief/stages/01_outline
mkdir -p ~/work/research-brief/stages/02_brief
mkdir -p ~/work/research-brief/rubrics
Workspace brief
~/work/research-brief/workspace.md:
# Research Brief Pipeline
A two-stage research pipeline. Stage 1 reads a topic and produces a
five-point outline. Stage 2 expands the outline into a brief between
600 and 1200 words. The brief loops until a writer-judge agrees it
covers every outline point.
Required, non-empty. Shows up at the top of every operator prompt so the stage agent knows where it sits in the larger workflow.
Workspace defaults
~/work/research-brief/icm.yaml is optional but useful for shared
defaults:
defaults:
agent:
posture: writer_base
model_role: writer
judge_posture: judge_basic
Anything a stage doesn’t override falls back here, then to plugin defaults.
Stage 1 — outline
~/work/research-brief/stages/01_outline/contract.md:
---
display: Outline the brief in five bullets
turns:
policy: fixed
max: 1
human_gate: end
output:
format: text
filename: outline.md
validators:
- type: native
handler: contains_required_ids
args:
ids: ["1.", "2.", "3.", "4.", "5."]
inputs:
artifacts:
- 00_input/topic.txt
---
Read the topic in `<artifact path="00_input/topic.txt"/>`. Produce a
markdown outline with exactly five numbered points. Each point is one
sentence. No prose around the list.
What this does:
turns.policy: fixed, max: 1— one LLM call, no retry.human_gate: end— pause after the artifact is written so the human can approve before stage 2 starts.contains_required_idsvalidator — refuses any output that drops one of the five numbered points. Withoutuntil_validsemantics this just surfaces asicm.predicate.failed; we keep it for visibility.inputs.artifacts: 00_input/topic.txt— the reserved00_inputnamespace is whereio.inputcontent lands.
Stage 2 — brief
~/work/research-brief/stages/02_brief/contract.md:
---
display: Expand the outline into a 600-1200 word brief
turns:
policy: until_valid
max: 3
human_gate: end
output:
format: text
filename: brief.md
validators:
- type: native
handler: word_count_over
args:
min_words: 600
- type: native
handler: word_count_under
args:
max_words: 1200
loop:
max_iterations: 3
until:
- type: llm
rubric: rubrics/coverage.md
on_exhausted: human_gate
inputs:
artifacts:
- 01_outline/outline.md
agent:
budget:
max_tokens: 4000
---
Expand the outline in `<artifact path="01_outline/outline.md"/>` into a
research brief between 600 and 1200 words. Cover every outline point.
Use a brief introduction, one paragraph per point, and a one-sentence
conclusion. Markdown allowed.
This stage exercises three loops at once:
- Turns —
until_validretries up to 3 times within an iteration if the word-count validators fail. The retry payload includes the prior attempt’s failure feedback. - Iterations —
loop.max_iterations: 3reruns the entire stage if thecoverage.mdLLM rubric is not satisfied. Each iteration writes its artifact to02_brief/iter_NN/brief.md. - Human gate —
human_gate: endpauses after the loop exits so the human can approve, restart, or fail.
Rubric
~/work/research-brief/rubrics/coverage.md:
The brief passes coverage when every numbered point in the outline is
addressed in its own paragraph. A point that is mentioned but not
expanded is a failure. Return JSON only.
The judge posture (judge_basic) sees this rubric plus the candidate
artifact and must return JSON conforming to the baked-in
icm.judge.response schema (verdict + score + feedback).
Step 3 — Wire the Nexus config
~/work/research-brief.yaml:
core:
models:
default: writer
writer:
provider: nexus.llm.anthropic
model: claude-sonnet-4-20250514
max_tokens: 8192
judge:
provider: nexus.llm.anthropic
model: claude-sonnet-4-20250514
max_tokens: 2048
plugins:
active:
- nexus.io.tui
- nexus.llm.anthropic
- nexus.agent.postures
- nexus.agent.delegate
- nexus.control.hitl
- nexus.memory.capped
- nexus.workflows.icm
nexus.agent.postures:
scan_dirs:
- ~/.nexus/postures
nexus.workflows.icm:
workspace: ~/work/research-brief
default_judge_posture: judge_basic
default_workflow_posture: writer_base
Note what’s not here: no nexus.agent.react, no
nexus.agent.orchestrator, no separate planner. ICM is itself the agent
loop — when io.input arrives it owns the conversation until the run
completes.
Two config details worth double-checking before you run:
- The
provider:value in eachcore.modelsentry takes the full plugin ID (nexus.llm.anthropic), not a short name likeanthropic. Wrong provider IDs surface at runtime as “delegate status error: no LLM response (provider not active for this role?)”. - Every role a posture references must be defined. The
writer_baseposture above declaresmodel_role: writer, andjudge_basicdeclaresmodel_role: judge, socore.modelsmust define both.
Step 4 — Run it
Start the TUI:
bin/nexus -config ~/work/research-brief.yaml
At the prompt, type a topic — the economics of mechanical keyboards,
say — and hit enter.
You will see this sequence of events. The TUI renders the plan progress inline; richer UIs subscribe to the typed events documented in the plugin reference.
io.input { content: "the economics of mechanical keyboards" }
plan.created { steps: 2 (01_outline, 02_brief) }
icm.run.started { run_id: r_<id>, stages: 2 }
plan.progress { step: 01_outline, status: active }
icm.stage.started { stage_id: 01_outline, order: 1 }
llm.request → llm.response (stage 1 turn 1)
icm.turn { stage_id: 01_outline, turn: 1 }
hitl.requested { action: icm.stage.end, stage_id: 01_outline }
The run pauses at hitl.requested. The TUI surfaces a prompt with three
choices: continue, restart this stage, abort the run. Press
continue.
hitl.responded { action: icm.stage.end, choice: continue }
plan.progress { step: 01_outline, status: completed }
icm.stage.completed { stage_id: 01_outline, artifact_path: ... }
plan.progress { step: 02_brief, status: active }
icm.stage.started { stage_id: 02_brief, order: 2 }
icm.stage.iteration { iteration: 1, max: 3 }
llm.request → llm.response (stage 2 iter 1 turn 1)
If stage 2’s first attempt is under 600 words, the until_valid retry
fires inside the same iteration. If three turns can’t satisfy the word
count, the iteration writes its best attempt to 02_brief/iter_01/brief.md
and the LLM judge runs against the coverage.md rubric. If the judge says
no, iteration 2 starts.
What if the loop exhausts?
Likely on the first run. The coverage.md rubric in this walkthrough
is deliberately strict — “every numbered point in the outline is
addressed in its own paragraph” — and small judge models often read
that too literally and reject an otherwise fine brief. When the loop
hits loop.max_iterations: 3 without converging, on_exhausted: human_gate fires a HITL prompt:
Loop on stage Expand the outline... did not converge after 3 iterations.
1. Accept handoff — take the last iteration's brief as the artifact
2. Restart loop — wipe iter_*/, run the whole loop again
3. Reject — fail the stage and halt the run
Pick Accept handoff if the actual brief reads well. The judge being picky shouldn’t gate you. Before deciding, inspect the per-iteration artifacts and the judge’s verdicts on disk:
ls ~/.nexus/sessions/*/plugins/nexus.workflows.icm/r_*/02_brief/iter_*/
cat ~/.nexus/sessions/*/plugins/nexus.workflows.icm/r_*/02_brief/iter_03/brief.md.icm.json
The .icm.json sidecar carries each iteration’s verdict + feedback
so you can tell whether the brief is genuinely weak or the rubric is
unreasonable. If you keep hitting this every run, three knobs (in
ascending effort):
- Loosen the rubric in
rubrics/coverage.md— “each outline point is addressed somewhere in the body; interleaved coverage is fine.” - Use a stronger judge — point the
judgemodel_roleat a more capable model. Judge calls are single-shot, so cost impact is small. - Drop the LLM rubric from
loop.untiland keep only the word-count native validators. Move the LLM check to averifiers/entry instead — failures get logged but don’t loop.
When the loop exits — convergent or exhausted-then-accepted —
human_gate: end fires again. Approve, and:
icm.stage.completed { stage_id: 02_brief, iterations_run: 2, convergence_failed: false }
icm.run.completed { stages_run: 2, elapsed_seconds: 47 }
Step 5 — Inspect the run
Every artifact, every iteration, and every per-artifact sidecar lives in
the engine session under
<session>/plugins/nexus.workflows.icm/<runID>/:
~/.nexus/sessions/<sid>/plugins/nexus.workflows.icm/r_<runID>/
.icm/
run.json # workspace path, started at, plan
state.json # per-stage progress
00_input/
topic.txt # what you typed
01_outline/
outline.md
outline.md.icm.json # sidecar: writer + validator results
02_brief/
iter_01/
brief.md
brief.md.icm.json
iter_02/
brief.md
brief.md.icm.json
brief.md # aggregate (last iteration after convergence)
brief.md.icm.json
The sidecar files (.icm.json) record which posture wrote the artifact
and which validators passed or failed. They are how downstream tooling
can answer “why did stage 2 take two iterations?” without re-reading the
artifact.
You can re-run the workflow on a different topic by sending another
io.input. ICM creates a fresh r_<runID> directory; prior runs are
untouched.
Step 6 — Watch a failure
Edit ~/work/research-brief/stages/01_outline/contract.md and change
filename: outline.md to filename: outline/v1.md. Restart Nexus.
workspace load failed (1 errors):
stages/01_outline/contract.md: output.filename must not contain path
separators (got "outline/v1.md")
ICM aggregates every validation error into one message at boot so you can fix all of them in a single pass instead of restart-fix-restart cycles. Revert the change and ICM boots cleanly.
Other failure modes worth provoking once for muscle memory:
- Duplicate stage prefix (
01_outline+01_summary) — boot fails with “duplicate stage prefix 01”. - A
type: llmpredicate with nodefault_judge_postureconfigured — runtime fails the predicate with “default_judge_posture is not configured”. - An
inputs.artifactsref to a later stage (02_brief/...inside01_outline/contract.md) — boot fails at load.
Each failure surfaces with the workspace-relative path + line number.
Step 7 — Iterate the workspace
The workflow you just built is the floor. Everything else ICM offers is incremental:
- Add a fan-out stage. Replace the brief stage’s monolithic output with
one paragraph per outline point. Produce a
topics.jsonfrom stage 1 andfan_out.source: 01_outline/topics.jsonfrom stage 2. - Add a
type: commandpredicate. Drop ascripts/lint_brief.shinto the workspace, mark it executable, and reference it from stage 2’s validators. The script reads the artifact on stdin and exits 0/non-0. - Add skills. Put a
shared/skills/house_voice/SKILL.mdunder the workspace and reference it fromstages/02_brief/contract.mdviainputs.skills: [house_voice]. The skill body inlines into the grounding; references load on demand via theread_skill_referencetool. - Add verifiers. Move the rubric check out of stage 2’s loop and into
a reusable
verifiers/coverage.md. Stages that need coverage reference it via top-levelverifiers: [coverage].
Each is one or two contract edits. The walkthrough deliberately stops short of all of them so the on-ramp stays short; the plugin reference covers each in detail.
Using the workspace builder skill
If hand-authoring contracts feels tedious, the
.claude/skills/icm-workspace-builder/SKILL.md skill interviews you for
the workflow shape and writes the workspace for you. Invoke it via the
configured Claude Code skill surface — the skill validates each answer
against the same loader rules above and produces a workspace that boots
cleanly on the first try.
What you proved
- A non-trivial agent workflow — two stages, loop convergence, an LLM
judge, native validators, a human gate, and a
00_inputinitial artifact — fit in five files under 100 lines. - The same workflow runs in any IO surface Nexus supports (TUI, browser, oneshot, wails) without a code change. Adding a new IO means activating a new plugin, not modifying the workspace.
- The
icm.*event surface plus the engine-genericplan.*events give any UI everything it needs to render workflow progress, including fan-out items and loop iterations. - The workspace is source-control-friendly — every change to the workflow is a markdown / YAML diff you can review on a PR.
Where to go next
- Overview — the rationale, the mental model, the full list of orthogonal features.
- Plugin reference — every contract field, predicate type, event payload, and troubleshooting case.
- Configuration reference — every plugin config key with defaults.
- Postures — the base postures stages inherit from.
- HITL — how human gates route.
nexus.workflows.icm
File-driven multi-stage workflow runner. A workspace is a folder on disk: each
stage is a subfolder with a YAML-fronted contract, optional grounding,
optional skills, and a declared output. The plugin loads + validates the
workspace at boot, registers one AgentPosture per stage, and dispatches each
stage as a sub-agent via a private delegate.Runtime whenever an io.input
arrives.
Source:
plugins/workflows/icm/. New to ICM? Start with the overview for the rationale + mental model, then follow the end-to-end walkthrough to build a workspace from scratch. This page is the field-by-field reference. Configuration table: configuration reference.
Overview
ICM (Interpretable Context Methodology, Van Clief & McDermott, arXiv:2603.16021) treats an LLM workflow as a sequence of small, human-reviewable contracts rather than one big agent loop. The Nexus implementation reduces a workspace to disk structure: the loader parses contracts, compiles regex + gojq + JSON-schema at load time, and the orchestrator dispatches stages strictly in folder-numeric order.
What the plugin does:
- Resolves
posture.registry(provided bynexus.agent.postures) at boot. - Loads + validates the workspace; aggregates errors so the user can fix in one pass.
- Derives one
AgentPostureper stage and per verifier; tools, model role, budget, and operator prompt are baked in at registration. - Subscribes to
io.input. Each input begins a fresh run with its own<runID>directory under the plugin’s data dir. - Dispatches stages as sub-agents through a private
delegate.Runtime. Stages never talk to one another — they read declared inputs and write declared outputs through the session helper. - Emits a stage-level
plan.created+plan.progresssurface plus a richericm.*event family for iteration / turn / fan-out detail. - Routes every human checkpoint through
hitl.requested; cancellation is surfaced viahitl.cancel.
What the plugin does NOT do:
- It does not directly call an LLM provider. Every model call routes through
delegate(sub-agents) or through the configured judge posture (LLM predicates). - It does not mutate the workspace. Artifacts live in the session directory.
- It does not auto-discover skills. Skills are loaded from the stage-local
skills/andshared/skills/folders inside the workspace.
Quick start
- Add the plugin to
plugins.activeand point it at a workspace.
plugins:
active:
- nexus.agent.postures
- nexus.control.hitl
- nexus.workflows.icm
nexus.workflows.icm:
workspace: ~/work/screenplay-pipeline
default_judge_posture: judge_strict
default_workflow_posture: workflow_base
- Scaffold the workspace. Below is a working three-stage screenplay pipeline. Every file shown is necessary; everything else is optional.
~/work/screenplay-pipeline/
workspace.md
stages/
01_outline/
contract.md
02_script/
contract.md
03_review/
contract.md
workspace.md — required, non-empty. Describes the workflow at the highest
level. Surfaces to the operator prompt and to anyone reading the folder.
# Screenplay Pipeline
A three-stage pipeline that converts a one-line premise into a short-form
screenplay: outline, draft, peer review. Each stage hands a single artifact
to the next; humans gate the start and end of every stage.
stages/01_outline/contract.md — YAML frontmatter then process body. The
body is rendered into the operator system prompt for this stage.
---
display: Outline a three-act structure
turns:
policy: fixed
max: 1
human_gate: end
output:
format: text
filename: outline.md
inputs:
artifacts:
- 00_input/premise.txt
agent:
model_role: writer
budget:
max_tokens: 4000
---
Read the premise in `<artifact path="00_input/premise.txt"/>`. Write a
three-act outline with beat counts per act. No prose. Markdown headings
allowed.
stages/02_script/contract.md — consumes the outline; loops up to three
times until the validator passes.
---
display: Draft the screenplay
turns:
policy: until_valid
max: 3
human_gate: end
output:
format: text
filename: script.md
validators:
- type: native
handler: word_count_over
args: { min_words: 800 }
- type: native
handler: word_count_under
args: { max_words: 2400 }
inputs:
artifacts:
- 01_outline/outline.md
agent:
model_role: writer
budget:
max_tokens: 8000
---
Expand the outline in `<artifact path="01_outline/outline.md"/>` into a
screenplay between 800 and 2400 words. Use industry-standard formatting
(INT./EXT., character names in caps before dialogue).
stages/03_review/contract.md — judges the script with an LLM rubric.
---
display: Peer review the screenplay
turns:
policy: fixed
max: 1
human_gate: none
output:
format: text
filename: review.md
validators:
- type: llm
rubric: rubrics/review_quality.md
model: judge_strict
inputs:
artifacts:
- 02_script/script.md
agent:
model_role: reviewer
---
Read the script in `<artifact path="02_script/script.md"/>`. Produce a
review covering structure, character voice, dialogue, and pacing. Be
specific; cite scene numbers.
- Start Nexus. Send the initial premise via
io.input(the engine writes it to<runID>/00_input/premise.txt). ICM creates the run, dispatches stage 01, gates at its end via HITL, then continues.
Workspace layout
The workspace is the source of truth for the workflow; artifacts are the source of truth for the run. They live in different directories.
<workspace>/
icm.yaml optional — layer name overrides + defaults
operator.md optional — Layer 0 operator prompt template
operator.overlay.md optional — appended to operator body
workspace.md required — high-level workflow description
stages/
01_<slug>/
contract.md required per stage
grounding/ optional — reference files inline at dispatch
skills/<name>/ optional — stage-local skills
02_<slug>/...
shared/
grounding/ optional — cross-stage reference files
skills/<name>/ optional — workspace-wide skills
schemas/ optional — JSON schemas referenced from contracts
scripts/ optional — command-predicate executables
rubrics/ optional — LLM-predicate rubric files
verifiers/ optional — verifier stage definitions
inputs/ optional — initial-run input fixtures
Folder-name rules enforced by the loader:
- Stage folders match
^\d+_[a-z0-9_]+$. Execution order is the numeric prefix, sorted numerically (so9_xruns before10_x). 00_inputis reserved — it names the synthetic input stage in artifact refs. Declaring a stage folder named00_inputis a load error.- Duplicate numeric prefixes (
05_foo+05_bar) are rejected: execution order would be ambiguous. - A stage folder containing an
artifacts/subdirectory is rejected. Artifacts live in the session, not the workspace.
icm.yaml
icm.yaml is optional. When present it overrides any subset of the layer
filenames and supplies workspace-level defaults that stages inherit unless
they override.
# All sections are optional.
layer_names:
operator: operator.md # default
workspace: workspace.md # default
contract: contract.md # default
grounding: grounding # default (folder name)
defaults:
turn_policy: fixed # fixed | until_valid | until_human_approves
human_gate: none # none | start | end | both
on_error: halt # halt | retry | human_gate
judge_posture: judge_basic # used for type: llm predicates with no `model:`
agent:
posture: workflow_base # base posture each stage inherits
model_role: writer
tools: [read_file]
budget:
timeout_seconds: 120
max_tokens: 8000
max_tool_calls: 16
max_recursion_depth: 3
operator:
overlay: |
Always cite section numbers when referencing scripts.
The judge posture and base workflow posture can also be configured at the
plugin level (default_judge_posture, default_workflow_posture). The
plugin-level keys win when both are set.
Stage contracts
Each stage is a single file: contract.md. It is YAML frontmatter (between
--- lines) followed by a markdown body. The body is the stage’s role
instructions — it is rendered into the operator system prompt at dispatch
time. Verifiers may be a single .md file directly under verifiers/
with the same shape.
Frontmatter
| Field | Type | Default | Notes |
|---|---|---|---|
id | string | folder name | When set, must match folder name. |
display | string | first body line or ID | Truncated to 80 chars. |
turns | object | see Turns | Inner-loop policy. |
human_gate | string | none | none / start / end / both. |
on_error | string | halt | Non-validator failure policy. |
loop | object | (none) | Convergence-driven iteration. |
fan_out | object | (none) | Data-driven iteration. |
output | object | (required) | Artifact + validators. |
inputs | object | (none) | Files the stage reads. |
agent | object | inherits | Posture + tools + budget. |
verifiers | list | (none) | Cross-stage verifier IDs. |
Output spec
output declares the file the stage writes and any validators that run
against it. Validators that fail under turns.policy: until_valid trigger
a retry; under any other policy they surface as
icm.predicate.failed and (depending on on_error) may halt the run.
output:
format: text # text (default) | json
persist: file_ref # file_ref (default) | context | both
filename: outline.md # required; no path separators
schema: schemas/outline.json # required when format: json
validators:
- type: regex
pattern: '^# '
anchor: first_line
message: "Outline must begin with an H1."
persist: file_ref writes the artifact and references it by logical
ref downstream. context keeps the body in conversation context only
(use sparingly — large bodies bloat downstream turns). both does both.
JSON outputs always parse + validate against schema before the validators
run.
Inputs
inputs declares what files the stage reads. The loader verifies each path
exists at boot.
inputs:
grounding:
- style_guide.md # under <stage>/grounding/
- examples/sample_act_1.md
shared_grounding:
- house_voice.md # under shared/grounding/
artifacts:
- 00_input/premise.txt # initial input from io.input
- 01_outline/outline.md # prior stage's declared output
skills:
- markdown-screenplay # resolved through skill precedence
Artifact refs are validated cross-stage at load: the referenced stage must
run before this one, and the filename must match that stage’s
output.filename. The reserved 00_input/... prefix points at files
copied into the session by io.input (or by workspace_inputs_dir).
Inline-vs-ref selection happens at dispatch: artifacts under
inline_artifact_limit_bytes (default 32 KiB) inline as <artifact>;
larger or binary content emits <artifact_ref/> and the LLM picks it up
via read_file.
Agent block (posture, model_role, tools, budget)
Stage-level agent fields override workspace defaults; absent fields fall through to defaults, then to the registry defaults at runtime.
agent:
posture: writer_posture # optional; existence checked at runtime
model_role: writer # role into the engine model registry
tools: # AllowedTools for this stage's posture
- read_file
- run_code
prompt_overlay: | # appended to operator body for this stage
Be concise. Bullet lists over prose.
budget:
timeout_seconds: 120
max_tokens: 8000
max_tool_calls: 16
max_recursion_depth: 3 # caps sub-agent nesting
posture is a base posture from nexus.agent.postures. ICM derives a
per-stage posture on top of it, layering operator prompt + role body +
overlay + tools + budget + skill-tool registration. The derived posture
name appears as icm.<runID>.<stage_id> in icm.stage.started events.
auto_include_skill_reference_tool: true (the default) appends the
read_skill_reference tool to any stage that declares inputs.skills.
For multi-instance setups the tool name becomes read_skill_reference_<suffix>
so two ICM instances can coexist in one engine without colliding.
Turns
Turns control the inner loop within a single stage invocation.
turns:
policy: until_valid # fixed | until_valid | until_human_approves
max: 3 # default 1 for fixed, 3 for until_valid
fixedrunsmaxturns unconditionally. Most common.until_validretries while any validator fails. Requires at least oneoutput.validatorsentry — the loader rejects the contract otherwise.until_human_approvesloops while the human selectscontinueat the per-turn HITL gate (icm.stage.turn). Free-text feedback from the human arrives in the next turn’s<previous_attempt><human_feedback>.
Human gates
Human gates fire at stage boundaries. They are independent from per-turn loop gates and per-iteration loop predicates.
human_gate: end # none (default) | start | end | both
start emits a HITL icm.stage.start action before the stage’s first
dispatch; end emits icm.stage.end after the artifact is written. The
end gate offers a restart choice that wipes the stage directory and
re-runs (subject to loop_max_restarts).
For looping stages the gate fires at the bounds of the entire stage, not
per iteration. Use a type: human predicate inside loop.until if you
need per-iteration human review.
Loops
Loops drive convergence: the entire stage runs as a fresh invocation each iteration, with prior-iteration artifact + exit failures included in the next payload.
loop:
max_iterations: 5
until:
- type: native
handler: word_count_over
args: { min_words: 1200 }
- type: llm
rubric: rubrics/coherence.md
on_exhausted: human_gate # human_gate (default) | error
until predicates run after each iteration’s artifact is written. All
must pass for the loop to exit. When max_iterations runs out without
convergence, on_exhausted: human_gate raises an icm.loop.exhausted
HITL request offering accept / restart / error; on_exhausted: error
halts the stage immediately. Restart-loop count is bounded globally by the
plugin’s loop_max_restarts config.
Iteration artifacts persist under <stageDir>/iter_NN/. The aggregate
<stageDir>/<filename> is written from the last iteration after
convergence (or the last attempt if a human accepts).
Fan-out
Fan-out runs the stage once per item in a JSON list. Distinct from loop:
loops are convergence-driven; fan-outs are data-driven. They compose — a
stage with both fans out per item and each item independently iterates.
fan_out:
source: 02_research/topics.json # earlier stage's JSON output
jsonpath: .topics # optional gojq expression; default "."
item_var: topic # name surfaced into payload's <fan_out_item>
item_id: .slug # optional gojq for per-item folder name
max_parallel: 4 # default 1
on_item_failure: continue # continue (default) | halt
source must resolve to a JSON artifact produced by an earlier stage (or
to 00_input/...). The orchestrator parses it, applies jsonpath, and
expects an array; non-arrays surface as a stage error. Each item writes
under <stageDir>/items/<itemID>/<filename>, and the orchestrator emits
an icm.fanout.item event at each item lifecycle boundary
(active → completed | failed).
The aggregate output filename is also written at the plain stage path so
downstream stages can reference it via the normal <stage_id>/<filename>
ref. Aggregation is a flat join of every item’s artifact for text outputs
and a JSON array for JSON outputs.
Verifiers
Verifiers are reusable stage-shaped contracts kept under verifiers/. A
stage references them by ID via the top-level verifiers: list. The
loader validates that every referenced ID exists. They run after the
stage’s own validators and may declare their own posture, tools, and
predicates.
# stages/03_review/contract.md
---
verifiers:
- house_voice_check
- structural_balance
---
# verifiers/house_voice_check.md
---
display: House voice check
output:
format: text
filename: house_voice.md
validators:
- type: llm
rubric: rubrics/house_voice.md
inputs:
artifacts:
- 02_script/script.md
---
Compare the script against the house style guide and flag violations.
Predicates
A predicate is the unified shape used by output.validators, loop.until,
and verifier outputs. The type field discriminates; the loader compiles
regex / gojq / JSON-schema at load time and rejects malformed predicates
before boot completes.
schema
JSON-schema validation against the stage output.
- type: schema
schema: schemas/outline.json
name: outline_shape # optional; default "<type>_<index>"
Path resolves against the workspace root. The loader reads the file, parses it as JSON, and compiles it as draft-2020 to catch malformed schemas at boot.
regex
Regex match against text output.
- type: regex
pattern: '^FADE IN:'
anchor: first_line # whole (default) | first_line | last_line
message: "Screenplay must open with FADE IN:."
The loader compiles the pattern at load time; failures surface in the load
error aggregate. message populates the predicate’s Feedback field, so
LLMs retrying under until_valid see your authored guidance.
native (builtin handlers)
Native predicates dispatch to a Go handler registered in the plugin. ICM ships four built-ins.
| Handler | Required args | Optional args | Behavior |
|---|---|---|---|
word_count_under | max_words: int > 0 | — | Passes when len(strings.Fields(artifact)) < max_words. |
word_count_over | min_words: int >= 0 | — | Passes when len(strings.Fields(artifact)) > min_words. |
contains_required_ids | ids: []string (non-empty) | case_insensitive: bool (default false) | Passes when every id appears at least once in the artifact. Empty ids is treated as malformed args, not vacuous truth. |
json_path_exists | path: string (gojq query) | must_be_non_empty: bool (default true) | Parses artifact as JSON and runs the query. Passes when at least one result is returned and (when must_be_non_empty) at least one is non-null / non-empty. |
- type: native
handler: contains_required_ids
args:
ids: [PROTAGONIST, ANTAGONIST]
case_insensitive: false
command
Shell-out predicate. The script reads the artifact on stdin and exits 0 to pass, non-zero to fail. Stderr becomes the feedback.
- type: command
run: scripts/lint_screenplay.sh
timeout_seconds: 30 # optional; falls back to plugin default
The loader resolves run against the workspace root, verifies the file
exists, and verifies it has the executable bit set. Scripts run inside the
engine sandbox (the same engine.Sandbox injected into the plugin), so
the workflow’s command surface is constrained by the host’s sandbox
policy.
llm
LLM judge predicate. The judge sees the artifact and a rubric, and returns a structured verdict.
- type: llm
rubric: rubrics/coherence.md
model: judge_strict # optional posture override
name: coherence_check # optional
model names a registered posture (not a raw model). When omitted, the
plugin uses default_judge_posture from its config. Workspaces that use
type: llm predicates without setting either are rejected at runtime
with default_judge_posture is not configured.
The judge posture must return JSON conforming to a baked-in judge schema (verdict + score + feedback). The plugin registers this schema at boot.
human
In-loop human predicate. The orchestrator emits a HITL request and waits. This is the right tool when “looks good” is the gating criterion for convergence.
- type: human
prompt: "Does this iteration meet the brief?"
require_feedback_on_continue: true # optional
continue (without selecting the explicit pass / fail choice) under
turns.policy: until_human_approves advances to the next turn and routes
the human’s free-text response into <previous_attempt><human_feedback>.
Skills
Skills are bundles a stage can load at dispatch. Each skill is a folder
containing SKILL.md (YAML frontmatter name + description, then a
body) and an optional references/ subfolder with deferred-load files.
Discovery is workspace-scoped — ICM does not use the global
nexus.skills scan_paths. The loader walks two locations in precedence
order:
stages/<NN_slug>/skills/<name>/— stage-local (wins on conflict).shared/skills/<name>/— workspace-wide.
Reference shape:
shared/skills/markdown-screenplay/
SKILL.md
references/
fade_transitions.md
standard_formatting.md
SKILL.md:
---
name: markdown-screenplay
description: How to format screenplays in markdown for downstream parsing.
---
Always use INT./EXT. headers. Capitalize character names before dialogue.
See references/standard_formatting.md for the full house spec.
Stages opt in via inputs.skills. At dispatch the orchestrator inlines
SKILL.md body into <grounding> and registers the
read_skill_reference[_<suffix>] tool so the agent can pull a specific
reference on demand. References are NOT inlined by default — that is the
point of progressive disclosure.
XML payload reference
Each turn assembles a single XML user message. The shape is uniform across
stage modes; loop iterations and fan-out items add their own elements
but the surrounding skeleton is invariant. Inline blocks contain the
content; _ref variants point at filesystem paths.
<icm_turn stage="02_script" turn="1" iteration="3" run_id="r_abc">
<grounding>
<skill name="markdown-screenplay" source="workspace">
<description><![CDATA[How to format screenplays...]]></description>
<body><![CDATA[Always use INT./EXT. headers...]]></body>
<references_available>
<ref path="standard_formatting.md" description="House spec"/>
</references_available>
</skill>
<file path="style_guide.md"><![CDATA[...]]></file>
<shared_file path="house_voice.md"><![CDATA[...]]></shared_file>
</grounding>
<layer_data>
<artifact path="01_outline/outline.md"><![CDATA[# Act I...]]></artifact>
<artifact_ref path="01_outline/huge.json" size_bytes="48000"/>
<fan_out_item key="topic"><![CDATA[{"slug":"act1","title":"Setup"}]]></fan_out_item>
</layer_data>
<previous_attempt turn="2">
<output><![CDATA[FADE IN: ...]]></output>
<validator_feedback>
<failure name="word_count_over" type="native">
word count 420 is not strictly greater than min_words=800
</failure>
</validator_feedback>
<human_feedback><![CDATA[Tighten Act 2.]]></human_feedback>
</previous_attempt>
<previous_iteration index="2">
<artifact path="02_script/iter_02/script.md"><![CDATA[...]]></artifact>
<exit_failures>
<failure name="coherence_check" type="llm">Act II drifts.</failure>
</exit_failures>
</previous_iteration>
<instructions><![CDATA[Expand the outline...]]></instructions>
</icm_turn>
Notes:
_refelements appear when an artifact exceedsinline_artifact_limit_bytes, fails resolution (missing="true"), or contains non-UTF-8 bytes.- Passing validator / exit-condition results are filtered out — the agent only sees actionable failures.
- The instructions block contains the stage contract body verbatim.
Plan + progress events
ICM emits the engine’s generic plan.created once at run start and
plan.progress after each stage transition, so generic UIs that render a
plan see the workflow without ICM-specific knowledge. On top of that, the
following typed events surface richer detail. All payloads carry a
_schema_version field and live in
plugins/workflows/icm/icmtypes/types.go.
| Event | When | Notes |
|---|---|---|
icm.run.started | After workspace load + plan.created, before stage 1 dispatches. | Carries run_id, instance_id, workspace_root, stages count. |
icm.run.completed | All stages finished without halt. | Includes elapsed_seconds. |
icm.run.halted | Stage error policy halts, gate rejects, or run context cancels. | cancelled: true distinguishes ctx cancellation from gate reject. |
icm.stage.started | Stage execution begins, before any human_gate: start gate. | Carries derived posture name + 1-based stage order. |
icm.stage.completed | Artifact written + any end gate resolved. | Includes iterations_run + convergence_failed for looping stages. |
icm.stage.failed | Dispatch error policy halts, gate rejects, or loop.on_exhausted: error fires. | Carries free-text reason. |
icm.stage.iteration | Once per loop iteration, immediately before the iteration’s invocation. | Includes prior iteration’s exit_failures. |
icm.turn | After each turn within an invocation. | Richer-UI feed only; basic UIs already see stage transitions via plan.progress. |
icm.fanout.item | Item lifecycle boundary in a fan-out stage. | Status is active / completed / failed. |
icm.predicate.failed | Any predicate evaluation returns Verdict=false. | Single source of truth for failure visibility — pass paths are not emitted. |
workflow.progress | Run start, stage start, every iteration, every fan-out item completion, stage / run completion, halt / failure. | Engine-generic structured payload (events.WorkflowProgress). Powers the dedicated workflow status panel in nexus.io.tui and the indicator chip in nexus.io.browser. Emitted alongside the icm.* family, not in place of it. |
In addition, with emit_progress_thinking_steps: true (default) ICM emits
thinking.step events tagged Phase="icm.<stage_id>" so UIs that render
thinking surfaces show inline stage transitions without subscribing to the
typed event family.
UI feedback surfaces
Both bundled IO plugins (nexus.io.tui, nexus.io.browser) wire ICM
progress directly so users see real-time feedback during long runs
without enabling extra observers:
- Scrollback (thinking-step stream) — every
icm.*event is formatted into a one-line audit row via the helpers inplugins/workflows/icm/icmtypes/format.goand rendered alongside other thinking steps. Long runs leave a complete trail of stage transitions, iteration retries, predicate failures, and fan-out item ticks. - Dedicated workflow panel — the generic
workflow.progressevent drives a sticky surface (TUI right-rail panel, browser chip indicator) that updates in place: workflow name, stage X/Y, iter N/M, turn N/M, fan-out items done/total, status badge, and the names of any predicate failures from the last iteration.
The two surfaces complement each other: scrollback for “what happened”, dedicated panel for “where are we now”.
Session layout + artifacts
Every run owns a directory under <dataDir>/<runID>/. dataDir is the
plugin’s per-session data dir provided by the engine, so multiple
concurrent runs do not collide.
<engine_session>/plugins/<instance>/<runID>/
.icm/
run.json run metadata: workspace path, started at, plan
state.json mutable per-stage progress (updated as stages run)
00_input/
premise.txt copied from io.input.Content or workspace_inputs_dir
01_outline/
outline.md
outline.md.icm.json per-artifact sidecar: writer, validators, schema
02_script/
iter_01/
script.md
script.md.icm.json
iter_02/
script.md
script.md.icm.json
script.md aggregate (last iteration after convergence)
script.md.icm.json
03_review/
items/ fan-out items live here
act1/
review.md
act2/
review.md
review.md flat aggregate written for downstream refs
review.md.icm.json
Logical refs (<stage_id>/<filename> in inputs.artifacts or
fan_out.source) resolve as follows:
- Plain stage path wins when present.
- Otherwise the highest-numbered
iter_NN/<filename>wins (numeric sort —iter_10beatsiter_9). - For fan-out stages, the aggregate at the plain stage path is what downstream stages see; per-item files are addressable only inside the fan-out itself.
<instance> is nexus.workflows.icm for the default instance, or the
instance-suffixed ID (nexus.workflows.icm.script, etc.) for additional
instances. The engine maps slashes in instance IDs to dots when computing
the on-disk directory.
Multi-instance setup
Multiple ICM instances coexist in one engine via the nexus.workflows.icm/<suffix>
form. Each instance pins its own workspace, judge posture, and tool
namespace.
plugins:
active:
- nexus.agent.postures
- nexus.control.hitl
- nexus.workflows.icm/script
- nexus.workflows.icm/research
nexus.workflows.icm/script:
workspace: ~/work/screenplay-pipeline
default_judge_posture: judge_strict
default_workflow_posture: writer_base
nexus.workflows.icm/research:
workspace: ~/work/topic-research
default_judge_posture: judge_basic
cache_size: 64 # research is read-heavy; caching is safe here
auto_include_skill_reference_tool: false
Per-instance details:
- The skill-reference tool name is namespaced per instance:
read_skill_reference_script,read_skill_reference_research. Default instances (no suffix) get the unsuffixedread_skill_reference. - Derived posture names embed the instance suffix so the registry stays unambiguous.
- Each instance carries a distinct
<runID>namespace and a distinct data-dir prefix. - The
icm_validateLLM tool is similarly suffixed (icm_validate_script,icm_validate_research).
A single judge / human-gate plugin services all instances. The HITL
request’s RequesterPlugin field carries the suffixed instance ID so
operators can route gates per workflow.
Troubleshooting
Workspace fails to load.
ICM aggregates every validation error before returning, so the boot log
shows them as a bulleted list (workspace load failed (N errors):).
Common causes:
- Duplicate numeric prefixes (
01_outline+01_intro) — execution order is ambiguous; renumber one. - Stage folder named
00_input— reserved name; rename. output.filenamecontaining/— must be a bare filename; the loader builds the path.inputs.artifactsref to a later stage — refs must point at the reserved00_inputor at a stage with a lower numeric prefix.output.format: jsonwithoutoutput.schema— JSON outputs always require a schema.turns.policy: until_validwith emptyoutput.validators— without validators there is nothing to retry against.type: commandpredicate pointing at a non-executable script —chmod +xthe file.
The aggregated error message includes the exact file path and line number when applicable.
Stage halts with default_judge_posture is not configured.
A type: llm predicate ran and the plugin had no posture to dispatch the
judge against. Wire a judge in one of two ways:
# Plugin-level fallback (every llm predicate without an explicit `model:`).
plugins:
nexus.workflows.icm:
default_judge_posture: judge_basic
# Per-predicate override.
output:
validators:
- type: llm
rubric: rubrics/quality.md
model: judge_strict
The named posture must be registered with nexus.agent.postures (typically
via its scan_dirs config) before ICM boots.
Loop never converges.
loop.max_iterations is the per-pass cap. When it runs out under
on_exhausted: human_gate (the default), the human is offered accept /
restart / error. restart wipes the stage directory and reruns from
iteration 1. The plugin’s loop_max_restarts caps the number of restarts
per run (default 3, 0 for unlimited) so a never-converging workspace
cannot spin forever. If you see repeated restarts, the root cause is
usually:
- Exit conditions that the writer model cannot satisfy (rubric too tight, or contradicts an earlier rubric).
- Operator prompt that does not reference the prior-iteration failure
block. The default operator template handles this; custom operators must
read
<previous_iteration><exit_failures>and address each failure. on_exhausted: errormakes failures loud and immediate — useful in CI.
Fan-out source doesn’t resolve.
The loader validates fan_out.source shape and that the named stage runs
earlier, but the artifact filename is checked against the prior stage’s
declared output.filename. Two common mistakes:
- Filename typo (
topics.jsonvstopic.json) — change one or the other. - Source stage’s
output.formatistext; fan-out always parses JSON. Make the producer JSON or change the consumer.
At runtime, if the source produces a non-array (or the jsonpath lands on
a non-array), the stage emits icm.stage.failed with a reason of
fan_out.source did not resolve to an array. Inspect the source artifact
in the session directory directly — the orchestrator never modifies it.
Run cancelled mid-HITL.
When the run context cancels while ICM is blocked on a HITL response, the
plugin emits hitl.cancel with the pending request ID so the UI can clear
its pending prompt. The run then completes with icm.run.halted,
cancelled: true.
See also
- configuration reference — every config key with defaults.
- agent postures — base postures stages inherit from; required at boot.
- HITL — the registry ICM routes gates through.
- skills — the engine-level skill machinery; ICM’s per-workspace
skills are independent but share the same
SKILL.mdshape. plugins/workflows/icm/schema.json— JSON schema for the config block.plugins/workflows/icm/workspace/types.go— full Go type model.plugins/workflows/icm/icmtypes/types.go—icm.*event payload structs.
Plugin Overview
Nexus ships with 21 built-in plugins organized into categories. Activate only the plugins you need — the engine handles dependency resolution and boot ordering automatically.
Plugin Categories
| Category | Count | Purpose |
|---|---|---|
| Agents | 4 | Core reasoning loops — ReAct, Plan & Execute, Subagent, Orchestrator |
| LLM Providers | 2 | LLM API integration (Anthropic, OpenAI) |
| Tools | 5 | Capabilities the agent can invoke — shell, files, PDF, opener, ask user |
| Memory | 2 | Conversation persistence and context window compaction |
| I/O Interfaces | 2 | User interaction — terminal UI and browser-based UI |
| Observers | 2 | Event logging and thinking step persistence |
| Planners | 2 | Execution planning — LLM-generated or pre-configured |
| Skills | 1 | Skill discovery and management |
| System | 1 | Dynamic system prompt variables |
| Control | 1 | Cancellation coordination |
Choosing Plugins
A minimal useful agent needs at least:
- One I/O plugin — How the user interacts (
nexus.io.tuiornexus.io.browser) - One LLM provider — Which AI model to use (
nexus.llm.anthropic,nexus.llm.openai) - One agent — The reasoning strategy (
nexus.agent.reactis the most common)
Everything else is optional. Add tools to give the agent capabilities, memory to persist conversations, planners for complex task decomposition, and observers for debugging.
Common Combinations
Conversational Agent (no tools)
active:
- nexus.io.tui
- nexus.llm.anthropic
- nexus.agent.react
- nexus.memory.capped
Coding Assistant
active:
- nexus.io.tui
- nexus.llm.anthropic
- nexus.agent.react
- nexus.tool.shell
- nexus.tool.file
- nexus.control.hitl
- nexus.skills
- nexus.memory.capped
Planned Coding Workflow
active:
- nexus.io.tui
- nexus.llm.anthropic
- nexus.agent.react
- nexus.planner.dynamic
- nexus.observe.thinking
- nexus.tool.shell
- nexus.tool.file
- nexus.control.hitl
- nexus.skills
- nexus.memory.capped
- nexus.memory.compaction
Multi-Agent Orchestration
active:
- nexus.io.tui
- nexus.llm.anthropic
- nexus.agent.orchestrator
- nexus.agent.subagent
- nexus.agent.react
- nexus.tool.shell
- nexus.tool.file
- nexus.memory.capped
- nexus.control.cancel
Plugin Documentation Format
Each plugin page in this reference covers:
- ID — The plugin identifier used in config
- Purpose — What it does and when to use it
- Configuration — All config options with types and defaults
- Events — What it subscribes to and emits
- Dependencies — Other plugins it requires
- Usage examples — Config snippets and common patterns
Agent Plugins
Agents are the brain of a Nexus harness. They receive user input, orchestrate LLM calls and tool usage, and produce output. You must activate exactly one agent plugin (unless using the orchestrator pattern, which depends on subagent + react).
Available Agents
| Plugin | ID | Strategy |
|---|---|---|
| ReAct | nexus.agent.react | Iterative reason-and-act loop |
| Plan & Execute | nexus.agent.planexec | Create a plan first, then execute step by step |
| Subagent | nexus.agent.subagent | Spawns child agents as tools |
| Orchestrator | nexus.agent.orchestrator | Decomposes tasks and dispatches to parallel workers |
| Remote AG-UI Agents | nexus.agent.agui_remote | Delegates to a remote AG-UI agent as a tool |
Choosing an Agent
- ReAct — Best for most use cases. Simple, flexible, supports planning as an optional phase. Start here.
- Plan & Execute — When you want a mandatory planning phase with explicit step tracking and optional replanning on failure.
- Orchestrator — For complex tasks that benefit from parallel decomposition across multiple subagent workers.
- Subagent — Not used standalone. Provides a
spawn_subagenttool that other agents (or the orchestrator) can invoke.
Agent + Planner Interaction
The ReAct and Plan & Execute agents can optionally integrate with planners:
- ReAct + Dynamic Planner — LLM generates a plan before the agent starts iterating. The plan is injected into the system prompt.
- ReAct + Static Planner — Fixed steps from config are injected into the system prompt.
- Plan & Execute — Has its own built-in planning phase (uses LLM directly, no separate planner plugin needed).
ReAct Agent
The ReAct (Reason + Act) agent is the default and most commonly used agent strategy. It runs an iterative loop: send messages to the LLM, parse the response, execute any tool calls, feed results back, and repeat until the LLM produces a final answer.
Details
| ID | nexus.agent.react |
| Dependencies | None |
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
planning | bool | false | Enable planning phase before iteration starts |
model_role | string | (default) | Model role to use (e.g., reasoning, balanced, quick) |
system_prompt | string | (none) | Inline system prompt text |
system_prompt_file | string | (none) | Path to a system prompt markdown file |
parallel_tools | bool | false | Run multiple tool calls from a single LLM response in parallel |
max_concurrent | int | 4 | Concurrency cap when parallel_tools: true |
tool_choice | string/object | (none) | Tool choice mode — shorthand string or object with mode/name/sequence |
Iteration limits are not an agent setting — enforce them with
nexus.gate.endless_loop(default cap: 25 LLM calls per turn).
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
io.input | 50 | Receives user messages to start processing |
tool.result | 50 | Receives results from tool execution |
llm.response | 50 | Receives non-streaming LLM responses |
llm.stream.chunk | 50 | Receives streaming response chunks |
llm.stream.end | 50 | Streaming complete signal |
skill.loaded | 50 | Receives loaded skill content |
tool.register | 50 | Dynamically registers available tools |
plan.result | 50 | Receives completed plans from planners |
cancel.active | 5 | Handles cancellation |
cancel.resume | 5 | Handles resumption after cancel |
memory.compacted | 50 | Updates conversation history after compaction |
gate.llm.retry | 50 | Retries previously vetoed LLM request |
agent.tool_choice | 50 | Dynamic tool choice override from other plugins |
Emits
| Event | When |
|---|---|
llm.request | Sending a message to the LLM |
before:tool.invoke | Before executing a tool (vetoable — enables approval) |
tool.invoke | Invoking a tool |
before:tool.result | Before tool result propagation (vetoable) |
tool.result | Synthetic tool results (e.g., for vetoed tools) |
before:io.output | Before sending output (vetoable) |
io.output | Final agent response to the user |
io.status | Status updates (thinking, tool_running, etc.) |
thinking.step | Reasoning/thinking steps for persistence |
plan.request | Requesting a plan from a planner plugin |
agent.turn.start | Beginning of a conversation turn |
agent.turn.end | End of a conversation turn |
How It Works
- User sends a message →
io.inputarrives - Agent builds the message history and sends
llm.request - LLM responds with text and/or tool calls
- If tool calls exist:
- Agent emits
before:tool.invoke(can be vetoed for approval) - Agent emits
tool.invokefor each tool call - Tool plugin emits
before:tool.result(vetoable — gates can inspect/block) - Waits for
tool.resultevents - Loops back to step 2 with tool results appended
- Agent emits
- If no tool calls, the LLM’s response is the final answer →
io.output - Stops when
nexus.gate.endless_loopvetoes the nextllm.request(default: 25 calls per turn)
Planning Integration
When planning: true, the agent requests a plan before starting iteration:
- Agent emits
plan.requestwith the user’s input - A planner plugin (dynamic or static) generates a plan
- Agent receives
plan.result - The plan steps are injected into the system prompt as context
- Normal ReAct iteration begins with the plan as guidance
Tool Choice
Controls whether the LLM must use tools. Supports static defaults, per-iteration sequences, and dynamic overrides.
Static default (shorthand or object)
nexus.agent.react:
tool_choice: required # shorthand: force tool use every iteration
# or
tool_choice:
mode: auto # "auto" | "required" | "none" | "tool"
name: shell # only when mode == "tool"
Per-iteration sequence
nexus.agent.react:
tool_choice:
sequence:
- mode: required # iteration 1: force tool use
- mode: tool # iteration 2: force specific tool
name: shell
- mode: auto # iteration 3+: last entry repeats
Dynamic override
Any plugin can emit agent.tool_choice with AgentToolChoice{Mode, ToolName, Duration}:
Duration: "once"— applies to next LLM request only, then reverts to config default.Duration: "sticky"— persists until replaced by another override. Reset on new turn.
Example Configuration
nexus.agent.react:
planning: true
model_role: balanced
system_prompt: |
You are a coding assistant powered by Nexus. You help users write, debug, refactor, and understand code.
## Guidelines
1. Always explain your reasoning before making changes
2. Run tests after modifications to verify correctness
3. Prefer minimal, targeted changes over broad refactors
4. Ask for clarification when requirements are ambiguous
5. Read files in chunks 16kb or less
6. Follow the existing code style and conventions of the project
tool_choice:
sequence:
- mode: required
- mode: auto
Tool Discovery
The agent discovers tools dynamically through tool.register events. When tool plugins initialize, they emit their tool definitions. The agent collects these and includes them in every llm.request.
This means adding a tool to your agent is as simple as adding it to the active list — no explicit wiring needed.
Plan & Execute Agent
The Plan & Execute agent separates planning from execution. It delegates plan
generation to whichever planner plugin is active on the bus (e.g.
nexus.planner.dynamic or nexus.planner.static) while retaining full
control of the surrounding flow: phase transitions, approval, step execution,
re-planning on failure, and final synthesis.
This means you can change how plans are produced — LLM-driven, static, role-specialized, or a custom planner you build — without modifying the agent.
Details
| ID | nexus.agent.planexec |
| Dependencies | A planner plugin (e.g. nexus.planner.dynamic) must be active |
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
execution_model_role | string | balanced | Model role used for step execution and synthesis |
replan_on_failure | bool | true | Request a fresh plan if a step fails (up to 2 replans per turn) |
approval | string | never | When planexec requires approval after the planner returns a plan: always, never |
system_prompt | string | (none) | Inline system prompt for execution/synthesis |
system_prompt_file | string | (none) | Path to system prompt file |
Per-step iteration limits are enforced by
nexus.gate.endless_loop, not by planexec itself. Plan-step counts are managed by the planner that producesplan.result.
Plan-generation options (model, prompt, max steps, planner-side approval) now live on the planner plugin itself. See the planner docs.
Approval layers
Two independent approval gates may apply:
- Planner-side — e.g.
nexus.planner.dynamicsupportsalways/auto/never. When the planner denies or the user rejects, the planner emitsplan.resultwithApproved: falseand planexec will end the turn. - planexec-side — even when the planner returns an approved plan,
setting planexec’s own
approval: alwayswill emit a secondplan.approval.requestbefore execution begins.
To avoid double-prompting, pick one side to own approval and set the other
to never.
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
io.input | 50 | Receives user messages |
tool.result | 50 | Tool execution results |
llm.response | 50 | Step execution and synthesis responses |
llm.stream.chunk / llm.stream.end | 50 | Streaming synthesis output |
skill.loaded | 50 | Skill content |
tool.register | — | Tool discovery |
plan.result | 50 | Receives generated plans from the active planner |
plan.approval.response | 50 | User response to planexec-side approval |
memory.compacted | 50 | History compaction |
Emits
| Event | When |
|---|---|
plan.request | Start of a turn, or when re-planning after a step failure |
plan.approval.request | When planexec’s own approval: always is set |
llm.request | Step execution and final synthesis |
before:tool.invoke / tool.invoke | Tool invocation |
before:tool.result | Before tool result propagation (vetoable) |
agent.plan | After each step status change |
io.status | Phase transitions |
thinking.step | Reasoning steps |
agent.turn.start / agent.turn.end | Turn boundaries |
Phases
The agent transitions through these phases:
stateDiagram-v2
direction LR
[*] --> idle
idle --> planning: agent.turn.start
planning --> awaiting_approval: approval == always
planning --> executing: approval skipped
awaiting_approval --> executing: user approves
awaiting_approval --> idle: user rejects
executing --> executing: next step
executing --> planning: step failed + replan_on_failure
executing --> synthesizing: all steps complete
synthesizing --> idle: agent.turn.end
idle --> [*]
- Planning — Emits
plan.request; waits forplan.resultfrom the active planner. - Awaiting Approval — Only entered if planexec’s own
approval: always. - Executing — Runs each step sequentially, with its own message history and iteration budget.
- Synthesizing — After all steps complete, generates a summary of results.
Replanning
When replan_on_failure: true and a step fails, the agent:
- Collects the status and results of completed/failed/pending steps.
- Emits a fresh
plan.requestwhoseInputcontains the original request plus a structured summary of what happened. - Waits for the new
plan.resultand resumes execution with the revised plan.
Because re-planning is just another plan.request, any planner
implementation automatically participates.
Example Configuration
plugins:
active:
- nexus.agent.planexec
- nexus.planner.dynamic # any planner plugin works
# ...
nexus.agent.planexec:
execution_model_role: balanced
replan_on_failure: true
approval: never
system_prompt: |
You are a coding assistant powered by Nexus. You help users write, debug, refactor, and understand code.
## Guidelines
1. Always explain your reasoning before making changes
2. Run tests after modifications to verify correctness
3. Prefer minimal, targeted changes over broad refactors
4. Ask for clarification when requirements are ambiguous
5. Read files in chunks 16kb or less
6. Follow the existing code style and conventions of the project
nexus.planner.dynamic:
model_role: reasoning
max_steps: 8
approval: auto
When to Use
Choose Plan & Execute over ReAct when:
- Tasks are complex and benefit from upfront decomposition.
- You want explicit step tracking and progress visibility.
- You want to swap planning strategies (LLM, static, domain-specific) without touching the agent.
- You need a different model tier for planning vs. execution.
Subagent
The subagent plugin provides a tool that spawns independent child agents. Other agents (or the orchestrator) can invoke it to delegate subtasks. Each instance can be configured with its own system prompt, tool name, and model role.
Details
| ID | nexus.agent.subagent |
| Dependencies | nexus.agent.react |
| Multi-instance | Yes — supports instance suffixes (e.g., nexus.agent.subagent/researcher) |
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
max_iterations | int | 10 | Max iterations for spawned agents |
model_role | string | (default) | Default model role for spawned agents |
system_prompt | string | (none) | Default system prompt for spawned agents |
system_prompt_file | string | (none) | Path to default system prompt file |
tool_name | string | spawn_subagent | Name of the tool exposed to the parent agent |
tool_description | string | (auto) | Description of the tool |
Tool Definition
The subagent registers a tool (default name: spawn_subagent) with these parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
task | string | Yes | The task description for the subagent |
system_prompt | string | No | Override the default system prompt |
model_role | string | No | Override the default model role |
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
tool.invoke | 50 | Handles spawn tool invocations |
tool.register | 50 | Collects available tools |
Emits
| Event | When |
|---|---|
tool.register | Registers the spawn tool at boot |
subagent.spawn | When a new subagent is created |
Multi-Instance Usage
Each instance creates its own spawn tool. Use instance suffixes to create specialized spawners:
plugins:
active:
- nexus.agent.react
- nexus.agent.subagent/researcher
- nexus.agent.subagent/writer
nexus.agent.subagent/researcher:
max_iterations: 15
model_role: reasoning
system_prompt: "You are a research specialist. Gather information thoroughly."
tool_name: spawn_researcher
nexus.agent.subagent/writer:
max_iterations: 10
model_role: balanced
system_prompt: "You are a technical writer. Produce clear, concise documentation."
tool_name: spawn_writer
The parent agent will see two tools: spawn_researcher and spawn_writer.
Subagent Events
When a subagent runs, these events are emitted:
| Event | Payload | When |
|---|---|---|
subagent.spawn | SpawnID, Task, ParentTurnID | Subagent created |
subagent.started | SpawnID, Task, ParentTurnID | Subagent begins execution |
subagent.iteration | SpawnID, Iteration, Content | Each reasoning iteration |
subagent.complete | SpawnID, Result, TokensUsed, CostUSD | Subagent finished |
Example
A ReAct agent with a research subagent:
plugins:
active:
- nexus.io.tui
- nexus.llm.anthropic
- nexus.agent.react
- nexus.agent.subagent
- nexus.memory.capped
nexus.agent.react:
max_iterations: 10
system_prompt: "You can delegate research tasks using the spawn_subagent tool."
nexus.agent.subagent:
max_iterations: 10
system_prompt: "Take in the user input and summarize the problem."
Orchestrator
The orchestrator implements a manager-worker pattern. It uses an LLM to decompose complex tasks into subtasks, then dispatches them to parallel subagent workers. Results are synthesized into a final response.
Details
| ID | nexus.agent.orchestrator |
| Dependencies | nexus.agent.subagent |
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
max_workers | int | 5 | Maximum concurrent subagent workers |
max_subtasks | int | 8 | Maximum number of subtasks to decompose into |
worker_max_iterations | int | 10 | Max iterations per worker subagent |
orchestrator_model_role | string | reasoning | Model for task decomposition |
worker_model_role | string | balanced | Model for worker execution |
synthesis_model_role | string | balanced | Model for result synthesis |
fail_fast | bool | false | Stop all workers if one fails |
system_prompt | string | (none) | System prompt for the orchestrator |
system_prompt_file | string | (none) | Path to system prompt file |
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
io.input | 50 | Receives user tasks |
tool.result | 50 | Tool results during decomposition |
llm.response / llm.stream.* | 50 | LLM responses |
skill.loaded | 50 | Skill content |
tool.register | 50 | Tool discovery |
subagent.started | 50 | Worker started notification |
subagent.iteration | 50 | Worker progress |
subagent.complete | 50 | Worker finished |
cancel.active / cancel.resume | 5 | Cancellation |
memory.compacted | 50 | History compaction |
Emits
| Event | When |
|---|---|
llm.request | Decomposition and synthesis LLM calls |
before:tool.invoke / tool.invoke | Tool invocation |
before:tool.result | Before tool result propagation (vetoable) |
thinking.step | Decomposition and synthesis reasoning |
io.status | Phase transitions |
agent.turn.start / agent.turn.end | Turn boundaries |
Phases
stateDiagram-v2
direction LR
[*] --> idle
idle --> decomposing: agent.turn.start
decomposing --> dispatching: subtasks ready
dispatching --> executing: workers spawned
executing --> executing: subtask completed
executing --> synthesizing: all subtasks done
synthesizing --> idle: agent.turn.end
idle --> [*]
note right of decomposing
orchestrator LLM
breaks task into
subtasks
end note
note right of executing
parallel workers
respect max_workers
end note
- Decomposing — The orchestrator LLM breaks the task into subtasks with descriptions and optional dependencies
- Dispatching — Subtasks are queued and sent to subagent workers (respecting
max_workersconcurrency) - Executing — Workers run in parallel; the orchestrator tracks progress and collects results
- Synthesizing — All results are gathered and the synthesis LLM produces a unified response
Subtask Dependencies
The orchestrator can recognize dependencies between subtasks. Dependent subtasks wait until their prerequisites complete before dispatching.
Failure Handling
fail_fast: false(default) — Other workers continue even if one fails. Failed results are included in synthesis.fail_fast: true— All remaining workers are cancelled when any worker fails.
Example Configuration
plugins:
active:
- nexus.io.tui
- nexus.llm.anthropic
- nexus.agent.orchestrator
- nexus.agent.subagent
- nexus.agent.react
- nexus.tool.shell
- nexus.tool.file
- nexus.memory.capped
- nexus.control.cancel
nexus.agent.orchestrator:
max_workers: 3
max_subtasks: 6
orchestrator_model_role: reasoning
worker_model_role: balanced
synthesis_model_role: balanced
fail_fast: false
nexus.agent.subagent:
max_iterations: 10
When to Use
The orchestrator is ideal for:
- Complex tasks that naturally decompose into independent subtasks
- Research across multiple topics that can be explored in parallel
- Code analysis across multiple files or modules simultaneously
- Any task where parallel execution significantly reduces total time
Posture Registry (nexus.agent.postures)
Loads AgentPosture YAML files from disk and exposes the posture.registry
capability that nexus.agent.delegate consumes. fsnotify watches every
configured directory for live edits; active sub-sessions keep their old
posture, new invocations resolve the new one.
See the Postures architecture page for the full conceptual model and delegation for how the registry is consumed.
Details
| ID | nexus.agent.postures |
| Capability | posture.registry |
| Dependencies | (none) |
| Requires | (none) |
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
scan_dirs | []string | [] | Directories scanned for *.yaml / *.yml posture files. Paths run through engine.ExpandPath (supports ~). |
debounce_ms | int | 250 | fsnotify reload debounce in milliseconds. |
If scan_dirs is empty, no postures load; the plugin still advertises the
capability so nexus.agent.delegate boots cleanly (delegate calls will
fail with posture: not found).
Posture YAML
Each file in a scan dir is parsed as a single AgentPosture. The filename
(minus extension) supplies the name if the YAML omits one.
name: analyst
description: deep reader; quotes sources verbatim
system_prompt: |
You are a careful analyst. Cite sources by URL. Be concise.
allowed_tools:
- web_search
- web_fetch
- read_pdf
model:
model_role: reasoning
max_tokens: 4000
default_budget:
timeout: 60s
max_tokens: 50000
max_tool_calls: 20
max_recursion_depth: 2
A 16-character content hash lands on Version, included automatically in
delegate cache keys so any edit invalidates stale entries.
Events
Emits
| Event | When |
|---|---|
posture.registered | A posture loads (initial scan) or reloads (watcher fire). |
posture.removed | A posture file is deleted or fails to parse after an edit. |
See events reference for payload shape.
Subscribes To
None.
Example
plugins:
active:
- nexus.io.tui
- nexus.llm.anthropic
- nexus.agent.react
- nexus.agent.postures
- nexus.agent.delegate
- nexus.memory.capped
nexus.agent.postures:
scan_dirs:
- ~/.nexus/postures
- ./configs/postures
debounce_ms: 250
Delegate (nexus.agent.delegate)
Exposes the delegate tool — the LLM-facing surface of the
sub-agent delegation primitive. A parent
agent picks a registered posture by name; the runtime spawns
an isolated sub-session with its own envelope identity, runs the LLM loop
filtered to the posture’s AllowedTools, and enforces the posture’s
DefaultBudget with optional per-call overrides.
Details
| ID | nexus.agent.delegate |
| Dependencies | (none) |
| Requires | Capability posture.registry (typically nexus.agent.postures) |
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
max_depth | int | 3 | Hard cap on sub-agent recursion depth across all postures. Individual postures may tighten with max_recursion_depth. |
cache_size | int | 256 | Capacity of the in-process LRU result cache (entries, not bytes). Zero disables eviction. |
cache | bool | true | Set false to disable result caching entirely. |
Tool definition
The plugin registers a single tool, delegate, with these parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
posture | string | yes | Registered AgentPosture name. |
task | string | yes | Natural-language description of what the sub-agent should accomplish. |
context | object | no | Structured context the sub-agent receives alongside the task. Serialized into the initial user message under <delegate_context>. |
max_tokens | int | no | Override the posture’s default token budget for this call. |
max_tool_calls | int | no | Override the posture’s default tool-call budget. |
timeout_seconds | int | no | Override the posture’s default timeout. |
The tool’s JSON output is a delegate.Output:
{
"Result": "...",
"Status": "success", // success | partial | error | timeout | cancelled | cache_hit
"Error": "",
"TokensUsed": 1832,
"ToolCallsUsed": 4,
"Elapsed": 8421000000,
"SubSessionID": "abcd...",
"PostureName": "analyst",
"PostureVer": "a1b2c3d4e5f6...",
"Depth": 1
}
The parent agent branches on Status to decide whether to retry with a
larger budget, fall back to handling the task itself, or surface the
partial result.
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
tool.invoke | 50 | Handle invocations of the delegate tool. |
tool.register | (default) | Build the snapshot of catalog tools so the runtime can filter to the posture’s AllowedTools. |
Emits
| Event | When |
|---|---|
tool.register | Registers the delegate tool at boot. |
delegate.start | A sub-session is about to begin. |
delegate.complete | A sub-session has finished. |
llm.request / before:llm.request | Per LLM iteration inside the sub-session. |
tool.invoke / before:tool.invoke | Per tool call the sub-agent dispatches. |
tool.result / before:tool.result | The final response to the parent agent. |
See delegate events for
delegate.start / delegate.complete payload shape.
Example
plugins:
active:
- nexus.io.tui
- nexus.llm.anthropic
- nexus.agent.react
- nexus.agent.postures
- nexus.agent.delegate
- nexus.tool.web
- nexus.memory.capped
nexus.agent.postures:
scan_dirs:
- ~/.nexus/postures
nexus.agent.delegate:
max_depth: 3
cache_size: 512
nexus.agent.react:
system_prompt: |
When a task benefits from a different reasoning style or a restricted
tool surface, call the delegate tool with the appropriate posture
(analyst, summarizer, auditor, ...).
Causation
Every event emitted from inside a sub-session carries the sub-agent’s
AgentID (delegate/<posture>/<sub_session_id>) and Depth on
Event.Causation. The replay primitive and observability tooling use
this to attribute work to the right specialist.
Remote AG-UI Agents (nexus.agent.agui_remote)
The consume side of Nexus’s AG-UI integration. Where
nexus.io.agui serves a Nexus agent over the AG-UI wire,
nexus.agent.agui_remote lets a Nexus agent call a remote AG-UI agent — any
service that speaks the AG-UI protocol, including another Nexus instance running
nexus.io.agui — as if it were a local delegate.
Each configured remote agent is registered as an LLM-facing tool (default name
delegate_agui_<name>). When the parent agent calls it, the plugin builds an
AG-UI RunAgentInput from the delegated task, streams the remote run over the
AG-UI wire (HTTP POST + SSE) via the reusable AG-UI client, maps the remote
run’s event stream back onto the Nexus bus, and returns the remote run’s
terminal outcome as the tool.result the parent expects.
From the parent agent’s perspective a remote AG-UI call is a single tool call,
exactly like the local delegate and subagent
primitives — the transport just happens to be the AG-UI wire instead of an
in-process sub-session.
Details
| ID | nexus.agent.agui_remote |
| Dependencies | (none) |
| Requires | (none) |
| Source | plugins/agents/aguiremote/plugin.go |
Configuration
The full, authoritative key list lives in the configuration reference. In brief:
| Key | Type | Default | Description |
|---|---|---|---|
agents | list | (required) | Non-empty list of remote AG-UI agents to expose. Each entry is a mapping (see below). |
timeout_seconds | int | 120 | Default per-call timeout (seconds). Overridable per agent and per call. |
cache_size | int | 128 | Capacity of the in-process LRU result cache (entries). Zero disables eviction. |
cache | bool | true | Set false to disable result caching entirely. |
Each agents[] entry:
| Key | Type | Default | Description |
|---|---|---|---|
name | string | (required) | Human-friendly identifier; used to derive the default tool name. |
endpoint | string | (required) | Full AG-UI POST endpoint URL (e.g. https://host/agui). |
tool_name | string | delegate_agui_<name> | Override the LLM-facing tool name. |
description | string | (auto) | Override the tool description shown to the LLM. |
bearer_token | string | (none) | Static bearer token for the Authorization header. Prefer bearer_token_env. |
bearer_token_env | string | (none) | Name of an environment variable holding the bearer token. Read at Init. |
timeout_seconds | int | (plugin default) | Per-agent default timeout, overriding the plugin-level value. |
Authentication
Secrets never live in config files: prefer bearer_token_env and point it at an
environment variable. When set, the client sends Authorization: Bearer <token>
on the POST that opens the remote run — matching the bearer auth
nexus.io.agui enforces on the serve side. bearer_token (an
inline literal) is supported for quick local testing but is only used when
bearer_token_env is unset.
Tool definition
Each remote agent registers one tool. The default name is
delegate_agui_<name> (the name lowercased with non-alphanumeric runs
collapsed to _); override it with tool_name.
| Parameter | Type | Required | Description |
|---|---|---|---|
task | string | yes | Natural-language description of what the remote agent should accomplish. |
context | object | no | Structured context passed alongside the task. Serialized into the initial user message under an XML <delegate_context> boundary. |
timeout_seconds | int | no | Override the remote agent’s default timeout for this call. |
Event & result mapping
While the remote run streams, the plugin translates the incoming AG-UI events onto the caller’s bus so local observers (journal, TUI, observability) see the remote work as a sub-run:
| Remote AG-UI event | Mapped Nexus event |
|---|---|
TextMessageContent / TextMessageChunk | io.output (role assistant) — streamed text deltas |
TextMessageEnd | subagent.iteration — a message boundary |
ToolCallStart / ToolCallArgs / ToolCallEnd | accumulated into a subagent.iteration with the tool call |
| (run begins) | subagent.started |
| (run ends) | subagent.complete — carries the terminal result or error |
The terminal outcome — accumulated text deltas, or the RunFinished result
payload when the remote streamed no text — becomes the tool.result Output
returned to the parent agent. The whole tool.result passes through the
vetoable before:tool.result gate first.
Every mapped event carries the remote sub-run’s causation identity
(AgentID = agui_remote/<name>/<spawn_id>, Depth = parent + 1), so remote
work slots into the causation tree beneath the caller just like a local
delegate.
Failure behavior
All failure modes surface as a clean tool error (tool.result.Error) — never
a hang or panic — and are mirrored on subagent.complete.Error:
| Condition | Result |
|---|---|
Remote emits RunError | remote agui run error: <code>: <message> |
| Transport error / endpoint unreachable | remote agui transport error: ... |
| Stream read error mid-run | remote agui stream error: ... |
Non-2xx rejection (e.g. 401 from bearer auth) | remote agui rejected request: HTTP <code> |
| Per-call timeout elapses | context-deadline error surfaced as a stream/transport error |
| Remote interrupts awaiting input | remote agui agent interrupted awaiting input: <prompt> (a one-shot delegate cannot resolve a remote HITL interrupt) |
| Remote run cancelled | remote agui run cancelled |
The parent agent’s loop consumes the error tool.result and continues normally,
so a flaky or unreachable remote never stalls the caller.
Caching
Identical calls replay from an in-process LRU keyed by
endpoint + task + canonicalized context (mirroring the local delegate
cache), so repeated delegations do not re-hit the remote endpoint. A cache hit
still emits a subagent.started / subagent.complete pair so observers see the
call. Errors are never cached. Set cache: false to disable.
Example
plugins:
active:
- nexus.io.tui
- nexus.llm.anthropic
- nexus.agent.react
- nexus.agent.agui_remote
- nexus.memory.capped
nexus.agent.agui_remote:
timeout_seconds: 90
agents:
- name: researcher
endpoint: https://research.internal/agui
bearer_token_env: RESEARCH_AGUI_TOKEN
description: A specialist research agent reachable over AG-UI.
- name: legal
endpoint: https://legal.internal/agui
tool_name: ask_legal
nexus.agent.react:
system_prompt: |
When a task needs specialist knowledge, delegate it: call
delegate_agui_researcher for research questions or ask_legal for
legal review. Pass a clear task and any relevant context.
Loopback (serve ↔ consume)
Because nexus.io.agui speaks the same AG-UI wire, you can
point nexus.agent.agui_remote at another Nexus instance’s serve endpoint. This
loopback topology is the cheapest faithful end-to-end proof of the consume path
and is exactly what the integration test
(tests/integration/agui_consume_test.go) exercises: a caller engine delegates
to a loopback nexus.io.agui serve engine and receives the remote agent’s
result back as a tool.result.
See also
- AG-UI Serve (
nexus.io.agui) — the serve side of the same wire. - Delegate / Subagent — the local sub-agent primitives this mirrors.
- Sub-agent delegation — the shared delegation model.
LLM Providers
LLM provider plugins handle communication with AI model APIs. They receive llm.request events, call the external API, and emit llm.response (or streaming chunks).
Available Providers
| Plugin | ID | Service |
|---|---|---|
| Anthropic | nexus.llm.anthropic | Claude (direct HTTP, no SDK) |
| OpenAI | nexus.llm.openai | GPT / o-series (direct HTTP, no SDK) |
| Gemini | nexus.llm.gemini | Google Gemini — public api-key + Vertex AI; thinking, multimodal, code execution, prompt caching |
| Fallback | nexus.provider.fallback | Automatic provider failover coordinator |
| Fanout | nexus.provider.fanout | Parallel multi-provider dispatch |
Provider Architecture
Providers are low-level plugins that:
- Subscribe to
llm.requestat high priority (10) - Resolve the requested model role via the Model Registry
- Apply prompt registry sections to the system prompt
- Make the API call (with streaming support)
- Emit
llm.responseorllm.stream.chunk/llm.stream.end
Providers don’t know about agents, tools, or conversations — they only translate between the Nexus event model and the external API.
Structured Output
When ResponseFormat is set on an LLMRequest, providers map it to their native structured output mechanism if supported, or simulate it otherwise.
Capability Matrix
| Provider | Native Support | Strategy |
|---|---|---|
| OpenAI | Yes | Maps directly to response_format in the API payload |
| Anthropic | No | Simulates via tool-use-as-schema: injects synthetic tool, forces tool choice, unwraps tool call arguments as structured response |
| Gemini | Yes | Maps to generationConfig.responseMimeType + responseSchema (incompatible JSON Schema keywords stripped) |
| Other/unknown | No | Ignores the field; json_schema gate handles validation downstream |
Metadata Flag
Providers set LLMResponse.Metadata["_structured_output"] = true when structured output enforcement was used (native or simulated). Downstream consumers (like the json_schema gate) can check this flag to skip redundant validation.
Anthropic (Claude) Provider
The Anthropic provider calls the Claude API via direct HTTP requests — no SDK dependency. It supports streaming, tool use, request cancellation, and automatic retries.
Details
| ID | nexus.llm.anthropic |
| Dependencies | None |
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
api_key_env | string | ANTHROPIC_API_KEY | Name of the environment variable containing the API key |
debug | bool | false | Log raw request/response bodies to the session plugin directory |
pricing | map | (embedded defaults) | Per-model pricing overrides. Keys are model IDs, values have input_per_million and output_per_million (USD) |
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
llm.request | 10 | Receives LLM requests from agents |
cancel.active | 5 | Cancels in-flight API requests |
Emits
| Event | When |
|---|---|
llm.response | Non-streaming response received |
llm.stream.chunk | Each chunk of a streaming response |
llm.stream.end | Streaming response complete |
core.error | API errors |
Features
Model Resolution
The provider uses the Model Registry to resolve role names. When an llm.request specifies a Role (e.g., "reasoning"), the provider looks up the concrete model config. If no role is specified, the default model is used.
Streaming
When llm.request.Stream is true, the provider uses Server-Sent Events (SSE) to stream the response. Each content chunk and tool use block generates a llm.stream.chunk event. When streaming completes, llm.stream.end carries the full usage statistics.
Tool Calling
The provider translates Nexus tool definitions into the Anthropic tool_use format. Tool call responses from the API are parsed and included in the llm.response or final llm.stream.end event.
Prompt Assembly
Before sending a request, the provider calls PromptRegistry.Apply() to append dynamic sections (skills catalog, system variables, etc.) to the system prompt.
Request Cancellation
Subscribes to cancel.active at priority 5. When a cancellation arrives, the in-flight HTTP request context is cancelled, aborting the API call.
Retry Logic
Transient errors (rate limits, server errors) are retried with exponential backoff.
Structured Output (Simulated)
Anthropic does not natively support response_format. When ResponseFormat is set with Type: "json_schema", the provider simulates structured output via tool-use-as-schema:
- A synthetic tool named
_structured_outputis injected alongside any real tools. Itsinput_schemais the output schema fromResponseFormat.Schema. tool_choiceis forced to{"type": "tool", "name": "_structured_output"}, overriding any existing tool choice.- Claude returns the structured data as tool call arguments.
- The provider unwraps the tool call arguments back into
LLMResponse.Content, so downstream consumers see structured output (not a tool call). LLMResponse.Metadata["_structured_output"]is set totrue.
During streaming, the synthetic tool’s input_json_delta chunks are emitted as llm.stream.chunk content events, so the UI can stream structured output in real time.
Cost Tracking
The provider computes CostUSD on every llm.response using per-model pricing rates. Embedded defaults cover common Claude models. Override via config for enterprise pricing tiers or new models:
nexus.llm.anthropic:
pricing:
claude-sonnet-4-6-20250514:
input_per_million: 3.0
output_per_million: 15.0
Config overrides are merged with embedded defaults — only override the models you need to change. Cost is accumulated into SessionMeta.CostUSD by the engine.
Debug Mode
When debug: true, raw request and response JSON bodies are written to the session’s plugin directory for inspection.
HTTP Configuration
- Timeout: 5 minutes per request
- API endpoint:
https://api.anthropic.com/v1/messages
Example Configuration
nexus.llm.anthropic:
api_key_env: ANTHROPIC_API_KEY
debug: false
To use a different environment variable for the API key:
nexus.llm.anthropic:
api_key_env: MY_CLAUDE_KEY
OpenAI Provider
The OpenAI provider calls the Chat Completions API via direct HTTP requests — no SDK dependency. It supports streaming, tool use, request cancellation, and automatic retries. Compatible with any OpenAI-compatible API endpoint (Azure OpenAI, local proxies, etc.) via base_url.
Details
| ID | nexus.llm.openai |
| Dependencies | None |
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
api_key_env | string | OPENAI_API_KEY | Name of the environment variable containing the API key |
base_url | string | https://api.openai.com/v1/chat/completions | API endpoint URL (override for Azure, local proxies, etc.) |
debug | bool | false | Log raw request/response bodies to the session plugin directory |
pricing | map | (embedded defaults) | Per-model pricing overrides. Keys are model IDs, values have input_per_million and output_per_million (USD) |
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
llm.request | 10 | Receives LLM requests from agents |
cancel.active | 5 | Cancels in-flight API requests |
Emits
| Event | When |
|---|---|
llm.response | Non-streaming response received |
llm.stream.chunk | Each chunk of a streaming response |
llm.stream.end | Streaming response complete |
core.error | API errors |
Features
Model Resolution
The provider uses the Model Registry to resolve role names. When an llm.request specifies a Role (e.g., "reasoning"), the provider looks up the concrete model config. If no role is specified, the default model is used.
Streaming
When llm.request.Stream is true, the provider uses Server-Sent Events (SSE) to stream the response. Each content chunk and tool use block generates a llm.stream.chunk event. When streaming completes, llm.stream.end carries the full usage statistics. Usage is requested via stream_options.include_usage.
Tool Calling
The provider translates Nexus tool definitions into the OpenAI function calling format (type: "function"). Tool call responses from the API are parsed and included in the llm.response or streamed via llm.stream.chunk events.
Prompt Assembly
Before sending a request, the provider calls PromptRegistry.Apply() to append dynamic sections (skills catalog, system variables, etc.) to the system prompt.
Request Cancellation
Subscribes to cancel.active at priority 5. When a cancellation arrives, the in-flight HTTP request context is cancelled, aborting the API call.
Retry Logic
Transient errors (rate limits, server errors) are retried with exponential backoff.
Structured Output (Native)
OpenAI natively supports structured output via the response_format API field. When ResponseFormat is set on an LLMRequest, the provider maps it directly:
json_object→{"type": "json_object"}— Forces valid JSON output.json_schema→{"type": "json_schema", "json_schema": {"name": "...", "schema": {...}, "strict": true}}— Forces output matching a specific schema. TheStrictfield controls whether OpenAI enforces exact schema adherence.text→ Noresponse_formatfield (OpenAI default).
LLMResponse.Metadata["_structured_output"] is set to true for json_object and json_schema types.
Cost Tracking
The provider computes CostUSD on every llm.response using per-model pricing rates. Embedded defaults cover common OpenAI models. Override via config for enterprise pricing tiers or new models:
nexus.llm.openai:
pricing:
gpt-4o:
input_per_million: 2.50
output_per_million: 10.0
Config overrides are merged with embedded defaults — only override the models you need to change. Cost is accumulated into SessionMeta.CostUSD by the engine.
Debug Mode
When debug: true, raw request and response JSON bodies are written to the session’s plugin directory for inspection.
Compatible Endpoints
The base_url config allows pointing at any OpenAI-compatible API:
- Azure OpenAI — Set
base_urlto your Azure endpoint - Local proxies — LM Studio, Ollama (with OpenAI-compatible mode), vLLM, etc.
- Other providers — Any service implementing the Chat Completions API
HTTP Configuration
- Timeout: 5 minutes per request
- API endpoint: Configurable via
base_url, defaults tohttps://api.openai.com/v1/chat/completions
Example Configuration
nexus.llm.openai:
api_key_env: OPENAI_API_KEY
debug: false
To use a different environment variable or custom endpoint:
nexus.llm.openai:
api_key_env: MY_OPENAI_KEY
base_url: https://my-proxy.example.com/v1/chat/completions
Using OpenAI models in the model registry:
core:
models:
default: balanced
reasoning:
provider: nexus.llm.openai
model: o3
max_tokens: 16384
balanced:
provider: nexus.llm.openai
model: gpt-4.1
max_tokens: 8192
quick:
provider: nexus.llm.openai
model: gpt-4.1-mini
max_tokens: 4096
Gemini Provider
The Gemini provider calls Google’s Gemini API via direct HTTP — no SDK dependency. It supports both the public Generative Language API (api-key auth) and Vertex AI (service-account JWT auth) and ships feature parity with the OpenAI and Anthropic providers (sync + streaming, tool use, structured output, retry, cancellation, debug logs, fallback hooks). On top of that it adds Gemini-only features: thinking (“reasoning”) parts, multimodal inputs, the built-in code execution tool, and prompt caching via the cachedContents API.
Details
| ID | nexus.llm.gemini |
| Dependencies | None |
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
auth | string | api_key | api_key for the public endpoint, vertex for Vertex AI |
api_key | string | — | Direct API key (overrides api_key_env) |
api_key_env | string | GEMINI_API_KEY, then GOOGLE_API_KEY | Env var holding the API key |
project_id | string | $GOOGLE_CLOUD_PROJECT | (Vertex) GCP project id |
location | string | us-central1 | (Vertex) GCP region for the AI Platform endpoint |
service_account_json | string | — | (Vertex) Path to a service-account JSON key |
service_account_json_env | string | GOOGLE_APPLICATION_CREDENTIALS | (Vertex) Env var holding the service-account path |
debug | bool | false | Log raw request/response bodies into the session plugin directory |
pricing | map | embedded defaults | Per-model pricing overrides; see Cost Tracking below |
retry | map | disabled | Retry/backoff config; see Retry Logic |
thinking | map | disabled | Reasoning config for Gemini 2.5; see Thinking |
code_execution | bool | false | Enable Gemini’s built-in code execution tool |
cache | map | disabled | Prompt cache config; see Prompt Caching |
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
llm.request | 10 | LLM requests from agents |
cancel.active | 5 | Cancels in-flight API requests |
Emits
| Event | When |
|---|---|
llm.response | Non-streaming response received (also after a stream completes) |
llm.stream.chunk | Each chunk of a streaming response |
llm.stream.end | Streaming response complete |
thinking.step | A thought: true part is observed (sync or stream) |
tool.invoke / tool.result | When the built-in code execution tool is used |
before:core.error / core.error | API errors (vetoable so fallback can intercept) |
Features
Auth Modes
api_key(default) — Sendsx-goog-api-keyheader. URL:https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent.vertex— Mints an OAuth2 access token via signed JWT exchange againsthttps://oauth2.googleapis.com/token, caches the token until expiry minus 60s, and routes requests tohttps://{location}-aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/publishers/google/models/{model}:generateContent. JWT signing uses RS256 from the stdlibcrypto/rsa; no third-party dependencies are added.
Streaming
When llm.request.Stream is true, the provider uses :streamGenerateContent?alt=sse and parses SSE data: lines. Text deltas are emitted as llm.stream.chunk (Content), function calls as llm.stream.chunk (ToolCall), and a final llm.stream.end carries usageMetadata.
Tool Calling
Nexus tool definitions are translated into Gemini functionDeclarations. Because Gemini matches tool calls and tool responses by function name (not an opaque ID), the provider synthesises stable IDs (call_{seq}_{name}) on outbound responses and resolves trailing tool messages back to the function name when serializing functionResponse parts.
Tool Choice
ToolChoice.Mode maps to toolConfig.function_calling_config:
auto→mode: AUTOrequired→mode: ANYnone→mode: NONEtool(withName) →mode: ANYplusallowed_function_names: [name]
Structured Output (Native)
When ResponseFormat.Type is json_object or json_schema, the provider sets generationConfig.responseMimeType: application/json and (for json_schema) generationConfig.responseSchema. JSON Schema fields Gemini doesn’t accept ($schema, $id, additionalProperties, $ref, definitions, $defs) are stripped recursively. LLMResponse.Metadata["_structured_output"] is set to true.
Thinking (Gemini 2.5)
When thinking.enabled: true, the provider sends generationConfig.thinkingConfig:
nexus.llm.gemini:
thinking:
enabled: true
budget_tokens: 8192 # 0 = disabled, -1 = dynamic budget
include_thoughts: true # surface thought parts to the bus
Response parts with thought: true are emitted as thinking.step events (Source: nexus.llm.gemini, Phase: reasoning) and are excluded from LLMResponse.Content. Every thinking.step event lands in the per-session journal automatically — read it via journal.Writer.SubscribeProjection (live) or journal.ProjectFile (post-mortem). usageMetadata.thoughtsTokenCount is mirrored into events.Usage.ReasoningTokens.
Multimodal
events.Message.Parts (text, image, audio, video, file) is serialized into Gemini parts. Inline payloads up to 18 MB use inlineData with base64 bytes; larger payloads must be uploaded via the Files API and referenced by URI (fileData.fileUri). Provider falls back to Content only when Parts is empty, so existing text-only callers are unaffected.
Code Execution
Set code_execution: true to advertise Gemini’s built-in code execution tool. Response parts of type executableCode and codeExecutionResult are dual-emitted: appended to Content as fenced markdown blocks for any UI, and emitted as tool.invoke / tool.result events under the synthetic name _gemini_code_execution so observers see them as ordinary tool activity.
Prompt Caching
nexus.llm.gemini:
cache:
enabled: true
min_tokens: 32768
ttl: "1h"
max_entries: 64
The provider computes a deterministic hash of the cache-eligible prefix (model + system instruction + tool declarations + the leading run of contents up to the first tool exchange). When a hit is present in the in-memory LRU, cachedContent is set on the request and only the trailing delta is sent. Cache entries are populated explicitly via Plugin.createCachedContent; the auto-populate path is intentionally read-only in this initial release. usageMetadata.cachedContentTokenCount flows into events.Usage.CachedTokens and the cost calculation applies the cached-input discount (default 25%, override via pricing.<model>.cached_ratio).
Cost Tracking
Embedded defaults cover the 1.5, 2.0, and 2.5 model lines (single tier — the 2.5-pro >200k tier is not modeled; override via config when high-context billing matters):
nexus.llm.gemini:
pricing:
gemini-2.5-pro:
input_per_million: 2.50 # >200k tier
output_per_million: 15.0
cached_ratio: 0.25
ReasoningTokens are billed at the output rate. CachedTokens are billed at input_per_million * cached_ratio; remaining prompt tokens at the standard input rate.
Retry Logic
Same retry surface as the OpenAI / Anthropic providers (constant, linear, exponential, exponential_jitter). Defaults retry 429 / 500 / 502 / 503 / 504. Honors Retry-After on 429 responses.
Request Cancellation
Subscribes to cancel.active at priority 5. When a cancellation arrives, the in-flight HTTP request context is cancelled.
Fallback Hook
Errors are emitted on the vetoable before:core.error event before the terminal core.error, letting nexus.provider.fallback swap to another provider in the chain.
Example: api-key
core:
models:
default: balanced
balanced:
provider: nexus.llm.gemini
model: gemini-2.5-flash
max_tokens: 8192
plugins:
active:
- nexus.io.tui
- nexus.llm.gemini
- nexus.agent.react
nexus.llm.gemini:
api_key_env: GEMINI_API_KEY
Example: Vertex AI
plugins:
nexus.llm.gemini:
auth: vertex
location: us-central1
project_id: my-gcp-project
service_account_json: ~/.config/gcloud/keys/nexus-sa.json
HTTP Configuration
- Timeout: 5 minutes per request
- Public endpoint:
https://generativelanguage.googleapis.com/v1beta - Vertex endpoint:
https://{location}-aiplatform.googleapis.com/v1
Search Grounding
A separate plugin, nexus.search.gemini_native, advertises the search.provider capability and answers search.request events using Gemini’s google_search tool. Use it independently of the LLM provider — for example, run Anthropic for chat and Gemini for grounded search lookups.
Provider Fallback
Plugin ID: nexus.provider.fallback
Automatic provider failover when the primary LLM provider returns a non-retryable error or exhausts its retry budget. Agents remain unaware — fallback is transparent at the provider layer.
Event Subscriptions
| Event | Priority | Purpose |
|---|---|---|
before:llm.request | 3 | Inject fallback tracking metadata |
before:core.error | 5 | Intercept provider errors for fallback |
Event Emissions
| Event | Payload | Purpose |
|---|---|---|
io.output.clear | (none) | Wipe partial streamed content from UI |
provider.fallback | ProviderFallback | Notify UI of provider switch |
llm.request | LLMRequest | Re-emit request targeting fallback provider |
Configuration
No plugin-specific config. Fallback chains are defined in core.models:
core:
models:
balanced:
- provider: nexus.llm.anthropic
model: claude-sonnet-4-20250514
max_tokens: 8192
- provider: nexus.llm.openai
model: gpt-4o
max_tokens: 8192
plugins:
active:
- nexus.llm.anthropic
- nexus.llm.openai
- nexus.provider.fallback # required for fallback to work
Trigger Conditions
Fallback occurs when:
- Non-retryable errors: 4xx status codes (except 429), malformed responses, auth failures
- Retries exhausted: Provider’s own retry logic (429, 5xx backoff) has hit
max_retriesand given up
Fallback does not occur for: cancellation, context deadline, or errors that the provider’s built-in retry is still handling.
Streaming Partial Failure
If a provider fails mid-stream:
- Emits
io.output.clearto wipe partial streamed content from UI - Emits
provider.fallbacknotification so user sees “Switching to [provider]…” - Re-emits
llm.requesttargeting next provider in chain
Clean restart — splicing output from two different models produces incoherent text.
What Stays Unchanged
- Agent plugins — unaware of fallback. Emit
llm.request, receivellm.response. - Provider routing — providers still check
cfg.Provider != pluginIDand skip non-matching requests. - Single-provider configs — backward compatible, parsed as chain of length 1.
- Gate plugins — operate on
before:llm.requestas before. Fallback re-emits go through same gate checks.
Request Metadata
The fallback plugin injects tracking metadata into requests for roles with fallback chains:
| Key | Type | Purpose |
|---|---|---|
_fallback_id | string | Unique ID for this fallback sequence |
_fallback_attempt | int | Current index in the chain (0 = primary) |
_fallback_role | string | Role name being resolved |
_target_provider | string | Plugin ID of the target provider (set on re-emission) |
This metadata flows through the provider and back via ErrorInfo.RequestMeta on failure, enabling the plugin to correlate errors with their original requests.
Provider Fanout
Plugin ID: nexus.provider.fanout
Sends a single LLM request to multiple providers in parallel and collects their responses. Supports configurable selection strategies to determine the final response. Agents remain unaware — fanout is transparent at the provider layer.
Event Subscriptions
| Event | Priority | Purpose |
|---|---|---|
before:llm.request | 2 | Detect fanout roles, veto original, dispatch parallel requests |
llm.response | 1 | Collect individual provider responses |
before:core.error | 4 | Absorb provider errors within fanout sequences |
Event Emissions
| Event | Payload | Purpose |
|---|---|---|
provider.fanout.start | ProviderFanoutStart | Fanout initiated |
provider.fanout.response | ProviderFanoutResponse | Individual provider responded (success or failure) |
provider.fanout.complete | ProviderFanoutComplete | All responses collected or deadline reached |
llm.request | LLMRequest | Per-provider targeted requests (via EmitAsync) |
llm.response | LLMResponse | Combined final response with Alternatives |
Configuration
core:
models:
compare:
fanout: true
providers:
- provider: nexus.llm.anthropic
model: claude-sonnet-4-20250514
max_tokens: 4096
- provider: nexus.llm.openai
model: gpt-4o
max_tokens: 4096
plugins:
active:
- nexus.llm.anthropic
- nexus.llm.openai
- nexus.provider.fanout # required for fanout to work
nexus.provider.fanout:
strategy: all # selection strategy (default: "all")
deadline_ms: 30000 # max wait time in milliseconds (default: 30000)
Plugin Config
| Key | Type | Default | Description |
|---|---|---|---|
strategy | string | "all" | Selection strategy: all, llm_judge, heuristic, user |
deadline_ms | int | 30000 | Maximum time to wait for all providers (milliseconds) |
Fanout Role Config
Fanout roles are defined in core.models as maps with fanout: true and a providers list:
core:
models:
compare:
fanout: true
providers:
- provider: nexus.llm.anthropic
model: claude-sonnet-4-20250514
- provider: nexus.llm.openai
model: gpt-4o
This is distinct from fallback chains (which are YAML arrays). Fanout roles dispatch to all providers simultaneously; fallback chains try providers sequentially on error.
Selection Strategies
| Strategy | Description | Status |
|---|---|---|
all | Return all responses. First response is primary, rest in Alternatives. | Implemented |
llm_judge | Separate LLM call picks best response. | Planned |
heuristic | Rule-based selection (response length, latency, confidence). | Planned |
user | Surface all to user, let them pick. | Planned |
Response Shape
The combined response uses the Alternatives field on LLMResponse:
- Primary fields (
Content,Model,ToolCalls, etc.) contain the first successful response Alternativescontains remaining successful responses as fullLLMResponsestructsUsageandCostUSDare aggregated across all responsesMetadata["_fanout"] = trueindicates this was a fanout responseMetadata["_fanout_id"]contains the fanout sequence ID
Deadline Handling
If any provider doesn’t respond within deadline_ms, the fanout finalizes with whatever responses have arrived. Failed or timed-out providers are counted in ProviderFanoutComplete.Failed.
If all providers fail or time out, the plugin emits a core.error instead of an llm.response.
Streaming
Fanout disables streaming for individual provider requests (Stream: false). Complete responses are required to support selection strategies and the Alternatives response shape.
Request Metadata
The fanout plugin injects tracking metadata into per-provider requests:
| Key | Type | Purpose |
|---|---|---|
_fanout_id | string | Unique ID for this fanout sequence |
_target_provider | string | Plugin ID of the target provider |
_fanout_provider | string | Plugin ID that this leg targets |
_source | string | Set to nexus.provider.fanout so agents skip individual responses |
Interaction with Other Plugins
- Fallback: Fanout and fallback serve different purposes. Fallback is sequential error recovery; fanout is parallel dispatch. A fanout leg that fails is simply marked as failed — no per-leg fallback.
- Gates: The initial
before:llm.requestpasses through gates normally. Per-provider requests emitted by fanout are directllm.requestevents (not vetoable). - Mock mode: Test IO mock responses work with fanout — each fanout leg gets the next mock response in sequence.
Tool Plugins
Tool plugins give the agent capabilities to interact with the outside world. Each tool registers itself via the event bus — agents discover tools automatically.
Available Tools
| Plugin | ID | Tool Name | Description |
|---|---|---|---|
| Shell | nexus.tool.shell | shell | Execute shell commands |
| File I/O | nexus.tool.file | read_file, write_file, list_files | Read, write, and list files |
| PDF Reader | nexus.tool.pdf | read_pdf | Extract text from PDF files |
| File Opener | nexus.tool.opener | open_path | Open files in the OS default app |
| Human-in-the-Loop | nexus.control.hitl | ask_user | Ask the user a question or approve an action (multi-choice supported) |
| Code Exec | nexus.tool.code_exec | run_code | Run a Go script that orchestrates multiple tool calls in one turn |
| Knowledge Search | nexus.tool.knowledge_search | knowledge_search | Semantic search over configured RAG namespaces; returns top-k chunks with source paths for citation |
How Tools Work
- Tool plugin initializes and emits
tool.registerwith its tool definition (name, description, JSON Schema parameters) - The agent collects registered tools and includes them in
llm.request - When the LLM responds with a tool call, the agent emits
before:tool.invoke(vetoable for approval) - If not vetoed, the agent emits
tool.invoke - The tool plugin handles the invocation and emits
before:tool.result(vetoable — gates can inspect/block results) - If not vetoed, the tool plugin emits
tool.result - The agent feeds the result back to the LLM
Tool Registration Event
Each tool emits a tool.register event with a ToolDef:
type ToolDef struct {
Name string // Tool name the LLM will use
Description string // Description shown to the LLM
Parameters string // JSON Schema for parameters
}
Approval Flow
The before:tool.invoke event is vetoable. I/O plugins (TUI, Browser) can intercept this to show an approval dialog, especially for high-risk operations like shell commands.
Shell Tool
Executes shell commands in a controlled environment with optional command allowlisting and sandboxing.
Details
| ID | nexus.tool.shell |
| Tool Name | shell |
| Dependencies | None |
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
working_dir | string | (session files dir) | Working directory for executions. |
timeout | duration | 30s | Maximum execution time per command. |
sandbox.backend | string | host | Sandbox tier (host; future: gvisor, firecracker, landlock). |
sandbox.allowed_commands | string[] | (none — all allowed) | Whitelist of base command names. |
sandbox.path_dirs | string[] | (none) | Directories prepended to PATH. |
sandbox.env_restrict | bool | false | Strip sensitive env vars before execution. |
sandbox.timeout | duration | 30s | Per-command default; top-level timeout wins per-call. |
Tool Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
command | string | Yes | The shell command to execute |
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
tool.invoke | 50 | Handles shell command execution |
Emits
| Event | When |
|---|---|
tool.result | Command output (stdout + stderr) |
tool.register | Registers the shell tool at boot |
core.error | Execution errors |
Security Features
Command Allowlist
When sandbox.allowed_commands is set, only commands whose base name matches the list are permitted:
nexus.tool.shell:
sandbox:
allowed_commands: ["ls", "cat", "grep", "find", "git", "make"]
Attempting to run a command not in the list returns an error to the agent.
Sandbox Environment Restriction
When sandbox.env_restrict: true, sensitive environment variables (AWS, Google, Azure, Anthropic API keys) are stripped before command execution.
Command History
All executed commands are logged to plugins/nexus.tool.shell/history.txt in the session directory.
Example Configurations
Minimal (full access)
nexus.tool.shell:
timeout: 30s
Coding assistant
nexus.tool.shell:
timeout: 30s
sandbox:
allowed_commands: ["go", "git", "ls", "cat", "grep", "find", "mkdir", "rm", "cp", "mv", "make", "docker", "npm", "cargo", "python"]
env_restrict: true
Read-only exploration
nexus.tool.shell:
timeout: 10s
sandbox:
allowed_commands: ["ls", "cat", "grep", "find", "head", "tail", "wc"]
env_restrict: true
File I/O Tool
Provides file read, write, and listing capabilities with path traversal protection.
Details
| ID | nexus.tool.file |
| Tool Names | read_file, write_file, list_files |
| Dependencies | None |
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
base_dir | string | (session files dir) | Root directory for file operations. All paths are resolved relative to this. |
allow_external_writes | bool | false | When false, write_file always writes to the session files directory regardless of base_dir. When true, write_file can write anywhere within base_dir. |
Tools
read_file
| Parameter | Type | Required | Description |
|---|---|---|---|
path | string | Yes | Path to the file to read |
offset | number | No | Byte offset to start reading from (default 0) |
length | number | No | Maximum bytes to read (default 4096) |
Reads up to length bytes starting at offset. Returns a JSON object with content, bytes_read, offset, and total_size so callers can page through files larger than the chunk size.
write_file
| Parameter | Type | Required | Description |
|---|---|---|---|
path | string | Yes | Path to write to |
content | string | Yes | Content to write |
Creates or overwrites the file. Emits session.file.created or session.file.updated.
list_files
| Parameter | Type | Required | Description |
|---|---|---|---|
path | string | Yes | Directory to list |
pattern | string | No | Glob pattern to filter results |
Returns a listing with file names and sizes.
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
tool.invoke | 50 | Handles file operations |
Emits
| Event | When |
|---|---|
tool.result | Operation result |
tool.register | Registers all three tools at boot |
core.error | File operation errors |
session.file.created | New file written |
session.file.updated | Existing file overwritten |
Security
All paths are resolved relative to base_dir. Path traversal attempts (e.g., ../../etc/passwd) are blocked.
By default, write_file is restricted to the session files directory even when a custom base_dir is configured. This prevents the agent from modifying files in the working directory unless explicitly opted in via allow_external_writes: true.
Example Configuration
# Use session files directory (default)
nexus.tool.file: {}
# Use a specific directory (reads from workspace, writes to session files)
nexus.tool.file:
base_dir: /home/user/workspace
# Allow writes to the workspace directory
nexus.tool.file:
base_dir: /home/user/workspace
allow_external_writes: true
PDF Reader Tool
Extracts text from PDF files using poppler-utils (pdftotext and pdfinfo).
Details
| ID | nexus.tool.pdf |
| Tool Name | read_pdf |
| Dependencies | None |
| Requires | poppler-utils installed on the system |
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
timeout | duration | 30s | Max time for PDF processing |
pdftotext_bin | string | pdftotext | Path to the pdftotext binary |
pdfinfo_bin | string | pdfinfo | Path to the pdfinfo binary |
save_to_session | bool | false | Save extracted text to session files |
save_file_name | string | (auto) | Filename for saved text |
Tool Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
path | string | Yes | Path to the PDF file |
first_page | int | No | First page to extract (1-based) |
last_page | int | No | Last page to extract |
layout | bool | No | Preserve original layout |
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
tool.invoke | 50 | Handles PDF read requests |
Emits
| Event | When |
|---|---|
tool.result | Extracted text |
tool.register | Registers the read_pdf tool at boot |
Prerequisites
Install poppler-utils:
# macOS
brew install poppler
# Ubuntu/Debian
sudo apt-get install poppler-utils
# Arch Linux
sudo pacman -S poppler
Example Configuration
nexus.tool.pdf:
timeout: 60s
save_to_session: true
File Opener Tool
Opens files or URLs using the OS-native default application (e.g., open on macOS, xdg-open on Linux).
Details
| ID | nexus.tool.opener |
| Tool Name | open_path |
| Dependencies | None |
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
open_cmd | string | (auto-detected) | Override the open command. Auto-detected: open (macOS), xdg-open (Linux), cmd /c start (Windows) |
timeout | duration | 10s | Max time to wait for the open command |
Tool Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
path | string | Yes | File path or URL to open |
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
tool.invoke | 50 | Handles open requests |
Emits
| Event | When |
|---|---|
tool.result | Confirmation of open action |
tool.register | Registers the open_path tool at boot |
Example Configuration
nexus.tool.opener:
timeout: 10s
Code Exec Tool (Programmatic Tool Calling)
Lets the LLM orchestrate multiple tool calls in a single turn by writing a short Go script. The script runs in an embedded Yaegi interpreter and dispatches inner tool calls through the real event bus, so every existing gate still fires.
Details
| ID | nexus.tool.code_exec |
| Tool Name | run_code |
| Dependencies | None (uses current tool registry at invocation time) |
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
timeout_seconds | int | 30 | Wall-clock limit for a script, propagated via context.Context |
max_output_bytes | int | 65536 | Stdout/stderr cap per script; excess is silently dropped and truncated=true is returned |
max_workers | int | runtime.NumCPU() | Concurrency ceiling for the parallel.* primitives — shared across Map/ForEach/All within a single script invocation |
allowed_packages | string[] | see below | Go stdlib whitelist. Default covers pure compute: fmt, strings, strconv, bytes, regexp, unicode*, encoding/* (json/base64/hex/csv/xml/pem/binary), crypto/* (sha/md5/hmac/rand/subtle), math, math/big, math/rand, math/rand/v2, math/bits, sort, container/* (heap/list/ring), hash/* (crc32/crc64/fnv/adler32), sync, sync/atomic, errors, time, context, io, bufio. Omitted: anything touching filesystem, network, OS processes, reflection, unsafe memory. Also omitted: slices/maps (Yaegi lacks full generics support). |
persist_scripts | bool | true | Write script.go, stdout.txt, result.json, error.txt to the session workspace |
reject_goroutines | bool | true | Reject scripts containing go statements at the AST layer |
Script Contract
The LLM passes a script argument containing a complete Go source file:
package main
import (
"context"
"fmt"
"tools"
)
func Run(ctx context.Context) (any, error) {
r, err := tools.Shell(tools.ShellArgs{Command: "ls"})
if err != nil {
return nil, err
}
fmt.Println("found:", r.Output)
return map[string]string{"listing": r.Output}, nil
}
Hard rules enforced before Yaegi ever sees the source:
- Package must be
main. - Must declare
func Run(ctx context.Context) (any, error)exactly. - No
gostatements (phase 1). - Imports restricted to
allowed_packagesplustools,parallel, andskills/<name>for each currently-active skill.
Violations surface as a structured error in the tool result. The script never executes.
Typed Tool Bindings
At every run_code invocation the plugin snapshots the current tool registry and builds a fresh tools package for Yaegi:
- JSON Schema types map to Go:
string→string,integer→int64,number→float64,boolean→bool,array→[]T,object→struct. - Each tool
foo_barbecomestools.FooBar(args tools.FooBarArgs) (<Result>, error). - Return type depends on whether the tool declared
OutputSchema:- With schema →
tools.FooBarResult, a struct generated from the schema. Fields are populated fromToolResult.OutputStructured(preferred) or parsed from JSON inOutputas a fallback. - Without schema → the fixed
tools.Resultstruct ({Output, Error, OutputFile string}). Scripts parseOutputthemselves.
- With schema →
tools.Resultis always exported so helper functions can handle both shapes.- Gate vetoes (
before:tool.invoke) surface as a Goerroron thetools.*call. - Outer
run_codecall is excluded from the binding — scripts cannot recursively invoke themselves.
Declaring an OutputSchema
Tool plugins opt in by adding OutputSchema to their ToolDef and populating ToolResult.OutputStructured:
_ = p.bus.Emit("tool.register", events.ToolDef{
Name: "shell",
// ...
OutputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"stdout": map[string]any{"type": "string"},
"stderr": map[string]any{"type": "string"},
"exit_code": map[string]any{"type": "integer"},
},
"required": []string{"stdout", "stderr", "exit_code"},
},
})
// ...then in the tool's handler:
result := events.ToolResult{
ID: tc.ID,
Name: tc.Name,
Output: humanReadableSummary,
OutputStructured: map[string]any{
"stdout": stdoutStr,
"stderr": stderrStr,
"exit_code": exitCode,
},
// ...
}
Scripts then see the typed shape:
r, err := tools.Shell(tools.ShellArgs{Command: "ls"})
if err != nil {
return nil, err
}
fmt.Println(r.Stdout, r.Stderr, r.ExitCode)
The existing Output string still flows through the bus unchanged — non-script consumers (LLM conversation history, logging, etc.) see the same human-readable text as before. Tools without schemas continue to work as they always did.
Skill Helpers
Skills may ship .go files alongside SKILL.md. On skill.loaded the plugin reads every non-test .go in the skill dir, rewrites the package declaration to a sanitised name, and stages the result into a per-invocation GOPATH. Scripts import the package as skills/<skill_name>:
import helpers "skills/math-helpers"
func Run(ctx context.Context) (any, error) {
return helpers.Double(21), nil
}
Skills are loaded on skill.loaded and removed on skill.deactivate. Cross-skill imports are not supported in phase 1.
Parallel Primitives
Scripts can parallelize work (tool fan-out or pure compute) via the parallel package. A host-side worker pool bounded by max_workers backs all three primitives; they share the same pool within a single run_code call.
import (
"context"
"parallel"
"tools"
)
func Run(ctx context.Context) (any, error) {
urls := []string{"https://a.example", "https://b.example", "https://c.example"}
// Map: ordered results, first-error-cancels-the-rest.
results, err := parallel.Map(ctx, urls, func(ctx context.Context, u string) (string, error) {
r, err := tools.Fetch(tools.FetchArgs{URL: u})
if err != nil { return "", err }
return r.Body, nil
})
if err != nil { return nil, err }
// results is `any` (Yaegi has no generics); cast to the element slice type.
bodies := results.([]string)
return bodies, nil
}
Semantics (all three):
- First non-nil error wins, cancels the derived
context.Context, waits for in-flight callbacks to observe cancellation, then returns the wrapped error. Mappreserves input order in the output slice;ForEachdiscards results but otherwise identical;Allruns N heterogeneousfunc(ctx context.Context) errorvalues.- Worker pool size =
max_workers(defaultruntime.NumCPU()). Scripts never spawn goroutines directly — thegokeyword is still rejected at the AST layer. - Callback panics are recovered and surface as an error on the outer call.
Expected signatures:
| Primitive | Callback shape |
|---|---|
parallel.Map(ctx, items, fn) | func(ctx context.Context, item T) (R, error) |
parallel.ForEach(ctx, items, fn) | func(ctx context.Context, item T) error |
parallel.All(ctx, fns...) | func(ctx context.Context) error |
Caveat: tools invoked from inside parallel callbacks execute concurrently on the bus. Host tool plugins must tolerate concurrent tool.invoke delivery. The built-in Nexus tools (shell, file, etc.) already do since they rely on OS-level isolation or hold no shared mutable state.
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
tool.invoke | 50 | Handles the outer run_code call |
tool.result | 50 | Routes inner tool results back to the waiting script |
tool.register | 50 | Builds the type catalogue used to generate tools.* bindings |
skill.loaded | 50 | Scans and stages skill helper source files |
skill.deactivate | 50 | Removes skill helpers from the active set |
Emits
| Event | When |
|---|---|
tool.register | Registers the run_code tool at boot |
before:tool.invoke / tool.invoke | For every inner tool call dispatched by a script |
before:tool.result / tool.result | For the outer run_code call |
code.exec.request | Just before script execution (carries the raw script + imports + active skills) |
code.exec.stdout | For every flushed stdout chunk while the script runs; the final chunk has Final=true and carries the truncation flag if applicable |
code.exec.result | When the script has finished, errored out, or timed out — always arrives after the final code.exec.stdout chunk for the same CallID |
Stdout Streaming
The interpreter’s stdout and stderr are wired to a chunking writer that emits code.exec.stdout events while the script is still running. Flush triggers:
- Any newline in the pending buffer — everything up to the newline flushes.
- Pending buffer crosses a 512-byte threshold — forces out long lines that would otherwise wait for a newline.
- Script finish — any residual tail is flushed as the
Finalchunk.
The aggregated Output field on the terminal code.exec.result still contains the full stdout (capped at max_output_bytes), so non-streaming consumers keep working without changes. IO plugins that want live output subscribe to code.exec.stdout instead.
Sandboxing Layers
Defense in depth; each layer enforces a different concern:
- Import allowlist — AST-level rejection of any import not on
allowed_packages∪{tools}∪ activeskills/<name>. - AST
go-stmt rejection — scripts cannot spawn goroutines. - Wall-clock timeout — script runs under
context.WithTimeout;tools.*shims observe cancellation. - Stdout byte cap — capped writer drops excess and flags truncation.
- No heap/CPU cap — documented limitation; operators rely on OS-level process limits if a stronger guarantee is required.
Session Persistence
When persist_scripts is enabled each call writes to plugins/nexus.tool.code_exec/<callID>/:
plugins/nexus.tool.code_exec/<callID>/
script.go # exact source the LLM submitted
stdout.txt # captured stdout (capped)
result.json # JSON-marshaled Run() return value
error.txt # only present if the script failed
Example Config
plugins:
active:
- nexus.tool.shell
- nexus.tool.file
- nexus.tool.code_exec
nexus.tool.code_exec:
timeout_seconds: 30
max_output_bytes: 65536
max_workers: 8
persist_scripts: true
reject_goroutines: true
Non-Goals (phase 1)
- Goroutines /
gokeyword - Provider-native programmatic tool calling (Anthropic
allowed_callers) - Cross-skill imports
- CPU / memory resource limits beyond the wall-clock timeout
- Script REPL or debugger
Knowledge Search Tool
Plugin ID: nexus.tool.knowledge_search
LLM-facing tool that searches configured knowledge-base namespaces using semantic (vector) similarity. Named to parallel web_search — agents pick knowledge_search for configured knowledge and web_search for the open web.
For an end-to-end example wiring this tool into an agent, see the RAG guide.
Capabilities
Requires both:
embeddings.provider— to embed the queryvector.store— to run the similarity search
Both auto-activate via Requires() if not in the user’s active list.
Configuration
nexus.tool.knowledge_search:
namespaces: [kb, project-docs] # required allow-list
default_namespaces: [kb] # subset used when LLM omits the arg
top_k: 5
include_metadata: true
# tool_name: knowledge_search # rename the LLM-visible tool if you want
| Key | Type | Default | Description |
|---|---|---|---|
namespaces | list of string | (required, non-empty) | Allow-list of namespaces the tool can query. |
default_namespaces | list of string | (equal to namespaces) | Subset used when the LLM doesn’t pass namespaces arg. |
top_k | int | 5 | Default max results. LLM may override per call up to maxTopK = 50. |
include_metadata | bool | true | Whether to return the full metadata map alongside structured fields. |
tool_name | string | knowledge_search | LLM-visible tool name. Override if it collides with another tool. |
The active embeddings.provider plugin owns the model choice. Configure
it on that plugin (e.g. nexus.embeddings.openai.model); the model used
to ingest must match the model used to query.
If namespaces is empty or unset, boot fails — the tool refuses to register without a defined allow-list.
Tool surface
| Argument | Type | Required | Notes |
|---|---|---|---|
query | string | yes | The semantic query. Phrase it as you would a search. |
namespaces | array of string | no | Subset of allowed namespaces. Filtered through the allow-list; unknown names silently dropped. |
k | int | no | Max results. Defaults to top_k, capped at maxTopK = 50. |
Output
{
"query": "what is the plugin lifecycle?",
"results": [
{
"rank": 1,
"namespace": "project-docs",
"similarity": 0.84,
"source": "docs/architecture/plugin-system.md",
"chunk_idx": "2",
"content": "Each plugin goes through three lifecycle phases: Init, Ready, and Shutdown...",
"metadata": {
"source": "docs/architecture/plugin-system.md",
"chunk_idx": "2",
"chunk_size": "768"
}
},
...
]
}
Fields:
| Field | Description |
|---|---|
rank | 1-based ranking by similarity, post-merge across namespaces. |
namespace | Which namespace the hit came from. |
similarity | Cosine similarity in [-1, 1]. Higher is better. |
source | Lifted from metadata.source for convenient citation. |
chunk_idx | Lifted from metadata.chunk_idx. |
content | The original chunk text, suitable for quoting. |
metadata | Full metadata map (only when include_metadata: true). |
Behavior
- Trim and validate the query. Empty queries return an error result (the LLM sees
Error: "query argument required"). - Resolve the namespace set: LLM-supplied names ∩ allow-list, falling back to
default_namespaceswhen the LLM didn’t specify. - Embed the query once via the
embeddings.provider. - Fan out one
vector.queryper namespace, all with the same vector andK = k. - Merge all hits, sort by similarity descending, truncate to
k. - Format as JSON and emit
tool.result.
Per-namespace failures log a warning but don’t fail the call — partial results are better than zero.
Choosing top_k
Higher = more context for the LLM, lower = less noise. Typical starting points:
| Use case | top_k |
|---|---|
| Strict citation, narrow questions | 3 |
| General knowledge base | 5 (default) |
| Open-ended research | 10 |
Don’t exceed the LLM’s context budget. With chunk size 1000 and top_k=10, the tool can return ~10 KB of JSON per call — fine for claude-sonnet-4, tight for smaller models.
Prompting the LLM
By default, the LLM may not realize the tool is the right thing to call for factual questions about your knowledge base. Add a hint to the system prompt — configs/rag.yaml does this:
nexus.agent.react:
system_prompt: |
You are a helpful assistant with access to a knowledge base. When the user
asks a factual question, use the knowledge_search tool first to pull
supporting chunks from the knowledge base, then cite sources by file path.
Adjust this for your domain. Be specific about what the knowledge base contains so the LLM knows when to reach for the tool.
Events
| Event | Direction | Payload |
|---|---|---|
tool.invoke | Catalog → plugin | events.ToolCall |
embeddings.request | Plugin → bus | *EmbeddingsRequest |
vector.query | Plugin → bus | *VectorQuery |
before:tool.result | Plugin → bus (vetoable) | *events.ToolResult |
tool.result | Plugin → bus | events.ToolResult |
tool.register | Plugin → bus (Ready) | events.ToolDef |
Gate interaction
Goes through the standard vetoable before:tool.result hook, so existing gates apply unchanged: nexus.gate.content_safety, nexus.gate.output_length, nexus.gate.tool_filter, etc.
To restrict which agents can call this tool, use nexus.gate.tool_filter per-profile rather than maintaining multiple plugin instances.
Errors
at least one namespace must be configured under 'namespaces'at boot — the allow-list is required and must be non-empty.query argument requiredin tool output — the LLM called without aquery.embed query: ...— the embeddings provider returned an error. Check the provider’s log line for the underlying cause.no valid namespaces selected— every namespace the LLM passed was filtered out by the allow-list. Either widen the allow-list or fix the system prompt so the LLM only requests valid names.
Memory Plugins
Memory plugins fall into three groups by what they remember and how:
- Conversation history — the active message buffer that ships back to the LLM on every turn. One plugin advertising
memory.historyis active at a time. - Compaction — summarizes older messages so the conversation stays within the context window.
- Cross-session memory — persists knowledge between sessions. Two flavors: structured (key-addressed notes) and semantic (embedding-addressed recall).
Available Memory Plugins
| Plugin | ID | Capability | Purpose |
|---|---|---|---|
| Simple History | nexus.memory.simple | memory.history | Unbounded append-only history; reference/test impl |
| Capped History | nexus.memory.capped | memory.history | Sliding window with JSONL persistence (default memory.history provider) |
| Summary-Buffer History | nexus.memory.summary_buffer | memory.history + memory.compaction | Keeps recent N verbatim, LLM-summarizes older inline |
| Context Compaction | nexus.memory.compaction | memory.compaction | External coordinator that summarizes old messages and emits memory.compacted |
| Long-Term Memory | nexus.memory.longterm | memory.longterm | Cross-session structured notes: file-per-entry, YAML frontmatter + markdown, key-addressed, LLM tools (memory_read, memory_write, memory_list, memory_delete) |
| Vector Memory | nexus.memory.vector | memory.vector | Cross-session semantic recall: embedding-addressed, automatic on every turn, auto-stores compaction summaries |
How Memory Works
Within a session, conversation memory plugins listen to I/O and tool events, building up a message buffer. When agents need history (for llm.request), they query the active memory.history provider via memory.history.query. When the buffer would exceed the context window, the compaction plugin (if active) summarizes older messages and emits memory.compacted so any history buffer that supports adoption can swap in the compacted view.
Across sessions, two independent plugins offer different recall styles:
nexus.memory.longtermis the agent’s filing cabinet. The LLM deliberately writes structured notes by key; on each session start, an index of titles + tags is injected into the system prompt and the agent reads full content on demand.nexus.memory.vectoris the agent’s associative memory. On every user input, the plugin embeds the message and pulls semantically-similar past content into the prompt automatically. It also auto-stores compaction summaries so trimmed context stays recallable.
The two coexist and don’t share storage — pick one, or activate both for complementary recall styles. They’re documented side-by-side in Vector Memory → vs long-term memory. For the full RAG context, see the RAG guide.
Simple History
Minimal reference implementation of memory.history: an unbounded,
in-memory slice with no persistence. Useful for tests, demos, and
short-lived sessions where the sliding-window/pair-safe machinery of
nexus.memory.capped adds no value.
Details
| ID | nexus.memory.simple |
| Capability | memory.history |
| Dependencies | None |
Configuration
No configuration required.
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
io.input | 10 | Records user messages |
llm.response | 10 | Records assistant responses (with ToolCalls populated) |
tool.invoke | 10 | Tracks ParentCallID filter set only; no message appended |
tool.result | 10 | Records tool role messages (unless internally dispatched) |
memory.history.query | 50 | Responds with the current buffer in LLM-native order |
memory.compacted | 50 | Replaces the buffer with the compacted message set |
Emits
None.
Behaviour
- Storage mirrors
events.Messageexactly so consumers feed the buffer directly into anLLMRequestwithout translation. - Internal tool calls (
ParentCallID != "") are filtered so the LLM never seestool_use_ids it didn’t generate — same invariant asnexus.memory.capped. llm.responseevents tagged withMetadata["_source"](planner replies, summariser replies) are ignored so only user-facing turns land in the buffer.- No persistence: history is lost on process exit.
When to Use
- Integration tests where the default
cappedplugin’s JSONL persistence is overhead. - Experimental agents where bounded history would confuse the outcome.
- Reference for building a new
memory.historyprovider.
Example Configuration
capabilities:
memory.history: nexus.memory.simple
plugins:
active:
- nexus.agent.react
- nexus.memory.simple
Capped Conversation History
Maintains a sliding window of conversation messages and persists them to the session as JSONL. Default provider of memory.history.
Details
| ID | nexus.memory.capped |
| Dependencies | None |
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
max_messages | int | 100 | Maximum messages to keep in the buffer |
persist | bool | true | Write messages to context/conversation.jsonl in the session |
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
io.input | 10 | Records user messages |
io.output | 10 | Records agent responses |
tool.invoke | 50 | Records tool calls |
tool.result | 50 | Records tool results |
memory.store | 50 | Explicit memory storage requests |
memory.query | 50 | Responds to history queries |
memory.compacted | 50 | Replaces history with compacted version |
Emits
| Event | When |
|---|---|
memory.result | Response to a memory.query |
Behavior
- Messages are stored in a rolling buffer of size
max_messages - Oldest messages are dropped when the buffer is full
- If
persist: true, each message is appended tocontext/conversation.jsonlas it arrives - On
memory.compacted, the buffer is replaced with the compacted messages
Querying History
Other plugins can query conversation history:
bus.Emit("memory.query", events.MemoryQuery{
Query: "", // Not filtered — returns all
Limit: 50, // Max messages to return
SessionID: "...", // Current session
})
// Listen for memory.result event with the messages
Example Configuration
nexus.memory.capped:
max_messages: 200
persist: true
Summary-Buffer History
Inline auto-compacting memory.history provider. Keeps the most recent
max_recent messages verbatim and replaces older messages with an
LLM-generated summary, emitted as a single system message at the head of
the buffer.
Unlike nexus.memory.compaction — which is an external
coordinator that emits memory.compacted for a separate history plugin
to adopt — this plugin serves memory.history directly, so the
summarised view is what the ReAct agent sees on the next request.
Details
| ID | nexus.memory.summary_buffer |
| Capabilities | memory.history, memory.compaction |
| Dependencies | An LLM provider addressable by the configured model_role. |
Note. Running this plugin alongside
nexus.memory.compactionis a misconfiguration: both advertisememory.compactionand the engine will emit a boot-time WARN naming the ambiguity. Pick one.
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
strategy | string | "message_count" | Trigger type: "message_count", "token_estimate", "turn_count" |
message_threshold | int | 50 | Buffer size that fires message_count strategy |
token_threshold | int | 30000 | Estimated tokens that fires token_estimate strategy |
turn_threshold | int | 10 | Turn count that fires turn_count strategy |
chars_per_token | float64 | 4.0 | Rough per-token char ratio for token estimation |
max_recent | int | 8 | Number of recent messages kept verbatim after summarisation |
model_role | string | "quick" | Role used to dispatch the summarisation LLM request |
prompt | string | built-in | Inline override of the summarisation system prompt. The default prompt is reasoning-preservation aware: it instructs the summariser to wrap segments in <summary topic="…" compressed-from-turns="…">…</summary> and end with a ## Preserved Kinds: trailer. Overriding loses both behaviours. |
prompt_file | string | unset | Path to a file containing the summarisation prompt (takes precedence over prompt) |
quality_retry | bool | false | Re-run the summariser once with a stricter prompt when the trailer omits any required preserved kind. Off by default for backwards compatibility. |
require_preserved_kinds | []string | ["decision","rationale"] | Trailer kinds whose presence is required when quality_retry: true. Allowed values: decision, rationale, error, next_step, technical_detail. |
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
io.input | 10 | Records user messages |
llm.response | 10 | Records assistant responses; absorbs summariser replies tagged _source=nexus.memory.summary_buffer |
tool.invoke | 10 | Tracks internal-call filter |
tool.result | 10 | Records tool role messages |
agent.turn.end | 5 | Increments turn counter (drives turn_count strategy) |
memory.history.query | 50 | Serves the current buffer in LLM-native order |
memory.compact.request | 10 | Forces a summarisation cycle (from context-window gate, etc.) |
Emits
| Event | When |
|---|---|
llm.request | When a summarisation trigger fires. Tagged Metadata["_source"] = "nexus.memory.summary_buffer" |
memory.compaction.triggered | Start of each summarisation cycle |
memory.compacted | End of each summarisation cycle, with the new buffer contents |
memory.summary_replaced | Span-level replacement event with from-turn range, original/summary token estimates, and the trailer-reported preserved kinds |
memory.curated | Stability descriptor for the cache-aware prompt builder (Layer: "summary_buffer", CacheInvalidates: true) |
io.status | UI status updates: "Summarising context..." / "idle" |
Behaviour
- Every append runs a threshold check (except
turn_count, which checks atagent.turn.end). - On trip, the plugin snapshots the buffer and computes a safe split —
protecting the trailing
max_recentmessages, shifting the boundary left when needed so an assistanttool_useand its matchingtool_result(s) are never separated. - The snapshot prefix is serialised into a transcript and sent to the
LLM via the configured
model_role. - On the summariser’s reply, the plugin collapses the prefix into a
single system message (
"## Prior Context (Summarised)\n\n...") and replaces the buffer with[summary, ...recent]. memory.compactedis emitted so any observer plugins (logger, UI) see the transition.
When to Use
- Long-running chat sessions where context window is the binding constraint.
- Agents that don’t benefit from verbatim history beyond the last few turns.
- Workloads that tolerate occasional latency spikes when a summarisation cycle runs mid-turn.
Example Configuration
capabilities:
memory.history: nexus.memory.summary_buffer
plugins:
active:
- nexus.agent.react
- nexus.memory.summary_buffer
nexus.memory.summary_buffer:
strategy: token_estimate
token_threshold: 20000
max_recent: 10
model_role: quick
Context Compaction
Monitors conversation size and automatically summarizes older messages using an LLM when thresholds are exceeded. This prevents the context window from growing unbounded.
Details
| ID | nexus.memory.compaction |
| Dependencies | None |
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
strategy | string | message_count | Trigger strategy: message_count, token_estimate, or turn_count |
message_threshold | int | 50 | Trigger when message count exceeds this (for message_count strategy) |
token_threshold | int | 30000 | Trigger when estimated tokens exceed this (for token_estimate strategy) |
turn_threshold | int | 10 | Trigger when turn count exceeds this (for turn_count strategy) |
chars_per_token | float | 4.0 | Characters per token estimate (for token_estimate strategy) |
model_role | string | quick | Model role for the compaction LLM call |
protect_recent | int | 4 | Number of most recent messages to keep verbatim (not summarized) |
compaction_prompt | string | (built-in) | Custom inline prompt for the summarization |
prompt_file | string | (none) | Path to a custom compaction prompt file |
persist | bool | true | Persist the live tracked log and archive snapshots to the session workspace |
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
io.input / io.output | 5 | Track messages for threshold checks |
tool.invoke / tool.result | 5 | Track tool messages |
agent.turn.end | 30 | Check thresholds after each turn |
llm.response | 5 | Track token usage |
Emits
| Event | When |
|---|---|
llm.request | Sends summarization request to LLM |
memory.compaction.triggered | Compaction started |
memory.compacted | Compaction complete — new message set |
thinking.step | Records the compaction reasoning |
io.status | Status updates during compaction |
Strategies
message_count
Triggers compaction when the total number of tracked messages exceeds message_threshold.
token_estimate
Estimates token count using chars_per_token and triggers when token_threshold is exceeded. This is approximate but avoids counting actual tokens.
turn_count
Triggers when the number of completed turns exceeds turn_threshold.
How Compaction Works
- Threshold is exceeded after a turn ends
- Plugin emits
memory.compaction.triggered - Archives the pre-compaction transcript to the session workspace
- Sends older messages (excluding the
protect_recentmost recent) to the LLM for summarization - Writes the returned summary as a sidecar next to the archive snapshot
- Rotates the live log so it now holds
[summary, ...protected] - Emits
memory.compactedwith the new message set - The active
memory.historyprovider replaces its buffer with the compacted version
Persisted Artifacts
When persist is enabled (the default) the plugin mirrors its tracked
state into the session workspace so every compaction cycle is fully
auditable:
plugins/nexus.memory.compaction/
├── current.jsonl # live log — mirrors in-memory state
└── archive/
├── 001-20260410-142301.jsonl # pre-compaction snapshot
├── 001-20260410-142301.meta.json # reason, strategy, counts
├── 001-20260410-142301.summary.md # LLM-produced summary
├── 002-20260410-151855.jsonl
├── 002-20260410-151855.meta.json
└── 002-20260410-151855.summary.md
current.jsonlis appended to on every tracked message and rewritten in place at the end of each compaction. After rotation it contains the summary system message followed by the protected recent messages, which is exactly what the plugin holds in memory.- Each
archive/NNN-*cycle is a three-file record: the raw transcript that was compacted, the metadata describing why, and the summary that replaced it. The numeric counter is recovered from the archive directory on startup so numbering survives session resumes. - On
Ready()the plugin preloadscurrent.jsonlso a resumed session continues from the exact state it left, with any prior compaction summaries already in place.
Example Configuration
nexus.memory.compaction:
strategy: token_estimate
token_threshold: 30000
model_role: quick
protect_recent: 6
With Custom Prompt
nexus.memory.compaction:
strategy: message_count
message_threshold: 40
model_role: quick
protect_recent: 4
prompt: |
Summarize the conversation so far in 200 words or less, preserving any
decisions, code changes, and unresolved questions.
Tool-Result Clearing
Live curator that drops the body of stale tool results from outgoing LLM
requests while keeping the call/result envelope. The model still sees that
the tool was invoked; the (often large) body is replaced inline with a
<tool_result … cleared="true" …/> marker.
This is the highest-leverage layer in Idea 30’s curation stack — for tool-heavy sessions, 50–80% of context bytes are tool-result bodies, most of which are dead weight by the time the next request fires.
Details
| ID | nexus.memory.tool_result_clear |
| Capabilities | none |
| Dependencies | none |
Operates at priority 12 on before:llm.request — after
nexus.discovery.progressive (priority 8) has shaped the tool list, then
re-shapes the outgoing message slice without touching the upstream
history buffer.
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Toggle the curator. |
age_turns | int | 5 | Clear tool results older than this many turns when also exceeding size_bytes_threshold. |
size_bytes_threshold | int | 1000 | Skip clearing for result bodies smaller than this many bytes. |
preserve_recent_kinds | []string | ["error","user_question"] | Result kinds never cleared regardless of age. |
drop_strategy | string | replace_with_envelope | replace_with_envelope keeps the call/result pair with a marker body (default). full_drop removes the message entirely; risks tool_use/tool_result pairing breakage. |
Heuristics
A tool result is cleared when any of the following hold:
- Age + size —
now_turn − call_turn ≥ age_turnsANDlen(result) ≥ size_bytes_threshold. - Subsequent-call — the same tool was invoked later with semantically equivalent arguments (canonical-JSON hash). Earlier results are redundant.
- Preserved kind exemption — results classified as
error(and any kind listed inpreserve_recent_kinds) are never cleared.
Once an ID is cleared, it stays cleared for the remainder of the session — subsequent requests re-replace deterministically without re-running the heuristic.
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
tool.invoke | 60 | Track call name, canonical args hash, and turn |
tool.result | 60 | Record result size and kind classification |
agent.turn.end | 60 | Increment internal turn counter |
before:llm.request | 12 | Mutate req.Messages, replace stale tool result bodies |
Emits
| Event | When |
|---|---|
memory.tool_result_cleared | Once per cleared call (carries tool_call_id, tool, original_size, cleared_at_turn, reason) |
memory.curated | Envelope event with stability descriptor (Layer: "tool_result_clear", CacheInvalidates: false) |
Replay Determinism
The clearing decision is heuristic, but every cleared call is recorded as
a memory.tool_result_cleared event. The durable journal (Idea 01)
captures these events so replay reproduces the same envelope without
re-running the heuristic.
Example Configuration
plugins:
active:
- nexus.agent.react
- nexus.memory.tool_result_clear
nexus.memory.tool_result_clear:
enabled: true
age_turns: 4
size_bytes_threshold: 512
preserve_recent_kinds: ["error", "user_question"]
drop_strategy: replace_with_envelope
Tool-Definition Pruner
Removes individual tool definitions from outgoing LLMRequest.Tools
when those tools have been idle past a turn threshold. Pairs with
nexus.discovery.progressive: progressive scopes
by class, this scopes per individual tool.
For sessions where the agent loaded 30 tool definitions in turn 4 but only uses 3 of them, the pruner keeps the unused 27 from paying tokens on every subsequent request.
Details
| ID | nexus.memory.tool_def_pruner |
| Capabilities | none |
| Dependencies | none |
Operates at priority 14 on before:llm.request — after both
nexus.discovery.progressive (8) and nexus.memory.tool_result_clear
(12) have shaped the request.
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Toggle the pruner. |
unused_turns_threshold | int | 6 | Drop a tool definition after this many consecutive turns without an invocation. |
never_prune | []string | ["discover","ask_user"] | Tool names exempt from pruning. |
Behaviour
- Subscribes to
tool.invoketo reset the per-tool last-used counter on every successful call. A pruned tool that the agent invokes again is un-pruned automatically — typically by going back throughdiscovery/progressive’sdiscovermeta-tool. - First sight of a tool registers it at the current turn rather than zero, so freshly-loaded tools aren’t pruned the moment they appear.
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
tool.invoke | 60 | Reset per-tool last-used counter |
agent.turn.end | 60 | Increment internal turn counter |
before:llm.request | 14 | Filter req.Tools |
Emits
| Event | When |
|---|---|
memory.tool_def_pruned | Per pruned tool (carries tool_id, last_used_turn, definition_size) |
memory.curated | Envelope event (Layer: "tool_def_pruner", CacheInvalidates: true — tool defs are part of the cached prefix) |
Example Configuration
plugins:
active:
- nexus.agent.react
- nexus.memory.tool_def_pruner
nexus.memory.tool_def_pruner:
unused_turns_threshold: 6
never_prune: ["discover", "ask_user", "memory_read"]
Topic-Aware Pruner
Detects topic boundaries in user input and emits
memory.topic_shift_detected. The pruner does not itself rewrite
history; it surfaces the shift so other plugins (summary buffer,
compaction) can react.
Details
| ID | nexus.memory.topic_pruner |
| Capabilities | none |
| Dependencies | none; uses embeddings.provider opportunistically when one is registered. |
Two signals are combined:
- Explicit phrase — substring match against a configurable list
(
"different question","new topic","let's move on", etc.). Cheap, deterministic. Lead-anchored phrases ("unrelated:","separately,") are taggeduser_explicit; substring matches are taggedphrase. - Embedding similarity — cosine similarity between the latest user
input and the rolling centroid of the current topic’s user inputs.
Below the configured threshold flags a shift. Runs only when an
embeddings.provideris active. Taggedembedding.
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Toggle the pruner. |
similarity_threshold | float | 0.55 | Cosine similarity below which a new user input flags a topic shift. Used only when an embeddings.provider is active. |
keep_last_topic_full | bool | true | Reserved for downstream consumers. |
explicit_phrases | []string | see code | Lowercase substrings that signal a topic shift. Replacing the list disables the defaults. |
Defaults for explicit_phrases:
"different question", "different topic", "new topic", "new question",
"let's move on", "moving on", "change of subject", "switching gears",
"unrelated:", "separately,", "on a different note"
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
io.input | 60 | Run the classifier on every user input |
agent.turn.end | 60 | Track the current turn for debouncing |
Emits
| Event | When |
|---|---|
memory.topic_shift_detected | Per detected shift (carries from_turn, to_turn, similarity, signal) |
memory.curated | Envelope event (Layer: "topic_pruner", CacheInvalidates: false) |
embeddings.request | Pointer-payload request when an embeddings.provider is active |
Same-turn duplicate signals are debounced — at most one shift event per turn boundary.
Replay Determinism
Topic-shift decisions are non-deterministic (heuristic + embeddings).
Each decision is journalled as a memory.topic_shift_detected event
(Idea 01) so replay reproduces the same boundaries.
Example Configuration
plugins:
active:
- nexus.agent.react
- nexus.memory.topic_pruner
- nexus.embeddings.openai # optional; enables the embedding signal
nexus.memory.topic_pruner:
enabled: true
similarity_threshold: 0.55
explicit_phrases:
- "different question"
- "new topic"
- "moving on"
Long-Term Memory
Plugin ID: nexus.memory.longterm
Cross-session memory persistence. Stores memories as individual markdown files with YAML frontmatter. Injects a lightweight index into the system prompt on boot and provides LLM tools for CRUD operations.
Configuration
nexus.memory.longterm:
# Where memory files live.
# Default: ~/.nexus/memory/ (CLI), ~/.nexus/agents/<agentID>/memory/ (desktop shell)
path: "~/.nexus/memory/"
# Memory scope: "agent" (per-agent isolated), "global" (shared across agents), or "both".
# "both" merges global + agent-scoped memories, with agent-scoped taking precedence on key conflicts.
scope: "agent"
# Inject memory index (titles + keys + tags) into system prompt on session start.
auto_load: true
# Instructions injected into system prompt telling the LLM when to
# proactively save memories mid-session. Empty string = only save when
# user explicitly asks (tools still available, just no prompting to use them).
auto_save_instructions: ""
# Agent ID for agent-scoped storage (injected automatically by desktop shell).
agent_id: ""
Scope Resolution
scope value | Paths searched | Write target |
|---|---|---|
agent | ~/.nexus/agents/<agentID>/memory/ | agent path |
global | ~/.nexus/memory/ | global path |
both | global path + agent path (agent wins conflicts) | agent path |
For CLI mode (no agent ID), agent and global behave identically — both use the configured path.
Storage Format
One file per memory entry at <memory_path>/<key>.md:
---
key: user_prefers_dark_mode
tags:
category: preference
domain: ui
created: 2026-04-10T14:30:00Z
updated: 2026-04-15T09:00:00Z
source_session: 20260410-143022
---
User strongly prefers dark mode across all interfaces.
They mentioned eye strain with light themes during long sessions.
Keys are sanitized to lowercase alphanumeric with hyphens and underscores, truncated to 128 characters.
LLM Tools
| Tool | Parameters | Description |
|---|---|---|
memory_write | key (string), content (string), tags (map, optional) | Create or update a memory entry |
memory_read | key (string) | Read full content of a memory entry |
memory_list | tags (map, optional) | List all memories, optionally filtered by tags (AND semantics) |
memory_delete | key (string) | Delete a memory entry |
System Prompt Injection
When auto_load: true, the plugin injects a section listing all available memories with key, tags, and a one-line preview (first line of content). Full content is retrieved on demand via memory_read.
Events
| Event | Direction | Payload |
|---|---|---|
memory.longterm.loaded | Plugin -> bus | LongTermMemoryLoaded |
memory.longterm.store | Any -> plugin | LongTermMemoryStoreRequest |
memory.longterm.stored | Plugin -> bus | LongTermMemoryStored |
memory.longterm.read | Any -> plugin | LongTermMemoryReadRequest |
memory.longterm.result | Plugin -> bus | LongTermMemoryReadResult |
memory.longterm.delete | Any -> plugin | LongTermMemoryDeleteRequest |
memory.longterm.deleted | Plugin -> bus | LongTermMemoryDeleted |
memory.longterm.list | Any -> plugin | LongTermMemoryQuery |
memory.longterm.list.result | Plugin -> bus | LongTermMemoryListResult |
Desktop Shell Integration
The desktop shell automatically injects agent_id into the plugin config before boot, enabling per-agent memory isolation without manual configuration.
Vector Memory
Plugin ID: nexus.memory.vector
Capability: memory.vector
Per-agent semantic recall backed by the vector.store capability. On every user turn, the plugin embeds the input, queries the agent’s namespace for relevant past content, and renders the hits as a <recalled_memory> block in the next system prompt. Compaction summaries are auto-stored so trimmed context stays recallable.
Fully independent of nexus.memory.longterm — separate code, capability, and storage. They coexist and complement each other:
nexus.memory.longterm | nexus.memory.vector | |
|---|---|---|
| Address by | key (LLM-managed) | embedding (semantic similarity) |
| Storage | one markdown file per entry, YAML frontmatter | vector store namespace |
| LLM tools | memory_read / memory_write / memory_list / memory_delete | none — automatic |
| Best for | structured notes, preferences, exact facts | fuzzy recall of summaries, topics, prior turns |
Long-term is the agent’s deliberate filing cabinet; vector memory is the agent’s automatic associative recall.
Capabilities
- Provides:
memory.vector - Requires:
embeddings.provider,vector.store
Both required capabilities auto-activate via Requires() if missing.
Configuration
nexus.memory.vector:
# namespace: memory-default # default: "memory-{InstanceID}"
top_k: 5
min_similarity: 0.3
auto_store_compaction: true # auto-write summaries on memory.compacted
auto_store_user_input: false # off by default; turning on makes every turn a memory
section_priority: 45 # PromptRegistry priority for the recalled-memory block
| Key | Type | Default | Description |
|---|---|---|---|
namespace | string | memory-{InstanceID} | Vector store namespace for this agent. The default sanitizes the InstanceID for filesystem safety (/ → -, : → -). Multi-agent desktop shells isolate automatically. |
top_k | int | 5 | Max hits queried per turn. |
min_similarity | float | 0.0 | Hits below this similarity are dropped from the prompt. 0 disables filtering. |
auto_store_compaction | bool | true | Write the compaction summary on memory.compacted. |
auto_store_user_input | bool | false | Write every user message. Off by default — usually too noisy. |
section_priority | int | 45 | PromptRegistry priority. Higher numbers append later. |
The active embeddings.provider plugin owns the model choice — set the
embedding model on that plugin. The model used to populate a namespace
must match the model used to query it.
Behavior
On io.input (priority 10)
Subscribed at priority 10 — earlier than the agent’s handler at 50. Sequence:
- Embed the user message via
embeddings.provider. - Query
vector.storefor top-k hits in the configured namespace. - Filter by
min_similarity. - Stash the hits in plugin state.
- Optionally also store the input itself (
auto_store_user_input).
When the agent builds the next llm.request (priority 50), the PromptRegistry calls buildPromptSection which renders the stashed hits as XML.
On memory.compacted
When nexus.memory.compaction or summary-buffer compaction fires, the plugin extracts the system-role summary message from CompactionComplete.Messages and stores it. Past context stays recallable even after the active history buffer trims it.
On memory.vector.store (explicit)
Plugins or tools can write a piece of content explicitly:
req := &events.VectorMemoryStore{
Content: "User confirmed they prefer dark mode in long sessions",
Source: "agent",
Metadata: map[string]string{"category": "preference"},
}
bus.Emit("memory.vector.store", req)
// req.Provider and req.Error filled in place
Source is recorded as a metadata tag on the chunk and shown in the rendered prompt block.
Prompt section
When at least one hit clears min_similarity, the plugin renders:
<recalled_memory>
These items were recalled from your vector memory based on the current user message.
Use them if relevant; ignore them if not.
<item rank="1" similarity="0.842" source="compaction">
Summary of prior turns: user is debugging the websocket reconnect logic...
</item>
<item rank="2" similarity="0.711" source="explicit">
User mentioned the bug was specific to the staging environment.
</item>
</recalled_memory>
When no hits clear the filter, the section returns an empty string and PromptRegistry skips it — turns with no recall add nothing to the prompt.
Salience
Salience policy is intentionally conservative:
- Compaction summaries — high signal, low volume, written automatically.
- Explicit stores — caller-decided.
- User input — off by default. Auto-storing every message floods the namespace with low-value content, hurts recall quality, and racks up embedding costs.
If you want richer auto-storage, the recommended path is a separate plugin that subscribes to agent.turn.complete (or similar) and emits memory.vector.store with whatever salience heuristic fits your domain — e.g., “store assistant messages but not user messages”, or “store anything tagged important: true in metadata”.
ID scheme
Each stored doc gets a 16-hex-char ID derived from sha256(content || source || RFC3339Nano-timestamp). Time in the hash means re-storing the same content twice produces two entries — vector memory accumulates rather than dedupes. If you want exact-content dedupe, use nexus.memory.longterm instead.
Stored metadata
Every recall doc lands with:
| Key | Description |
|---|---|
source | One of user, compaction, explicit, or whatever caller supplied. |
stored | RFC3339 UTC timestamp of when the doc was written. |
session | Session ID (only on user source). |
backup_path | Compaction archive path (only on compaction source). |
Plus any extra string metadata supplied on the VectorMemoryStore payload.
Multi-agent shells
In a desktop shell hosting multiple agents, each plugin instance receives a distinct InstanceID (see Plugin System → Instance IDs). The default namespace memory-{InstanceID} keeps each agent’s recall isolated automatically — no extra configuration needed.
To deliberately share memory across agents, override namespace to a common value.
Events
| Event | Direction | Payload | When |
|---|---|---|---|
io.input | Bus → plugin | events.UserInput | Triggers query + stash. |
memory.compacted | Bus → plugin | events.CompactionComplete | Triggers auto-store of summary. |
memory.vector.store | Any → plugin | *events.VectorMemoryStore | Explicit store. |
embeddings.request | Plugin → bus | *events.EmbeddingsRequest | Used during query and store. |
vector.query | Plugin → bus | *events.VectorQuery | Used during turn-start retrieval. |
vector.upsert | Plugin → bus | *events.VectorUpsert | Used during store. |
Limits
- One namespace per agent instance. Multi-namespace recall (e.g., separate “facts” and “summaries” namespaces) isn’t supported in v1. Run two plugin instances if you need it.
- No re-ranking. Hits are returned in cosine-similarity order. A future
rag.rerankercapability will allow LLM-based or cross-encoder rerank passes. - No filter. The query doesn’t pass a metadata filter. Adding one is a small change if you have a use case — file an issue.
Errors
- Errors during query, embed, or store are logged as warnings rather than surfaced in the prompt. Recall is best-effort: a missing prompt section is far better than blocking the user’s turn on a transient embedding-API blip. Watch the engine log for
vector memory: ...lines if you suspect recall is silently failing.
Embedding Providers
Plugins that advertise the embeddings.provider capability. Convert text to dense vectors so the rest of the RAG stack — vector stores, ingest, search — can find passages by semantic similarity.
Why a capability
Embedding APIs are ergonomically similar (text in, vectors out) but the providers and prices vary a lot. Splitting embeddings.provider into its own capability lets the same nexus.rag.ingest, nexus.tool.knowledge_search, and nexus.memory.vector plugins work against any backend — OpenAI today, Ollama or a self-hosted model tomorrow — without code changes elsewhere.
This mirrors how search.provider and llm.provider work. Pin one explicitly with a top-level capabilities: block when more than one is active.
Built-in adapters
| Plugin ID | Backend | Notes |
|---|---|---|
nexus.embeddings.openai | OpenAI embeddings API | text-embedding-3-* models. Needs OPENAI_API_KEY. Supports base_url override for Azure / OpenAI-compatible proxies. |
nexus.embeddings.mock | Deterministic hash-based | Zero I/O, no API key. For tests and offline development. |
Bus contract
// pkg/events/embeddings.go
type EmbeddingsRequest struct {
Texts []string
Model string
Dimensions int // optional truncation hint
// Filled by the provider:
Vectors [][]float32
Provider string
Usage EmbeddingsUsage
Error string
}
Emitted as a pointer payload on embeddings.request. The capability-resolved provider fills the result fields in place before Emit returns. Adapter handlers must:
- Ignore the event if
req.Provider != ""(someone else already answered). - Set
req.Provider = pluginIDwhether the call succeeded or failed. - Set either
req.Errororreq.Vectors, not both. - Echo back the actual model used in
req.Modelso consumers can record what produced the vectors.
See the RAG guide for a full adapter skeleton.
OpenAI Embeddings
Plugin ID: nexus.embeddings.openai
Advertises the embeddings.provider capability backed by OpenAI’s /v1/embeddings API. Supports text-embedding-3-small (default), text-embedding-3-large, and text-embedding-ada-002. Compatible with Azure OpenAI and OpenAI-compatible proxies via base_url.
Configuration
nexus.embeddings.openai:
api_key_env: OPENAI_API_KEY # default; ignored if api_key is set directly
# api_key: sk-... # direct literal (avoid checking in)
# model: text-embedding-3-small # default
# dimensions: 1536 # provider default; smaller = cheaper
# base_url: https://api.openai.com/v1/embeddings
# timeout: 30s
| Key | Type | Default | Description |
|---|---|---|---|
api_key | string | — | OpenAI API key (direct literal). |
api_key_env | string | OPENAI_API_KEY | Env var name to read the key from when api_key is unset. |
model | string | text-embedding-3-small | Embedding model. |
dimensions | int | (provider default) | Optional truncation. text-embedding-3-* accepts arbitrary smaller dims; older models ignore this. |
base_url | string | https://api.openai.com/v1/embeddings | Override for Azure / proxies. |
timeout | duration | 30s | HTTP timeout for the embeddings call. |
Model picking
| Model | Dim (default) | Notes |
|---|---|---|
text-embedding-3-small | 1536 | Default. Cheap, good enough for most retrieval. |
text-embedding-3-large | 3072 | ~3× the price; better on hard semantic tasks, more storage. |
text-embedding-ada-002 | 1536 | Legacy. Cheaper than 3-large, less accurate than 3-small. |
You can request a smaller dimensionality from text-embedding-3-* to trade some quality for storage and search latency:
nexus.embeddings.openai:
model: text-embedding-3-large
dimensions: 1024 # roughly 3-small accuracy at 2/3 the storage
Switching models
The embedding cache (in nexus.rag.ingest) is keyed on content hash, not model. Mixing vectors from two models in one namespace produces nonsense rankings, so when you change model or dimensions you should:
rm -rf ~/.nexus/vectors/_cache/
nexus ingest --namespace=kb ./docs # re-embed under the new model
Events
| Event | Direction | Payload |
|---|---|---|
embeddings.request | Any → plugin | *EmbeddingsRequest |
Pointer-fill: the plugin sets req.Vectors, req.Provider, req.Model, req.Usage (or req.Error) in place.
Errors
no API key configured (set api_key in config or OPENAI_API_KEY env var)at boot — set the key in env or in the config block.openai returned HTTP 401— invalid or expired key. Check the variable name inapi_key_envis the one actually set in the shell.expected N embeddings, got M— the API returned a different number of vectors than the input text count. The adapter retries the request internally; if you see this surfaced, the API call partially failed mid-stream. Re-running ingest will pick up where the cache stopped.
Mock Embeddings
Plugin ID: nexus.embeddings.mock
A deterministic, zero-I/O embeddings.provider for tests and offline development. Same text in → same vector out. No network, no API key, sub-millisecond per call.
The vectors are derived from the SHA-256 hash of the input text. Two different inputs map to two different (but stable) vectors; the same input always maps to the same vector. The output is unit-normalized so backends like chromem-go that require normalized inputs accept it directly.
This plugin ships in the main binary alongside production providers — same pattern as nexus.io.test. It is opt-in; to use it just include it in plugins.active.
Configuration
nexus.embeddings.mock:
# dimensions: 128 # default
# model: mock-embedding # default; echoed back in EmbeddingsRequest.Model
| Key | Type | Default | Description |
|---|---|---|---|
dimensions | int | 128 | Output vector dimensionality. Per-call override via EmbeddingsRequest.Dimensions. |
model | string | mock-embedding | Echoed back as EmbeddingsRequest.Model. |
When to use it
- Integration tests. The RAG integration tests under
tests/integration/rag_test.gouse this plugin so the suite runs without an API key and without spending money. See Integration Testing. - Adapter and storage development. When iterating on a new vector store backend, you don’t want a real embedding API in the loop. Wire up
nexus.embeddings.mockto keep the focus on the storage side. - Pipeline smoke tests. Boot a CLI ingest with mock embeddings to confirm chunking, persistence paths, and watch-mode wiring all work, then swap in a real provider.
When not to use it
The mock has no semantic structure — “cat” and “kitten” produce vectors as different as “cat” and “transistor radio”. Tests that depend on similarity rankings for meaning will give wrong-shaped signals. The included integration tests work around this by querying with the exact stored content (which has bit-for-bit-identical vectors and produces similarity ≈ 1.0).
Don’t use this plugin in production. Don’t use it for anything where retrieval quality matters.
Events
Same as any embeddings.provider: subscribes to embeddings.request, fills the result in place. See Embedding Providers for the full payload shape.
Vector Stores
Plugins that advertise the vector.store capability. Persist embedding vectors with their content and metadata, and answer nearest-neighbor queries against a named subset (a namespace).
Why a capability
Vector backends differ wildly — pure-Go in-memory stores, embedded SQLite extensions, Postgres extensions, dedicated services like Qdrant or Weaviate. Splitting vector.store into its own capability lets the same nexus.rag.ingest, nexus.tool.knowledge_search, and nexus.memory.vector plugins work against any of them, with the choice driven entirely by config.
Built-in adapters
| Plugin ID | Backend | Notes |
|---|---|---|
nexus.vectorstore.chromem | philippgille/chromem-go | Pure Go, in-memory with JSON on-disk persistence. No CGO, no service to run. Suitable up to low millions of chunks. |
Namespaces
Every operation takes a Namespace string. Namespaces isolate logically distinct knowledge — a project’s docs, a shared knowledge base, per-agent semantic memory. They are cheap to create (chromem-go creates the collection on first upsert) but do not validate against an allow-list: the consumer plugin is responsible for namespace policy.
nexus.tool.knowledge_search enforces an allow-list at the tool level. nexus.memory.vector namespaces by InstanceID. The vector store itself just routes by name.
Bus contract
Four event types cover the interface:
// pkg/events/vector.go
type VectorUpsert struct { Namespace string; Docs []VectorDoc;
Provider string; Error string }
type VectorQuery struct { Namespace string; Vector []float32;
K int; Filter map[string]string;
Matches []VectorMatch; Provider string; Error string }
type VectorDelete struct { Namespace string; IDs []string;
Provider string; Error string }
type VectorNamespaceDrop struct { Namespace string;
Provider string; Error string }
type VectorDoc struct { ID string; Vector []float32; Content string; Metadata map[string]string }
type VectorMatch struct { ID string; Content string; Metadata map[string]string; Similarity float32 }
Emitted as pointer payloads on vector.upsert, vector.query, vector.delete, vector.namespace.drop. Adapters mutate in place and set req.Provider / req.Error (and req.Matches on query) before Emit returns.
Upsert semantics
VectorUpsert replaces documents with matching IDs. Adapters without native upsert (chromem-go) implement this as Delete(IDs) followed by Add. Re-ingesting the same path with nexus.rag.ingest is therefore safe and idempotent.
Metadata constraint
VectorDoc.Metadata is map[string]string — the common denominator across chromem, sqlite-vec, pgvector, Qdrant. Numeric or boolean metadata should be stringified at the producer.
Query filter
VectorQuery.Filter is an exact-match metadata filter. {"source": "docs/foo.md"} returns only chunks whose stored metadata’s source field equals docs/foo.md. Empty filter returns over the whole namespace. Range / contains / regex filters are backend-dependent and not exposed in v1.
Idempotent drop
VectorNamespaceDrop succeeds whether the namespace exists or not. Adapters must not error on missing namespaces; the consumer is allowed to call drop defensively at startup or shutdown.
Chromem-go Vector Store
Plugin ID: nexus.vectorstore.chromem
Backed by philippgille/chromem-go — a pure-Go, in-memory vector database with JSON on-disk persistence. No CGO, no separate service. Each namespace becomes a chromem-go collection under the configured path.
Suitable up to roughly low millions of chunks depending on dimensionality and host RAM. Beyond that, migrate to a dedicated backend (sqlite-vec, pgvector, Qdrant) — the vector.store event surface is identical, so consumers don’t change.
Configuration
nexus.vectorstore.chromem:
path: ~/.nexus/vectors # default
compress: false # gzip-compress collection files on disk
| Key | Type | Default | Description |
|---|---|---|---|
path | string | ~/.nexus/vectors | Directory for the chromem DB. One subdirectory per namespace. Created if missing. |
compress | bool | false | Gzip-compress collection files. Saves disk at the cost of a little CPU on read/write. |
~ is expanded relative to $HOME.
On-disk layout
~/.nexus/vectors/
├── kb/ # one chromem collection per namespace
│ ├── ...documents
│ └── metadata
├── project-docs/
│ └── ...
└── memory-react/
└── ...
Plus the embedding cache (managed by nexus.rag.ingest, not this plugin) at ~/.nexus/vectors/_cache/. Keeping cache and store under the same root makes them easy to back up and easy to wipe together when switching embedding models.
Behavior
Upsert
chromem-go has no native upsert. The plugin implements it as Delete(IDs) followed by Add(...). Stable IDs (which nexus.rag.ingest produces from <sha256-prefix-of-abspath>-<chunk-idx>) make re-ingest idempotent.
Query
Cosine similarity over normalized vectors. The plugin clamps K to the namespace size to avoid chromem panicking when asked for more results than exist, and short-circuits empty namespaces with a zero-results response (no error).
Delete
VectorDelete ignores unknown IDs. Watch-mode deletes in nexus.rag.ingest rely on this — they delete a fixed upper bound of IDs (0..4095) under the file’s path-hash without first querying how many actually exist.
Drop namespace
VectorNamespaceDrop removes the entire collection — both in-memory and the on-disk directory. Idempotent: dropping an unknown namespace returns success.
Concurrency
The plugin holds a mutex around GetOrCreateCollection and GetCollection/DeleteCollection. Per-collection chromem operations (Add, QueryEmbedding, Delete) are themselves thread-safe, so reads and writes against an existing namespace fan out without serialization.
Events
Subscribes to all four vector.* events. See Vector Stores for payload shapes.
| Event | Direction | Payload |
|---|---|---|
vector.upsert | Any → plugin | *VectorUpsert |
vector.query | Any → plugin | *VectorQuery |
vector.delete | Any → plugin | *VectorDelete |
vector.namespace.drop | Any → plugin | *VectorNamespaceDrop |
Limits and caveats
- In-memory at runtime. chromem-go loads the full collection into RAM on first access. A namespace with 100k chunks at 1536 dims is ~600 MB resident. Watch the process RSS when scaling up.
- Single-process. Two engines pointed at the same
pathwill race on writes. Use one process perpath, or pick a backend with proper transactional guarantees. - No native upsert / no atomic transactions. The delete-then-add pattern is exposed as a single call from the consumer’s perspective but isn’t transactional under the hood. A crash between delete and add can leave a chunk’s slot empty until the next ingest. Re-running ingest fixes it; the embedding cache makes that cheap.
- No range or full-text filter.
VectorQuery.Filteris exact-match string equality only. If you need richer filters, that’s a v2 backend feature.
RAG
Plugins that consume embeddings.provider and vector.store to build a retrieval pipeline.
For an end-to-end walkthrough — wiring a profile, ingesting docs, and getting an agent to cite sources — see the RAG guide.
Plugins
| Plugin ID | Role |
|---|---|
nexus.rag.ingest | Reads files, chunks them, embeds via embeddings.provider, upserts via vector.store. Two entry points (event + watch mode) sharing one codepath. Backs the nexus ingest CLI subcommand. |
Two more RAG consumers live elsewhere in the plugin tree:
nexus.tool.knowledge_search— LLM-facing tool the agent calls explicitly.nexus.memory.vector— automatic per-agent semantic recall on every turn.
They’re under plugins/tools/ and plugins/memory/ because they fit those existing categories — the RAG namespace is reserved for ingestion machinery and any future RAG-specific glue.
RAG Ingest
Plugin ID: nexus.rag.ingest
Ingests files into the vector store. One plugin, two entry points sharing one codepath:
- Event mode — any plugin emits
*events.RAGIngeston"rag.ingest". Plugin reads → chunks → embeds uncached chunks → upserts. Sync pointer-fill (returns withChunks/SkippedCached/Errorset), plus a notification event"rag.ingest.result"for observers. - Watch mode —
fsnotifywatchers declared in config fire the same code path on writes/deletes, debounced 250ms to coalesce save bursts.
The nexus ingest CLI subcommand drives event mode from a minimal engine — useful for bulk pre-loading without a running agent. See RAG guide → bulk-ingest CLI reference.
Capabilities
nexus.rag.ingest requires both:
embeddings.provider— to produce vectors from chunk contentvector.store— to persist them
Both are auto-activated via Requires() if not in the user’s active list.
Configuration
nexus.rag.ingest:
chunker:
size: 1000 # default
overlap: 200 # default
cache_dir: ~/.nexus/vectors/_cache # default
watch:
- path: ./docs
glob: "*.md"
namespace: project-docs
- path: ./knowledge-base
namespace: kb
| Key | Type | Default | Description |
|---|---|---|---|
chunker.size | int | 1000 | Target chunk size in characters. |
chunker.overlap | int | 200 | Overlap between adjacent chunks. Preserves context across boundaries. |
cache_dir | string | ~/.nexus/vectors/_cache | Embedding cache directory. Content hash → vector. |
watch | list | (empty) | fsnotify watch entries. Each requires path and namespace; glob optional. |
Chunker
Recursive-character splitter: paragraph (\n\n) → line (\n) → sentence (. ) → space → hard-split-by-character. Each step backs off when the next chunk would exceed size. Overlap is taken as the trailing N characters of the previous chunk seeded into the next, so retrieval matches that span the boundary still hit.
The chunker is internal to this plugin — there is no shared pkg/rag/chunker. A second caller would justify promotion; until then, keep it simple.
Embedding cache
Content-hash → vector, persisted as one JSON file per entry under cache_dir. Two-byte directory shard keeps ls fast at scale.
The cache is not keyed per embedding model. If you change the embedding model or dimensionality, drop cache_dir — mixing vectors from two models in a namespace is incoherent regardless of cache state, so re-ingest is required either way:
rm -rf ~/.nexus/vectors/_cache/
nexus ingest --namespace=kb ./docs
Chunk IDs
Deterministic: <sha256-prefix-of-abspath>-<chunk-idx>. Two consequences:
- Idempotent re-ingest. Replaying the same file produces identical IDs, so the upsert path replaces existing chunks rather than accumulating duplicates.
- Cheap deletes.
nexus.rag.ingestdoesn’t track how many chunks were stored per file. Watch-mode deletes drop a generous upper bound of IDs (4096) under the file’s path-hash; the underlying vector store ignores unknown IDs. Files with more than 4096 chunks (rare; that’s >4 MB at default chunk size) need a wider limit — file an issue.
Stored metadata
Every chunk lands in the store with this metadata:
| Key | Description |
|---|---|
source | Absolute path to the source file. |
path_hash | First 16 hex chars of the path’s SHA-256. Useful for filter queries. |
chunk_idx | Zero-based chunk index (string). |
chunk_size | Length of the chunk content in characters (string). |
Plus any extra Metadata fields supplied on the RAGIngest payload by the caller.
Watch mode
Each watch: entry installs an fsnotify watcher on path. On a Write or Create event, the file is debounced (250ms) and re-ingested. On Remove or Rename, the file’s chunks are dropped via rag.ingest.delete.
Globs are matched both relative-to-root and on basename, so glob: "*.md" works for both docs/getting-started/installation.md and notes.md in the watched root.
The watcher runs only when at least one entry is configured. With no watch: block, the plugin runs in event mode only.
Bus contract
| Event | Direction | Payload | Purpose |
|---|---|---|---|
rag.ingest | Any → plugin | *RAGIngest | Ingest one file. Sync pointer-fill. |
rag.ingest.delete | Any → plugin | *RAGIngestDelete | Drop a file’s chunks. Sync pointer-fill. |
rag.ingest.result | Plugin → bus | *RAGIngest | Notification after rag.ingest completes. |
embeddings.request | Plugin → bus | *EmbeddingsRequest | Used during ingest. |
vector.upsert | Plugin → bus | *VectorUpsert | Used during ingest. |
vector.delete | Plugin → bus | *VectorDelete | Used during file delete. |
RAGIngest payload
| Field | Direction | Description |
|---|---|---|
Path | input | Absolute or relative path to the file. |
Namespace | input | Target namespace in the vector store. |
Metadata | input | Optional metadata merged into every chunk. |
Provider | output | nexus.rag.ingest. |
Chunks | output | Number of chunks produced and upserted. |
SkippedCached | output | How many of those came from the embedding cache. |
Error | output | Non-empty on failure. |
RAGIngestDelete payload
| Field | Direction | Description |
|---|---|---|
Path | input | Absolute or relative path. |
Namespace | input | Target namespace. |
Provider | output | nexus.rag.ingest. |
Deleted | output | Currently always 0; the underlying vector store drops by ID without counting. |
Error | output | Non-empty on failure. |
Errors
namespace required—Namespacewas empty on the request payload.read PATH: permission denied— file unreadable. Watch-mode entries should point at directories whose permissions don’t change at runtime.embed: <provider error>— the embeddings provider returned an error. Look at the provider’s adjacent log line for the underlying cause.upsert: <store error>— the vector store rejected the batch. Most commonly this is a backend running out of disk; check the store’spathlocation.
I/O Interface Plugins
I/O plugins handle user interaction — displaying agent output and collecting user input. You need exactly one active I/O plugin.
Available I/O Plugins
| Plugin | ID | Interface |
|---|---|---|
| Terminal UI | nexus.io.tui | BubbleTea-based terminal interface |
| Browser UI | nexus.io.browser | HTTP/WebSocket web interface |
| Wails Desktop | nexus.io.wails | Wails webview transport for desktop apps |
| Oneshot | nexus.io.oneshot | Non-interactive single-turn JSON transcript (scripting / CI) |
| Broker IO | nexus.io.broker | Dial-back transport for instances spawned by the session broker |
I/O Event Flow
Both I/O plugins follow the same event pattern:
- Input: Collect user text → emit
io.input - Output: Receive
io.output→ display to user - Streaming: Receive
io.output.streamchunks → render incrementally - Approvals: Receive
io.approval.request→ show dialog → emitio.approval.response - Questions: Receive
io.ask→ show prompt → emitio.ask.response - Status: Receive
io.status→ update status indicator
Terminal UI (TUI)
A rich terminal interface built with BubbleTea. Provides markdown rendering, streaming output, approval dialogs, and status indicators.
Details
| ID | nexus.io.tui |
| Dependencies | None |
Configuration
No additional configuration. The TUI plugin uses BubbleTea defaults.
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
io.output | 50 | Display agent responses |
io.output.stream / io.output.stream.end | 50 | Streaming response rendering |
io.status | 50 | Update status bar (thinking, tool_running, etc.) |
io.approval.request | 50 | Show tool approval dialogs |
io.ask | 50 | Show question prompts |
thinking.step | 50 | Display thinking indicators |
plan.approval.request | 50 | Show plan approval dialogs |
plan.created | 50 | Display generated plans |
agent.plan | 50 | Display plan progress |
session.file.created / session.file.updated | 50 | Show file activity notifications |
io.history.replay | 50 | Replay conversation on session resume |
cancel.complete | 50 | Handle cancellation UI |
Emits
| Event | When |
|---|---|
io.input | User submits a message |
io.approval.response | User responds to approval dialog |
io.ask.response | User answers a question |
plan.approval.response | User approves/rejects a plan |
io.session.start / io.session.end | Session lifecycle |
cancel.request | User cancels current operation |
cancel.resume | User resumes after cancellation |
Features
- Markdown rendering in the terminal
- Streaming response display with incremental rendering
- Approval dialogs for tool execution
- Plan display and approval
- Status bar showing current agent state
- File creation/update notifications
- Session resume with history replay
- Built-in commands:
/quit,/exit
Browser UI
A web-based interface using HTTP and WebSockets. Provides the same functionality as the TUI but accessible through a browser.
Details
| ID | nexus.io.browser |
| Dependencies | None |
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
host | string | localhost | HTTP server bind address |
port | int | 8080 | HTTP server port |
open_browser | bool | true | Automatically open the browser on start |
Events
Subscribes to and emits the same events as the TUI plugin.
Architecture
- HTTP Server — Serves the web UI static assets
- WebSocket — Real-time bidirectional communication
- Hub — Coordinates multiple WebSocket connections
Input is emitted asynchronously to avoid deadlocks with the event bus.
Example Configuration
nexus.io.browser:
host: localhost
port: 3000
open_browser: true
Oneshot I/O
A non-interactive I/O plugin for scripting, batch jobs, and CI. It runs the agent for a single turn, auto-approves every approval request, and writes a JSON transcript of the run to stdout (and optionally a file).
The name reflects its semantics: one prompt in, one transcript out, then the process exits. (A future nexus.io.headless plugin will keep a long-running process alive so external systems can drive it — this is not that.)
Details
| ID | nexus.io.oneshot |
| Dependencies | None |
Use this plugin instead of nexus.io.tui or nexus.io.browser — exactly one I/O plugin should be active at a time.
Prompt resolution
The plugin resolves the prompt to feed into the agent from the first of these sources that is non-empty:
NEXUS_ONESHOT_PROMPTenvironment variableinputfield in the plugin configinput_filefield in the plugin config (path to a text file)- Piped stdin (only when stdin is not a terminal)
If none of these yield a prompt, the run fails fast and still emits a JSON document containing the error so callers get something actionable.
Configuration
plugins:
active:
- nexus.io.oneshot
- nexus.llm.anthropic
- nexus.agent.react
- nexus.memory.capped
nexus.io.oneshot:
input: "" # inline prompt, or leave empty to use another source
input_file: "" # path to a file containing the prompt
output_file: "" # optional: also write the JSON transcript to this path
pretty: true # pretty-print the JSON (default true)
read_stdin: true # allow reading a piped stdin as a fallback (default true)
All fields are optional.
Usage
# Pipe a prompt through stdin
echo "What is 2+2?" | bin/nexus -config configs/oneshot.yaml
# Supply the prompt via environment variable
NEXUS_ONESHOT_PROMPT="Summarize octopus intelligence" \
bin/nexus -config configs/oneshot.yaml
# Read the prompt from a file (via config)
bin/nexus -config configs/oneshot.yaml # with input_file: ./prompt.txt set
# Feed the JSON transcript into jq for post-processing
echo "List three interesting facts about octopuses" \
| bin/nexus -config configs/oneshot-planned.yaml \
| jq '.final_output'
Auto-approval
The oneshot plugin auto-approves every approval request so agents with planners and protected tools can run unattended:
io.approval.request(tool call approval) → respondsApproved: trueplan.approval.request(planner approval) → respondsApproved: trueio.ask(free-form question to the user) → responds with an empty string
Every auto-approval is recorded in the approvals array of the transcript so callers can audit what happened.
⚠️ Because every approval is granted, be deliberate about which tools you make available under this profile. Avoid enabling destructive shell commands or filesystem writes outside a sandbox unless you trust the prompt source.
JSON transcript schema
The root document is tagged with schema: "nexus.oneshot.transcript/v1".
| Field | Type | Description |
|---|---|---|
schema | string | Always nexus.oneshot.transcript/v1 |
session_id | string | Nexus session ID (matches ~/.nexus/sessions/<id>/) |
started_at / ended_at | RFC3339 timestamp | Lifetime of the run |
duration_ms | number | Wall-clock duration |
final_output | string | Final assistant message text |
plans | array | plan.created events (full plan snapshots) |
plan_updates | array | agent.plan events (step status transitions) |
thinking | array | thinking.step events (reasoning trace) |
approvals | array | Auto-approved tool / plan / ask requests |
errors | array | core.error events and error-role io.output messages |
Every array field is omitted from the JSON when empty.
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
io.output | 50 | Capture final assistant text and error messages |
io.approval.request | 10 | Auto-approve tool call approvals |
plan.approval.request | 10 | Auto-approve plan approvals |
io.ask | 10 | Auto-respond with an empty answer |
plan.created | 50 | Record generated plans in the transcript |
agent.plan | 50 | Record plan status updates |
thinking.step | 50 | Record reasoning trace |
agent.turn.start / agent.turn.end | 50 | Detect when the single turn has completed |
core.error | 50 | Record errors in the transcript |
The approval / ask handlers subscribe at priority 10 so they run before any other plugin’s handlers and respond immediately.
Emits
| Event | When |
|---|---|
io.input | Once during Ready, with the resolved prompt |
io.approval.response | Auto-approval for a tool call |
plan.approval.response | Auto-approval for a plan |
io.ask.response | Auto-response for an ask prompt |
io.session.start | On Ready |
io.session.end | After the final JSON transcript has been flushed |
Lifecycle
Initwires subscriptions and reads config.Readyemitsio.session.start, resolves the prompt, then emitsio.inputon a new goroutine so the engine’s main loop can install its signal + session-end handlers.- The agent runs its turn. Any approval or ask prompts are auto-handled.
- When
agent.turn.endbrings the turn depth back to zero, the plugin builds the JSON transcript, writes it to stdout (andoutput_fileif set), then emitsio.session.endto trigger engine shutdown. Shutdownis idempotent — if the run was terminated by a signal before the turn completed, it still flushes whatever was captured.
Sample profiles
Two profiles ship with Nexus:
configs/oneshot.yaml— minimal ReAct loop with conversation memory.configs/oneshot-planned.yaml— adds the dynamic planner withapproval: alwaysto exercise the auto-approval path end-to-end.
Test IO (nexus.io.test)
Non-interactive IO plugin for automated integration testing. Replaces nexus.io.tui in test configurations to drive sessions programmatically.
Purpose
The test IO plugin feeds scripted inputs into the engine, collects all bus events during execution, and handles approval requests automatically. Used alongside pkg/testharness for Go integration tests.
Configuration
plugins:
active:
- nexus.io.test
# ... other plugins
nexus.io.test:
inputs: # scripted user messages (in order)
- "Hello, who are you?"
- "List files in current directory."
input_delay: 500ms # delay between inputs (default: 500ms)
approval_mode: approve # approve | deny | per-prompt (default: approve)
approval_rules: # only when approval_mode: per-prompt
- match: "shell" # substring match on tool call or description
action: approve
- match: "rm -rf"
action: deny
ask_responses: # canned answers for io.ask events
- "yes"
mock_responses: # synthetic LLM responses (no real API calls)
- content: "Hello! I'm a test assistant."
- content: "Here are the files: main.go, go.mod"
timeout: 60s # max session duration (default: 60s)
Approval Modes
| Mode | Behavior |
|---|---|
approve | Auto-approve all approval requests |
deny | Auto-deny all approval requests |
per-prompt | Match against approval_rules, approve if no rule matches |
Mock Responses
When mock_responses is configured, the plugin intercepts before:llm.request (at priority 20, after gates at priority 10) and injects synthetic llm.response events instead of letting requests reach the real LLM provider. No API key needed, millisecond execution.
Gates still fire first — a stop words gate can veto a request before the mock ever sees it. Responses are consumed in order; the last one repeats for any remaining requests.
Mock responses can include tool calls for testing tool execution flows:
mock_responses:
- content: ""
tool_calls:
- name: shell
arguments: '{"command": "ls"}'
- content: "Done listing files."
Input Feeding
Inputs are sent sequentially. The plugin waits for the agent to become idle (turn depth returns to zero) before sending the next input. After all inputs are sent and the final turn completes, the plugin emits io.session.end.
Ask Responses
When the agent emits io.ask events, the plugin responds with canned answers from ask_responses in order. The last response in the list repeats for any remaining asks. If ask_responses is empty, an empty string is returned.
Event Collection
The plugin subscribes to all bus events via SubscribeAll and stores them in an ordered slice. After the session ends, collected events are accessible via the Collected() method for test assertions.
Integration with Test Harness
The test IO plugin is designed to work with pkg/testharness:
h := testharness.New(t, "configs/test-minimal.yaml")
h.Run() // boots engine, feeds inputs, waits for completion
h.AssertEventEmitted("io.output") // check collected events
h.AssertNoSystemOutput() // no gate vetoes
See the Integration Testing guide for full usage.
Subscriptions
| Event | Purpose |
|---|---|
io.approval.request | Auto-respond per approval config |
plan.approval.request | Auto-respond per approval config |
io.ask | Respond with canned answers |
agent.turn.start | Track turn depth for input pacing |
agent.turn.end | Detect idle state, trigger next input or session end |
* (wildcard) | Collect all events for assertions |
Emissions
| Event | When |
|---|---|
io.session.start | On Ready() |
io.input | For each scripted input |
io.approval.response | In response to approval requests |
plan.approval.response | In response to plan approval requests |
io.ask.response | In response to ask events |
io.session.end | After all inputs processed or timeout |
Wails Desktop IO
A Wails-native IO transport for embedding Nexus inside a desktop webview
shell. Unlike nexus.io.browser, which is a session-scoped dev-mode
transport owned by the stock nexus binary, nexus.io.wails is
process-scoped and owned by a host Wails application.
Nexus does not take a build-time dependency on
github.com/wailsapp/wails/v2/pkg/runtime. The plugin defines a small
Runtime interface and the downstream Wails app hands in a wrapper
around runtime.EventsEmit / runtime.EventsOn before calling
engine.Boot.
Details
| ID | nexus.io.wails |
| Dependencies | None |
| Status | Production (config-driven event bridging, multi-agent scoping) |
Configuration
The plugin supports two modes:
Legacy mode (no config keys): falls back to hardcoded chat-event
subscriptions (io.output, io.input, etc.) for backward compatibility.
Config-driven mode: explicit subscribe and accept lists control
which events are bridged:
plugins:
nexus.io.wails:
# Events bridged outbound: bus → frontend
subscribe:
- "match.result"
- "hello.response"
- "ui.state.restore"
- "io.file.selected"
- "session.file.created"
# Events accepted inbound: frontend → bus
accept:
- "match.request"
- "hello.request"
- "ui.state.save"
- "io.file.selected"
Events
In config-driven mode, the plugin bridges whatever events are listed in
subscribe (outbound: Go → JS) and accept (inbound: JS → Go). Custom
domain events use a generic passthrough handler. The existing typed
handlers (handleOutput, handleStreamChunk, etc.) remain for chat
events that need special mapping.
Architecture
- Hub — A single-client transport wrapper holding the
Runtimeimplementation installed by the embedder. No fanout, no client map, no lifecycle — a Wails app has exactly one attached webview for its process lifetime. - Adapter — Implements
ui.UIAdapterby marshaling outbound messages intoui.Envelopeand callingHub.BroadcastEnvelope, which in turn callsRuntime.EmitEvent("nexus", envelopeJSON). - Plugin — Wiring layer that subscribes to configured events and translates inbound events from the webview onto the Nexus bus.
The Runtime interface is deliberately minimal:
type Runtime interface {
EmitEvent(name string, optionalData ...any)
OnEvent(name string, callback func(optionalData ...any))
}
Multi-agent scoping
When a desktop shell runs multiple agent engines, the shell provides
a scoped Runtime adapter per agent. The scoped runtime prepends
the agent ID to event channels:
- Outbound:
"{agentID}:nexus"instead of"nexus" - Inbound:
"{agentID}:nexus.input"instead of"nexus.input"
The plugin itself is unaware of scoping — it talks to its Runtime,
and the Runtime implementation handles the namespace. No plugin
changes needed for multi-agent.
Using pkg/desktop/ (recommended)
Full documentation: See the Desktop Shell section for architecture details, a step-by-step build guide, and the complete API reference.
The desktop shell framework in pkg/desktop/ handles all the
boilerplate of embedding Nexus in a Wails app. Each agent is
registered with its config YAML and plugin factories:
desktop.Run(&desktop.Shell{
Title: "My App",
Width: 900,
Height: 720,
Assets: assets,
Agents: []desktop.Agent{{
ID: "my-agent",
Name: "My Agent",
ConfigYAML: configYAML,
Factories: map[string]func() engine.Plugin{
"nexus.io.wails": wailsio.New,
"my.custom.plugin": myplugin.New,
},
}},
})
The shell handles:
- Per-agent engine lifecycle (lazy boot on first selection)
- Scoped
Runtimeadapters for multi-agent event isolation - Singleton-factory registration for the wails plugin
- Shell services (file dialogs, OS notifications, etc.)
- Left-nav navigation for multi-agent apps
- Settings UI with agent-contributed schemas, keychain secrets, and
${var}config injection - Session history: per-agent session list, recall, new session, cleanup
See cmd/desktop/ for the reference multi-agent app.
Agent-contributed settings
Agents declare configurable fields via Settings []SettingsField on the
Agent struct. The shell renders a settings UI from the schema, persists
values to ~/.nexus/desktop/settings.json (plaintext) and the OS
keychain (secrets), and resolves ${var} placeholders in the agent’s
ConfigYAML before engine creation.
desktop.Agent{
ID: "my-agent",
ConfigYAML: configYAML, // contains ${shell.anthropic_api_key}, ${data_dir}
Settings: []desktop.SettingsField{
{
Key: "shell.anthropic_api_key",
Display: "Anthropic API Key",
Type: desktop.FieldString,
Secret: true,
Required: true,
},
{
Key: "data_dir",
Display: "Data Folder",
Type: desktop.FieldPath,
Required: true,
},
},
}
Scope fallback: Settings keys prefixed with shell. are stored in
shell scope and shared across agents. During resolution, the shell
checks agent scope first, then falls back to shell scope. This means
an API key entered once under “shell” is available to all agents.
Required field gating: If an agent has required settings with no value and no default, the shell refuses to boot the engine and redirects the user to the settings page with missing fields highlighted.
Session history
The shell tracks session history per agent in
~/.nexus/desktop/sessions.json. Each engine boot creates a new session
entry; agents contribute metadata via bus events:
session.meta.title— Human-readable session title (e.g. “Match: Senior Go Engineer”). Emitted by the agent plugin after a meaningful action completes.session.meta.preview— Agent-specific summary data for the session list (e.g.{ candidateCount: 5, topCandidate: "Jane" }).session.meta.status— Explicit status change (e.g.{ status: "completed" }). Also inferred fromio.session.end.
The shell subscribes to these events on the engine’s bus after boot and
updates the session index. The frontend receives updates via
{agentID}:sessions.updated Wails events.
Session lifecycle:
- New session:
Shell.NewSession(agentID)stops the current engine and boots a fresh one. - Recall:
Shell.RecallSession(agentID, sessionID)stops the current engine, creates a new one withRecallSessionIDset, and boots it. The engine replays conversation history viaio.history.replay. - Delete:
Shell.DeleteSession(agentID, sessionID)removes the session from the index and deletes the engine session directory. - Cleanup: On startup, the shell removes sessions older than the configured retention period (default 30 days) and reconciles orphaned engine directories.
UI state persistence
The shell provides a framework-agnostic mechanism for frontends to persist and restore UI state across sessions via two bus events:
-
ui.state.save(inbound: frontend → bus) — The frontend emits this event with an opaque{ state: { ... } }payload whenever it wants to checkpoint its UI state. The shell writes the payload toui-state.jsonin the engine session directory (~/.nexus/sessions/<id>/ui-state.json). -
ui.state.restore(outbound: bus → frontend) — On session recall, after the engine boots, the shell readsui-state.json(if it exists) and emits this event onto the bus. The frontend listens for it and rehydrates its state from the payload.
Both events must be included in the wails IO plugin’s config-driven
accept and subscribe lists:
plugins:
nexus.io.wails:
subscribe:
- "ui.state.restore"
accept:
- "ui.state.save"
The payload structure is entirely up to the frontend — the shell treats it as an opaque JSON blob. This means the mechanism works regardless of whether the frontend uses Alpine.js, React, vanilla JS, or any other framework.
File portal
The desktop shell provides a file portal layer that gives agents a
consistent way to access files without navigating the raw filesystem.
Agents declare input_dir and output_dir settings; the shell uses
these to root file dialogs, list directory contents, and provide a
file browser panel.
Shell-bound methods:
ListFiles(agentID, filter)— Non-recursive listing of the agent’sinput_dir, filtered by glob pattern.OutputDir(agentID)— Resolve and create the agent’soutput_dir.CopyFileToInputDir(agentID, sourcePath)— Copy a file into the agent’sinput_dir(used for drag-and-drop).WatchInputDir(agentID)— Start fsnotify watcher, emits{agentID}:files.changedon file create/remove/rename.
Bus events:
| Event | Direction | Purpose |
|---|---|---|
io.file.open.request | Plugin → shell | Request a file dialog (existing) |
io.file.open.response | Shell → plugin | File dialog result (existing) |
io.file.output_dir.request | Plugin → shell | Ask where to write outputs |
io.file.output_dir.response | Shell → plugin | Output directory path |
io.file.selected | Shell → plugin | User selected a file in the browser panel |
session.file.created | Plugin → shell | Agent wrote an output file (existing) |
Directory resolution priority:
- Agent-scoped
input_dirsetting - Shell-scoped
shared_data_dirsetting - User’s
~/Documentsdirectory
File browser panel (frontend):
The reference desktop app includes a right-side collapsible file
browser panel that shows the active agent’s input_dir contents. It
supports click-to-select (emits io.file.selected), open in default
app, reveal in finder, and drag-and-drop file import from the OS.
The panel auto-refreshes via fsnotify when files change on disk.
Manual embedding (advanced)
If you need more control than pkg/desktop/ provides:
1. Singleton-factory registration
p := wailsio.New().(*wailsio.Plugin)
eng.Registry.Register("nexus.io.wails", func() engine.Plugin { return p })
2. Install runtime before boot
p.Hub().SetRuntime(&wailsRuntime{ctx: ctx})
eng.Boot(ctx)
3. Frontend bus helper
const bus = createBus('my-agent');
bus.on('my.response', (data) => { /* handle */ });
bus.emit('my.request', { /* payload */ });
const result = await bus.call('my.request', 'my.response', payload);
Known gotchas
- Factory-per-boot: Always use a singleton factory closure for the
Wails plugin.
LifecycleManager.Bootcalls factories once per boot. - Boot ordering:
SetRuntimemust happen beforeBoot. OnStartuptiming: the webview may not be fully attached the instantOnStartupfires. If you see dropped events during the first tick, deferBootone event loop iteration.- Do not call
eng.Run: Embedders must useBoot/Stopdirectly.Runinstalls its own signal handler, which conflicts with Wails.
Broker IO (dial-back transport)
nexus.io.broker is the IO transport for Nexus instances spawned by the
session broker (cmd/nexus-broker).
Unlike every other IO transport, this plugin dials out instead of
listening. nexus.io.tui, nexus.io.browser, and nexus.io.realtime all open
a listening socket and wait for a client to connect. The broker plugin does the
opposite: when an instance boots, the plugin dials back to the broker’s
instance gateway over a single WebSocket. The broker is the only listening
socket in the system — there is no per-instance loopback port to allocate or
firewall.
You normally never configure this plugin by hand. The broker injects its config via environment variables when it spawns an instance, and the plugin reads them on boot. It is included for completeness and for anyone embedding the broker protocol in a custom host.
Details
| ID | nexus.io.broker |
| Dependencies | None |
| Spawned by | cmd/nexus-broker (one instance per lease) |
| Listens? | No — it dials out to the broker gateway |
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
broker_addr | string | $NEXUS_BROKER_ADDR | WebSocket URL of the broker’s instance dial-back endpoint, e.g. ws://127.0.0.1:8080/instance. Falls back to the NEXUS_BROKER_ADDR env var (injected by the broker at spawn). When empty the plugin stays dormant — it does not dial and the engine still boots cleanly. |
lease_id | string | $NEXUS_BROKER_LEASE_ID | Lease id the broker assigned to this instance; echoed in the register frame so the gateway can bind this socket to the lease. Falls back to the NEXUS_BROKER_LEASE_ID env var. When empty the plugin stays dormant. |
Config keys take precedence over the environment variables. The reference table above is canonical; see the Configuration Reference.
How it works
On Ready (after the engine is fully up), the plugin:
- Dials
broker_addrover WebSocket usinggithub.com/coder/websocket. - Registers by sending a
registerframe keyed bylease_id— this MUST be the first frame so the gateway can bind the socket to the lease. - Announces readiness with a
readyframe. The broker’sPOST /claimhandler is blocked on exactly this signal before it returns to the caller. - Reports the session id with a
session-id-reportframe so the broker can persist the engine-generated session id for a later-recallresume. - Bridges IO in both directions for the rest of the session.
If the connection drops, the plugin reconnects with exponential backoff (250 ms → 5 s) until shutdown.
Outbound (engine bus → broker → client)
These engine events are forwarded as IO messages inside broker frames:
| Bus event | IO message type |
|---|---|
io.output | output |
llm.stream.chunk | stream.delta |
llm.stream.end | stream.end |
io.status | status |
io.approval.request | approval.request |
hitl.requested | hitl.request |
cancel.complete | cancel.complete |
Output already delivered as stream.delta chunks is not re-sent as a final
output message (the plugin skips io.output events flagged streamed).
Inbound (client → broker → engine bus)
Inbound IO messages are decoded and injected onto the bus:
IO message type | Bus event |
|---|---|
input | before:io.input (vetoable) → io.input |
approval.response | io.approval.response |
hitl.response | hitl.responded |
cancel | cancel.request |
io.input is emitted from a goroutine (not the read pump) because bus dispatch
is synchronous and an agent loop may block waiting on a HITL response — the same
pattern nexus.io.browser and nexus.io.realtime use.
Graceful shutdown
When the broker tears a lease down (manual POST /release, idle, or crash
handling) it sends a shutdown frame. The plugin then:
- latches its reconnect loop off so the teardown is not undone by a retry, and
- emits
io.session.end, which drives a clean engineStopthat flushes and persists the session before the process exits.
The plugin never hard-exits mid-write; the engine owns teardown ordering. The
broker bounds how long it waits for the process and force-kills it if the
graceful path overruns (release_grace).
Security
There is no auth in the plugin itself. The broker gateway owns lease validation and any transport-level authentication. The session broker ships with no auth in v1 — it is a trusted-caller boundary only. See the session broker guide for the full list of v1 limitations.
Example configuration
You rarely write this by hand — the broker injects both values as environment variables at spawn. When you do set them explicitly:
nexus.io.broker:
broker_addr: "ws://127.0.0.1:8080/instance"
lease_id: "lease-abc123"
Omit both keys (or leave the env vars unset) and the plugin stays dormant, so a config that activates the plugin outside a broker still boots without error.
See also
- Session Broker guide — running the broker, the HTTP API, and the new-vs-resume flow.
- Configuration Reference.
AG-UI Serve Transport (nexus.io.agui)
nexus.io.agui exposes Nexus over the AG-UI protocol
(“Agent-User Interaction”), the open, event-based standard for connecting
streaming agents to user-facing applications. It stands up an HTTP listener so
that any standards-compliant AG-UI client — CopilotKit/React, the AG-UI
terminal client, or a framework integration (LangGraph, CrewAI, Pydantic AI,
Google ADK, Mastra, …) — can drive a Nexus agent with no Nexus-specific client
code.
This is a serve transport: it accepts AG-UI requests and streams AG-UI
responses. It is additive and external-facing — it does not replace the
nexus.io.browser / nexus.io.wails Envelope wire that backs the built-in web
and desktop UIs. See I/O Transport Plugins for the transport
family, and .claude/docs/io-transport.md for why AG-UI intentionally sits
outside the browser↔wails parity rule.
Details
| ID | nexus.io.agui |
| Dependencies | None |
| Wire format | AG-UI over HTTP + SSE (defined by pkg/agui, not the pkg/ui Envelope) |
| Endpoint | POST /agui (plus OPTIONS /agui for CORS preflight) |
| Spec version | v1 (docs.ag-ui.com, 2026-07-10) — pinned in pkg/agui as agui.SpecVersion |
The codec is hand-rolled in pkg/agui (no third-party SDK), matching the
raw-net/http, minimal-dependency house style. Because the spec is tracked
manually, the targeted version is pinned in one place (agui.SpecVersion) and
quoted above.
How it works
A client POSTs a RunAgentInput JSON body to /agui and receives a
text/event-stream (SSE) response carrying one run: a well-formed AG-UI
lifecycle from RUN_STARTED to RUN_FINISHED (or RUN_ERROR). The stream flushes
incrementally as bus events arrive — nothing is buffered until the end.
Inbound (client → bus). The request’s messages are mapped to a Nexus
io.input:
- The trailing
usermessage becomes the live turn content. - Any earlier messages ride as
PreloadMessages, so a resumed thread keeps its prior context. threadIdis recorded as the Nexus session id;runIdidentifies the turn.
io.input is published vetoably (before:io.input first); a veto ends the run
with RUN_ERROR.
Outbound (bus → client). The plugin subscribes to the same engine bus events as the browser transport and translates each into canonical AG-UI SSE. Bus handlers only enqueue translated events onto the active run’s channel; a single HTTP handler goroutine is the sole SSE writer, so the stream is race-free.
Event mapping
Nexus bus events map near-1:1 onto the canonical AG-UI event taxonomy. The
AG-UI wire type discriminator is UPPER_SNAKE_CASE (per the AG-UI protocol);
the values below are the exact strings emitted on the SSE stream:
| Nexus bus event | AG-UI event(s) | Notes |
|---|---|---|
| (run accepted) | RUN_STARTED | Emitted eagerly on accept so even an agent-less run is well-formed. threadId / runId echoed. |
agent.turn.start | STEP_STARTED | Each turn/iteration opens a step; the step name derives from TurnID. |
agent.turn.end | STEP_FINISHED, then RUN_FINISHED | A top-level turn end closes the open step and terminates the run/stream. |
llm.stream.chunk | TEXT_MESSAGE_START → TEXT_MESSAGE_CONTENT | TEXT_MESSAGE_START (role assistant) is emitted lazily on the first non-empty delta; subsequent deltas append content. |
llm.stream.end | TEXT_MESSAGE_END | Closes the open streamed text message. |
io.output | TEXT_MESSAGE_START → TEXT_MESSAGE_CONTENT → TEXT_MESSAGE_END | Self-contained triple. Skipped when the same content was already streamed via llm.stream.chunk; still rendered when a non-streaming provider (mock / batch) flags output streamed but emitted no chunks, so text is never dropped. |
tool.invoke | TOOL_CALL_START → TOOL_CALL_ARGS → TOOL_CALL_END | The agent emits tool.invoke (not tool.call) to run a tool. Arguments are fully resolved on the bus (not streamed), so the three events are emitted together; args are JSON-encoded. Internal sub-calls (non-empty ParentCallID) still render but never suspend the run. |
tool.result | TOOL_CALL_RESULT | Correlated to the call by toolCallId; Error content is surfaced in place of Output when present. |
thinking.step | REASONING_START → REASONING_MESSAGE_CONTENT | REASONING_START opens lazily on the first step; REASONING_END is emitted at turn end. |
| (failure / disconnect / veto / concurrent run) | RUN_ERROR | Terminal; ends the stream. |
Both RUN_FINISHED and RUN_ERROR are terminal — the SSE stream ends on either.
Non-canonical events: the CUSTOM superset
Nexus emits rich events that have no canonical AG-UI equivalent. Rather than
drop them, they ride the AG-UI CUSTOM event as a documented superset: the
Custom.name is the Nexus bus event type and Custom.value is the JSON-encoded
payload. Stock AG-UI clients that only understand canonical events can safely
ignore CUSTOM without losing the run’s canonical lifecycle; Nexus-aware
clients can opt in.
The bridged bus events are:
workflow.progresssubagent.startedsubagent.iterationsubagent.completecode.exec.stdout
AG-UI also defines a
RAWevent for passthrough of an upstream provider’s native event shape. Nexus-specific events useCUSTOM(name + JSON value) consistently;RAWis available inpkg/aguifor future passthrough needs.
Interrupts: HITL and client-executed tools
AG-UI uses a terminal-run model for anything that needs input mid-run: the
run ends with an interrupt outcome and the client starts a continuation run
carrying resume[]. Nexus emulates this as virtual runs over one persistent
in-process session — the agent stays parked in-process and a continuation POST
unblocks it.
One Nexus turn spans multiple AG-UI runs
The load-bearing consequence of the terminal-run model is that a single Nexus turn can span several AG-UI runs. When an agent needs input, the current run ends — but the Nexus session (and the parked agent) stays alive. Each subsequent resume opens a new run over the same thread until the turn finally completes:
POST /agui { threadId: T, runId: R1, messages:[…] } ← run 1 begins the turn
… RUN_STARTED … STEP_STARTED … (agent calls ask_user) …
… STATE_SNAPSHOT … MESSAGES_SNAPSHOT … RUN_FINISHED(interrupt) ← run 1 ends, agent PARKED
POST /agui { threadId: T, runId: R2, resume:[…] } ← run 2 continues the SAME turn
… RUN_STARTED … (agent unblocks, finishes) … RUN_FINISHED ← turn complete
The threadId is identical across the runs; each continuation uses a fresh
runId. No messages are needed on a resume — the resume[] items are the
payload the server correlates back to its pending interrupt(s). Because the
session is persistent and in-process, a threadId must route to the same
Nexus instance across its runs (see threadId / runId semantics below).
Two flows ride the identical suspend/resume machinery:
- Human-in-the-loop (HITL). A
hitl.requestedduring a run (e.g. the agent calling theask_usertool) emits aSTATE_SNAPSHOT+MESSAGES_SNAPSHOTthenRUN_FINISHED(interrupt); the resume emitshitl.respondedto unblock the waiter. - Client-executed (frontend) tools. Tools the client advertises via
RunAgentInput.toolsare surfaced to the agent (the plugin appends them to the synchronoustool.catalog.querysnapshot, scoped to exactly the advertising run — they never leak into later runs or shadow a same-named server tool). When the agent calls one, itstool.invokestreams theTOOL_CALL_START/ARGS/ENDsequence and then the run ends interrupt-style: there is no in-process handler to produce atool.result, so the client runs the tool and resumes with a tool result. The plugin feeds that result back to the parked agent as thetool.resultit was waiting on, and the continuation streams on a fresh run.
A server-side Nexus catalog tool is never intercepted: its own handler runs
inline and produces the tool.result that streams as a normal TOOL_CALL_RESULT.
Client tools are distinguished purely by origin (they came from
RunAgentInput.tools).
The interrupt anchor
RUN_FINISHED(interrupt) carries an Interrupt payload in its result field.
The client renders it and echoes its interruptId in the resume. It provides:
| Field | Meaning |
|---|---|
interruptId | The anchor the client echoes back in resume[].interruptId. Distinct from any internal request id. |
prompt | The rendered question/approval text (HITL) or a client-tool hint. |
mode | free_text, choices, or both — controls the response affordance. |
choices / defaultChoiceId | The options (and deadline default) for a choices/both interrupt. |
The interrupt kind (HITL vs client tool) is also mirrored in the STATE_SNAPSHOT
under an interrupt (HITL) or toolCall (client tool) anchor, so a client that
restores from state alone — rather than replaying MESSAGES_SNAPSHOT — still has
everything it needs to resume.
The resume[] wire shape
Each resume[] item names an interruptId, a status, and an optional
payload. The payload fields depend on the interrupt kind:
status | Interrupt kind | payload fields | Effect |
|---|---|---|---|
resolved | HITL | choiceId, freeText, editedPayload | Answers the prompt. A choices-only interrupt drops stray freeText. All fields optional; an empty payload accepts the default. |
resolved | client tool | output, error | Becomes the parked agent’s tool.result. Empty resolves the call with empty output (the agent still advances). |
cancelled | either | (none) | Abandons the interrupt: a HITL waiter unblocks as cancelled; a client-tool call resolves with an error tool.result so the agent’s loop still advances. |
// HITL resume: pick a choice.
{ "threadId":"T", "runId":"R2",
"resume":[ { "interruptId":"int-…", "status":"resolved",
"payload": { "choiceId":"staging" } } ] }
// Client-tool resume: return the tool's output.
{ "threadId":"T", "runId":"R2",
"resume":[ { "interruptId":"int-…", "status":"resolved",
"payload": { "output":"sunny, 24C" } } ] }
// Cancel either kind.
{ "threadId":"T", "runId":"R2",
"resume":[ { "interruptId":"int-…", "status":"cancelled" } ] }
As AG-UI requires, all open interrupts on a thread must be addressed in one
resume request: a resume that references an unknown/expired interrupt, addresses
one twice, or leaves an open interrupt unaddressed is rejected with a clean
terminal RUN_ERROR stream and leaves the parked agent untouched for a corrected
retry.
The reusable pure-Go conformance client (pkg/agui/aguiclient) provides
constructors for these payloads — ResumeInput, ResolveChoice, ResolveText,
ResolveToolResult, and Cancel — plus Result.Interrupt() to extract the
anchor from a RUN_FINISHED(interrupt). The end-to-end interrupt/resume and
client-tool round-trips are exercised in
tests/integration/agui_hitl_test.go.
threadId / runId semantics
threadId↔ Nexus session. ThethreadIdis recorded as the session id on the inboundio.input. Because the serving session is persistent and lives in-process, athreadIdmust route to the same Nexus instance across runs — the terminal-run/resume model is emulated as virtual runs over one live session, not by reconnecting to a stateless backend.runId↔ Nexus turn. EachPOSTis one run == one turn. Message ids in the outbound stream are derived deterministically from therunIdso a client can correlate streamed text, tool calls, and results within the run.
Concurrency and scope
One in-flight run per listener (single engine/session per listener, mirroring
nexus.io.browser). A second POST while a run is active receives a terminal
RUN_STARTED + RUN_ERROR stream rather than interleaving into the live run. On
client disconnect or engine shutdown, the active run fails with RUN_ERROR and
its handler returns promptly, releasing the slot.
Exposure, auth, and CORS
Safe by default: the listener binds loopback (127.0.0.1:8090) so the
endpoint is never network-exposed without an explicit operator opt-in.
- Bearer auth is enforced only when a non-empty token is resolved. An inline
bearer_tokentakes precedence; otherwisebearer_token_envnames an environment variable to read it from. When set, every request must carryAuthorization: Bearer <token>. - CORS is off by default (same-origin only).
cors_originsaccepts a YAML list (or comma-separated string); a single*echoes any requestOrigin, while an explicit list echoes only matching origins.OPTIONS /aguianswers preflight for browser AG-UI clients.
Configuration
The canonical, always-current key list lives in the Configuration Reference. The keys are summarized here for convenience:
| Key | Type | Default | Description |
|---|---|---|---|
bind | string | 127.0.0.1:8090 | host:port the HTTP listener binds to. Loopback by default. |
bearer_token | string | (empty) | Inline bearer token. Takes precedence over bearer_token_env. |
bearer_token_env | string | (empty) | Env var name to read the bearer token from (used only when bearer_token is empty). |
cors_origins | list<string> | (empty) | Allowed CORS origins. * echoes any Origin; a list echoes only matches; empty means same-origin only. Also accepts a comma-separated string. |
emit_state | bool | false | Opt-in AG-UI shared-state emission: mirror the scene store as a shared-state document and emit STATE_SNAPSHOT/STATE_DELTA on the run stream. See Shared state below. |
Shared state
With emit_state: true, the transport mirrors the session’s scene store
(nexus.scene) as the AG-UI shared state document so a frontend can render
and track agent state. The mapping is:
- The scene store emits
scene.created/scene.patched/scene.deletedon the bus, each carrying the scene’s full post-mutation content. The transport tracks these into a document keyed byscene_id(value = the scene’s current content). It never calls the scene plugin directly — the bus events are the sole input. - On run start, a
STATE_SNAPSHOTof the current document is emitted right afterRUN_STARTED. - Each scene mutation during the run emits a
STATE_DELTAwhosedeltais an RFC 6902 JSON Patch from the previous document to the new one. TheSTATE_SNAPSHOTalways precedes anySTATE_DELTAon the stream, and applying the deltas in order to the snapshot reconstructs the state (verified end to end by theTestAGUIState_*integration tests as well as thepkg/aguiunit tests). This aligns AG-UI’sSTATE_DELTAwith the scene store’s patch model while normalizing the scene store’s shallow-merge semantics into a valid JSON Patch computed from full content.
The document is session-scoped and persists across runs on the listener, so a later run’s snapshot reflects scenes created by an earlier run.
Inbound state (client → agent)
A client may send a shared-state document on RunAgentInput.state to seed or
edit state the agent then observes. The document uses the same scene-keyed
shape the transport emits outbound: a JSON object whose keys are scene_ids
and whose values are that scene’s content.
- Inbound state is applied at run start (and on a resume/continuation run)
before the initial
STATE_SNAPSHOTis emitted, so the snapshot reflects the client’s view and the agent’s first turn observes it. - To make a client write real (not just a mirror update), each
scene_id → contententry is pushed into the scene store via a bus-emittedscene_createtool.invokecarrying an explicitscene_id. The scene plugin creates the scene under that id, or shallow-merges the content as a patch when the scene already exists (client edits a scene the agent created preserve keys the client did not send). The agent then reads the seeded state through the normalscene_get/scene_listtools. No direct plugin-to-plugin call is made — the bus is the only channel. - A non-object state document (or otherwise malformed) is logged and skipped; it
never fails the run. Inbound state is a no-op when
emit_stateis off.
Conflict / ordering semantics — client-state-seeds-then-agent-wins. The
client seed is fully applied before the run’s io.input is emitted, so the agent
always starts from the seeded state. For the rest of the run, agent-side scene
mutations are last-writer over the same scene_id: a later scene_patch
overwrites the client’s value per the scene store’s shallow-merge semantics, and
that change flows back out as a STATE_DELTA (completing the round-trip). The
transport’s stateMu and the scene store’s own lock serialize concurrent client
and agent mutations, so ordering is deterministic (client seed first, then agent
writes in bus order) and no half-applied document is ever observed.
Because the mirror is seeded to the same value the scene store echoes back, the
seed itself produces no STATE_DELTA — only genuine agent mutations do. The
TestAGUIState_InboundSeedObserved and TestAGUIState_ConflictAgentWins
integration tests exercise this round-trip: the client seed appears in the
initial STATE_SNAPSHOT and is read back through scene_get, and a subsequent
agent scene_patch on the same scene_id wins on the overlapping key (with the
client’s untouched keys preserved by shallow-merge) and surfaces as exactly one
STATE_DELTA.
The scene_create tool accepts an optional scene_id argument to support this
seeding; when omitted the store assigns an id as before, so existing agent usage
is unchanged.
Example configuration
plugins:
nexus.io.agui:
bind: "127.0.0.1:8090"
bearer_token_env: "AGUI_BEARER_TOKEN"
cors_origins:
- "https://app.example.com"
See also
- Configuration Reference —
nexus.io.agui— canonical config keys. - I/O Transport Plugins — the transport family.
- Browser UI — the session-scoped Envelope transport AG-UI mirrors for scope/exposure.
Observer Plugins
Observers watch system activity without affecting behavior. They’re useful for debugging, auditing, and persisting reasoning traces.
Available Observers
| Plugin | ID | Purpose |
|---|---|---|
| Thinking Persistence | nexus.observe.thinking | Persists thinking steps and plan progress |
| OpenTelemetry | nexus.observe.otel | Exports events as OTel traces via OTLP |
The legacy
nexus.observe.loggerplugin was removed in #66 / Phase 3. Itsevents.jsonlrole is now subsumed by the always-on engine journal at<session>/journal/events.jsonl. External tooling that previously tailed the logger’s events.jsonl can tail the journal instead.
Thinking Observer
Marker observer for thinking.step and plan.progress events. The
plugin no longer writes derived JSONL files — the per-session
journal is the single source of
truth for both event types, alongside every other event on the bus.
When this plugin is in plugins.active, terminal and browser shells
turn on thinking-related UI affordances (e.g. dedicated “thinking”
message styling). Without it, the events still flow on the bus and
land in the journal — only the optional UI surface differs.
Details
| ID | nexus.observe.thinking |
| Dependencies | None |
Configuration
No configuration options.
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
thinking.step | 90 | Marker subscription (no side effects) |
plan.progress | 90 | Marker subscription (no side effects) |
Emits
None.
Reading the thinking history
Thinking steps and plan progress live in
<session>/journal/active.jsonl (and rotated *.jsonl.zst segments)
exactly like every other event. Two ways to consume them:
- Live, in-process: subscribe to envelopes via
journal.Writer.SubscribeProjection(["thinking.step", "plan.progress"], handler). Handlers fire synchronously after the writer has flushed each envelope to disk. - Post-mortem, walking the journal directory:
journal.ProjectFile(journalDir, []string{"thinking.step", "plan.progress"}, handler). Useful for regenerating derived views after a recall or crash.
Thinking Step Payload
{
"turn_id": "abc123",
"source": "nexus.agent.react",
"content": "The user wants to refactor the auth module...",
"phase": "reasoning",
"timestamp": "2026-04-08T10:30:00Z"
}
Phases: planning, executing, reasoning.
Example Configuration
# Just activate it — no config needed
nexus.observe.thinking: {}
OpenTelemetry Observer
Exports all bus events as OpenTelemetry traces via OTLP. Creates one trace per session with individual spans for each event. Rich span attributes are extracted from LLM, tool, agent, and error payloads.
Details
| ID | nexus.observe.otel |
| Dependencies | None |
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
endpoint | string | localhost:4317 | OTLP collector endpoint |
protocol | string | grpc | Transport protocol: grpc or http |
insecure | bool | true | Skip TLS verification |
service_name | string | nexus | OTel service name reported to collector |
exclude_events | list | [] | Event types to skip. Supports prefix wildcards (e.g. llm.stream.*) |
Events
Subscribes To
Uses SubscribeAll() — receives every event in the system (minus excluded events).
Emits
None.
Trace Structure
Each Nexus session produces one trace:
- Root span:
nexus.session— spans the full session lifetime with session ID attribute. - Child spans: One per bus event, named by event type (e.g.
llm.request,tool.invoke).
All spans include base attributes:
nexus.event.id— unique event IDnexus.event.type— event type stringnexus.event.source— emitting plugin IDnexus.event.timestamp_unix_ms— event timestamp
Rich Attributes by Event Type
LLM requests (llm.request):
nexus.llm.role,nexus.llm.model,nexus.llm.max_tokens,nexus.llm.streamnexus.llm.message_count,nexus.llm.tool_count,nexus.llm.temperature
LLM responses (llm.response):
nexus.llm.model,nexus.llm.finish_reasonnexus.llm.usage.prompt_tokens,nexus.llm.usage.completion_tokens,nexus.llm.usage.total_tokensnexus.llm.tool_call_count
Tool calls (tool.invoke):
nexus.tool.id,nexus.tool.name,nexus.tool.turn_id
Tool results (tool.result):
nexus.tool.id,nexus.tool.name,nexus.tool.has_error,nexus.tool.error
Agent turns (agent.turn):
nexus.agent.turn_id,nexus.agent.iteration,nexus.agent.session_id
Subagent events:
nexus.subagent.spawn_id,nexus.subagent.task,nexus.subagent.iterations,nexus.subagent.usage.total_tokens
Vetoable events (before:*):
nexus.veto.vetoed,nexus.veto.reason(when vetoed)- Plus attributes from the wrapped original payload
Example Configuration
plugins:
active:
- nexus.observe.otel
# Export to local Jaeger via gRPC
nexus.observe.otel:
endpoint: "localhost:4317"
protocol: grpc
insecure: true
service_name: nexus
exclude_events:
- llm.stream.chunk
- core.tick
# Export to remote collector via HTTP
nexus.observe.otel:
endpoint: "otel-collector.example.com:4318"
protocol: http
insecure: false
service_name: my-agent
Backends
Any OTLP-compatible backend works:
- Jaeger — local development,
docker run -p 4317:4317 -p 16686:16686 jaegertracing/jaeger:latest - Grafana Tempo — production tracing
- Honeycomb — managed observability
- SigNoz — open source APM
Online Eval Sampler
Opt-in observer plugin that snapshots a configurable fraction of live session journals (plus every failed session, when failure-capture is on) into a local directory so the eval pipeline can score them later.
The sampler is off by default. It does not appear in any of the
shipped configs; activating it is a deliberate two-step opt-in: list the
plugin in plugins.active, then set enabled: true in its config block.
Omitting either step keeps the plugin inert — Subscriptions() returns
empty, no bus traffic, no disk writes.
Details
| ID | nexus.observe.sampler |
| Source | plugins/observe/sampler/plugin.go |
| Dependencies | None — the journal is core, not a plugin |
| Capabilities | None |
| Default state | Disabled |
Why it exists
Promotion (nexus eval promote, see Promoting a Session)
turns a real session into a deterministic eval case in one command. But
the operator has to know a session is interesting before promoting it.
The sampler closes that loop:
- A small fraction of normal sessions land on disk so a sample of the workload is always available for offline scoring.
- Every failed session is captured automatically (no “I forgot to keep that one”), preserving the journal exactly as it was when it failed.
The captured directory is in the same shape nexus eval promote
accepts as input — so a captured session can be promoted to a fully
deterministic case without any intermediate transformation.
Configuration
Per the configuration reference:
plugins:
active:
- nexus.observe.sampler
nexus.observe.sampler:
enabled: false # master switch (default false)
rate: 0.0 # fraction of normal sessions captured (0..1)
failure_capture: true # always capture status != completed/active
out_dir: ~/.nexus/eval/samples # path expanded via engine.ExpandPath
Init (plugins/observe/sampler/plugin.go:96) validates rate ∈ [0, 1]
when enabled: true and creates out_dir ahead of any captures.
What gets written
For each captured session, the sampler writes:
<out_dir>/<session-id>/
journal/
header.json # exact copy of the source journal header
events.jsonl # active segment (byte-for-byte under IdentityRedactor)
events-001.jsonl.zst # rotated segments, byte-for-byte
cache/... # tool result cache, byte-for-byte
metadata.json # provenance: captured_at, reason, sampling_rate_at_capture, session_status, engine_version
<session-id> is the live session’s ID — not a fresh ID per sample.
Re-running the sampler against the same session is idempotent: the
plugin’s in-memory captured set short-circuits a duplicate capture in
the same engine lifetime.
The journal/ subtree is produced by
pkg/iocopy.CopyDir — the same helper
the promote pipeline uses, so the two paths cannot drift.
Capture decision
Every io.session.end runs through Plugin.decide
(plugins/observe/sampler/plugin.go:194):
- If
failure_capture: trueand the session’smetadata/session.jsonstatusis anything other thanactiveorcompleted, return("failure_capture", true)and snapshot. - Else, if
rate <= 0, skip. - Else, if
rate >= 1, return("sampled", true)and snapshot. - Else, roll a
[0, 1)float againstrate. Capture on hit.
Tests inject a deterministic RNG via the package-private
Plugin.SetRandSource so the rate path is reproducible:
p := New().(*Plugin)
p.SetRandSource(rand.New(rand.NewSource(42)))
The eval.candidate event
Every capture emits one eval.candidate envelope:
type EvalCandidate struct {
SessionID string `json:"session_id"`
CaseDir string `json:"case_dir"`
Reason string `json:"reason"` // "sampled" or "failure_capture"
Warnings []string `json:"warnings,omitempty"`
}
Downstream tooling (a future nexus eval list-candidates, an external
ingestion backend, a UI badge) can subscribe to eval.candidate and
enumerate captures without scanning out_dir itself. Definition:
plugins/observe/sampler/events.go:1.
Redaction
The sampler accepts a pluggable Redactor
(plugins/observe/sampler/redact.go:12):
type Redactor interface {
Redact(eventType string, payload []byte) ([]byte, error)
}
v1 ships only the IdentityRedactor (no-op). When a non-identity
redactor is configured, the sampler walks the active events.jsonl
segment line-by-line after the byte copy and rewrites each envelope’s
payload through the redactor. A nil return wipes the payload while
preserving envelope metadata (seq, type, ts).
Limitation. Compressed
*.jsonl.zstrotated segments are byte-copied as-is in v1 — round-tripping zstd would expand the dependency surface. If a non-identity redactor matters for rotated segments, surface the case as a follow-up issue and design a streaming rewrite path.
Integration with nexus eval promote
The on-disk shape under out_dir/<session-id>/journal/ is exactly what
pkg/eval/promote consumes. A future
follow-up will let nexus eval promote --session ~/.nexus/eval/samples/<id>
pick up a sampled directory directly. Today the same end can be reached
with one symlink or copy into ~/.nexus/sessions/ and the regular
promote flow.
The metadata.json sibling carries the bookkeeping that promote does
not need (captured_at, sampling_rate_at_capture, etc.) — it is
provenance for analytics, not state for replay.
Privacy posture
- Off by default. No data is captured without an explicit config opt-in.
out_diris local to the host. The plugin makes zero network calls.- A
Redactorhook is in place from day one. The default is identity; custom redactors are an API-only contract — no surface area in the YAML schema yet. - Sampled bytes are subject to the same retention rules the operator
applies to
out_diritself; the plugin never deletes its own output.
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
io.session.end | 0 (low) | Run decide → snapshot → emit eval.candidate. Lowest priority so end-of-session writers (journal, memory persisters) finalize first. |
Emits
| Event | Payload | When |
|---|---|---|
eval.candidate | EvalCandidate | After a successful snapshot. |
Planner Plugins
Planners generate execution plans before the agent starts iterating. They’re optional — enable them when you want structured task decomposition before action.
Available Planners
| Plugin | ID | Strategy |
|---|---|---|
| Dynamic Planner | nexus.planner.dynamic | LLM generates a plan from the user’s input |
| Static Planner | nexus.planner.static | Returns a fixed set of steps from config |
How Planning Works
- The agent (with
planning: true) emitsplan.requestwith the user’s input - The active planner generates a plan
- Plan is delivered via
plan.result - Optionally, the user is asked to approve via
plan.approval.request - The agent injects the plan into its system prompt and begins iteration
Plan Persistence
Plans are persisted to the session under plugins/<planner-id>/<plan-id>/:
| File | Content |
|---|---|
plan.json | The generated plan steps |
request.json | The original plan request |
approval.json | The approval decision (if applicable) |
Dynamic Planner
Uses an LLM to generate an execution plan from the user’s input. The plan is a sequence of steps with descriptions and optional detailed instructions.
Details
| ID | nexus.planner.dynamic |
| Dependencies | None |
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
approval | string | always | Approval mode: always (user must approve), never (skip), auto (LLM decides) |
model_role | string | (default) | Model role for plan generation |
max_steps | int | 10 | Maximum number of plan steps |
plan_prompt | string | (built-in) | Custom inline planning prompt |
plan_prompt_file | string | (none) | Path to a custom planning prompt file |
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
plan.request | 50 | Receives plan generation requests |
llm.response | 50 | Receives the LLM’s generated plan |
Emits
| Event | When |
|---|---|
plan.result | Plan generated and ready |
thinking.step | Planning reasoning |
io.status | Status updates during plan generation |
How It Works
- Receives
plan.requestwith user input - Constructs a prompt asking the LLM to generate a JSON plan
- Sends
llm.requesttagged withMetadata["_source"]so the ReAct agent ignores this response - Parses the LLM response as JSON steps
- Emits
plan.resultwith the steps
Approval Modes
| Mode | Behavior |
|---|---|
always | User must explicitly approve before execution |
never | Plan is executed immediately |
auto | The LLM includes a risk assessment; low-risk plans skip approval |
Example Configuration
nexus.planner.dynamic:
approval: auto
max_steps: 10
model_role: reasoning
Static Planner
Returns a pre-configured set of steps from the YAML config. No LLM call is needed — the plan is always the same regardless of input. Useful for enforcing a consistent workflow.
Details
| ID | nexus.planner.static |
| Dependencies | None |
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
approval | string | never | Approval mode: always, never, auto (defaults to never for static) |
summary | string | (none) | Human-readable summary of the plan |
steps | list | (required) | List of step objects |
Step Object
| Key | Type | Required | Description |
|---|---|---|---|
description | string | Yes | What this step does |
instructions | string | No | Detailed instructions for the agent |
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
plan.request | 50 | Receives plan requests |
Emits
| Event | When |
|---|---|
plan.result | Immediately returns the configured plan |
Example Configuration
nexus.planner.static:
approval: never
summary: "Standard coding workflow"
steps:
- description: "Analyze the request and identify affected files"
- description: "Plan the implementation approach"
- description: "Implement the changes"
- description: "Verify correctness"
- description: "Summarize what was done"
With Detailed Instructions
nexus.planner.static:
approval: always
summary: "Code review workflow"
steps:
- description: "Read the changed files"
instructions: "Use the file tool to read all files mentioned in the request. Understand the full context."
- description: "Identify issues"
instructions: "Check for bugs, security issues, performance problems, and style violations."
- description: "Write the review"
instructions: "Summarize findings by severity. Include code suggestions for each issue."
Gates
Gates are plugins that subscribe to before:* events and may veto them
before the corresponding action takes effect. They are how Nexus enforces
iteration limits, banned content, token budgets, schema validation, rate
limits, and similar guardrails — without baking the policy into agents or
providers.
Plugins
| Plugin | Vetoes | Purpose |
|---|---|---|
nexus.gate.endless_loop | before:llm.request | Cap LLM calls per turn (replaces agent max_iterations). |
nexus.gate.stop_words | before:llm.request, before:io.output | Block messages containing banned terms. |
nexus.gate.token_budget | before:llm.request | Cap session token usage. |
nexus.gate.rate_limiter | before:llm.request | Throttle LLM call frequency (pause via gate.llm.retry, not reject). |
nexus.gate.prompt_injection | before:llm.request | Detect and block prompt-injection patterns in user input. |
nexus.gate.json_schema | before:io.output | Validate output against JSON Schema; LLM-retry on failure. |
nexus.gate.output_length | before:io.output | Cap response length; LLM-retry to compress. |
nexus.gate.content_safety | before:io.output | Block or redact PII / secrets / sensitive content. |
nexus.gate.context_window | before:llm.request | Estimate context size; trigger compaction when approaching the limit. |
nexus.gate.tool_filter | before:llm.request | Modify the tool list (allowlist / blocklist). |
nexus.gate.approval_policy | before:tool.invoke, before:llm.request | Policy-driven HITL approvals; emits before:hitl.requested then hitl.requested and applies the operator’s allow/reject/edit. |
Configuration
Every gate’s full YAML config — keys, types, defaults — is in the Configuration Reference.
Mechanics
The vetoable event system, priority ordering, and the shared gate.llm.retry
pattern are documented in .claude/docs/gates.md.
That document is the design-level reference; the configuration reference is the
keys-and-defaults reference.
Skills Plugin
Discovers, catalogs, and manages skills — reusable instruction sets that extend the agent’s behavior without writing code.
Details
| ID | nexus.skills |
| Dependencies | None |
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
scan_paths | string[] | (none) | Directories to scan for skills. Required — no implicit defaults. If empty, no skills are loaded. |
trust_project | string | ask | Trust level for project-scoped skills: ask (prompt user), always, never |
max_active_skills | int | 10 | Maximum number of concurrently active skills |
catalog_in_system_prompt | bool | true | Include skill catalog in the system prompt |
disabled_skills | string[] | (none) | Skills to exclude from discovery |
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
core.boot | 10 | Scans for skills at startup |
skill.activate | 50 | Activates a skill by name |
skill.deactivate | 50 | Deactivates a skill |
skill.resource.read | 50 | Reads a skill’s resource files |
before:llm.request | 15 | Tags requests with _expects_schema for active skills that declare output_schema |
Emits
| Event | When |
|---|---|
skill.discover | Skills catalog assembled |
skill.loaded | Skill content loaded for the agent |
skill.resource.result | Skill resource content |
before:skill.activate | Before activation (vetoable for trust checks) |
schema.register | Skill with output_schema activated |
schema.deregister | Skill with output_schema deactivated |
Skill Discovery
At boot, the plugin scans only the directories listed in the scan_paths config — there are no implicit defaults. If scan_paths is empty or unset, no skills are loaded.
Each directory containing a SKILL.md file under a configured scan path is recognized as a skill. Scope is inferred from the resolved path: directories under the user’s home .nexus or .agents trees are treated as user scope; everything else is treated as project scope.
Tilde paths (~, ~/...) are expanded to the user’s home directory automatically.
nexus.skills:
scan_paths:
- ./skills # project skills, relative to cwd
- ~/.agents/skills # user-scope skills (tilde is expanded)
- /shared/team-skills # any other directory you want to include
System Prompt Catalog
When catalog_in_system_prompt: true, the plugin registers a prompt section listing available skills in XML format:
<skills>
<skill name="code-review" scope="project">Review code for quality, bugs, security issues, and style.</skill>
<skill name="git-workflow" scope="project">Standard git workflow with branching and PR creation.</skill>
</skills>
This lets the agent know which skills exist and can request activation when appropriate.
Trust Levels
Project-scoped skills may be untrusted. The trust_project setting controls behavior:
| Mode | Behavior |
|---|---|
ask | Show approval dialog before activating project skills |
always | Trust all project skills automatically |
never | Block all project skill activation |
User-scoped skills (paths under the user’s home .nexus or .agents trees) are always trusted.
Example Configuration
nexus.skills:
trust_project: ask
max_active_skills: 5
catalog_in_system_prompt: true
scan_paths:
- ./skills
- /shared/team-skills
disabled_skills:
- experimental-skill
For details on creating skills, see Writing Skills.
Scene Store (nexus.scene)
Owns the per-session Scene store and exposes five tools the LLM uses to construct durable, addressable, structured visual output: charts, dashboards, multi-section documents.
Details
| ID | nexus.scene |
| Capability | scene.store |
| Dependencies | (none) |
| Requires | (none) |
Configuration
No config keys today — activate the plugin in plugins.active and the
default tools register at boot.
Tool surface
| Tool | Arguments | Output |
|---|---|---|
scene_create | schema (string), content (any) | SceneHandle JSON |
scene_patch | scene_id, patch | SceneHandle JSON |
scene_get | scene_id | full Scene JSON (handle + content + history) |
scene_list | (none) | array of SceneHandle |
scene_delete | scene_id | {"deleted":true} |
Map patches merge shallow (keys in the patch overwrite); non-map patches
replace content. Schema-specific renderers wanting richer merge semantics
can plug a custom Patcher into the in-process scene.MemoryStore.
Persistence
| File | When | Contents |
|---|---|---|
<session>/plugins/nexus.scene/scenes.jsonl | Per mutation | JSONL record of every create / patch / delete. Replay reads this to reconstruct historical state. |
<session>/plugins/nexus.scene/scenes.json | On Shutdown | Full snapshot of every scene at session end. Loaded on next Init for clean restart resume. |
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
tool.invoke | 50 | Handle invocations of the scene_* tools. |
Emits
| Event | When |
|---|---|
tool.register | Registers each scene_* tool at boot. |
tool.result / before:tool.result | Per tool call. |
scene.created | Per scene_create. Payload includes content (the initial content). |
scene.patched | Per scene_patch. Payload includes content (the full post-merge content). |
scene.deleted | Per scene_delete. |
scene.created and scene.patched carry the scene’s full post-mutation
content so bus consumers (e.g. the AG-UI transport’s shared-state mirror) can
track scene state without a tool call. The scene store’s own patch semantics are
shallow-merge, not RFC 6902, so a consumer needing an RFC 6902 delta diffs the
full content itself.
agent_id on scene events flows from Event.Causation.AgentID so
sub-agent contributions stay attributable. See
scene events for payload shape.
Example
plugins:
active:
- nexus.io.tui
- nexus.llm.anthropic
- nexus.agent.react
- nexus.scene
- nexus.memory.capped
nexus.agent.react:
system_prompt: |
When building visual output (charts, dashboards, multi-section
documents), use the scene_create / scene_patch tools to build
durable, addressable artifacts the UI can render incrementally.
Dynamic Variables Plugin
Injects dynamic system information (date, time, OS, working directory) into the agent’s system prompt.
Details
| ID | nexus.system.dynvars |
| Dependencies | None |
Configuration
Each variable is opt-in — defaults to false and must be explicitly
enabled:
| Key | Type | Default | Description |
|---|---|---|---|
date | bool | false | Include current date |
time | bool | false | Include current time |
timezone | bool | false | Include timezone |
cwd | bool | false | Include current working directory |
session_dir | bool | false | Include session directory path |
os | bool | false | Include operating system |
Events
This plugin does not subscribe to or emit any events. It registers a prompt section via the Prompt Registry during initialization.
System Prompt Output
The plugin appends a section like this to the system prompt:
## System Info
- Date: 2026-04-08
- Time: 10:30:00
- Timezone: America/New_York
- OS: darwin
- CWD: /Users/frank/projects/myapp
- Session: ~/.nexus/sessions/abc123
Example Configuration
# Empty config → no variables emitted (every flag defaults to false).
nexus.system.dynvars: {}
# Enable only the variables you want.
nexus.system.dynvars:
date: true
cwd: true
Cancel Control Plugin
Coordinates cancellation of in-progress agent operations. Tracks active turns and routes cancellation requests to the appropriate handlers.
Details
| ID | nexus.control.cancel |
| Dependencies | None |
Configuration
No configuration options.
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
agent.turn.start | 10 | Tracks which turn is active |
agent.turn.end | 10 | Clears active turn tracking |
cancel.request | 10 | Receives cancellation requests from I/O |
cancel.resume | 10 | Receives resume requests |
Emits
| Event | When |
|---|---|
cancel.active | Broadcasts cancellation to all handlers |
io.status | Status updates |
How It Works
- When the user triggers a cancel (e.g., pressing a key in the TUI), the I/O plugin emits
cancel.request - The cancel controller checks if there’s an active turn
- If so, it emits
cancel.activewith the turn ID - All plugins listening for
cancel.activeabort their current work (LLM provider cancels the API call, agent stops iterating) - When the user resumes,
cancel.resumerestarts the flow
When to Use
Include this plugin when using agents that support cancellation (ReAct, Plan & Execute, Orchestrator). It’s essential for interactive workflows where the user may want to interrupt long-running operations.
plugins:
active:
- nexus.control.cancel
# ... other plugins
MCP client
Bridges one or more Model Context Protocol servers into Nexus. Each configured server contributes its tools, resources, and prompts to the running agent through the existing event bus surfaces — agents and IO plugins don’t need any MCP awareness.
Details
| ID | nexus.mcp.client |
| Source | plugins/mcp/client/ |
| Capability | mcp.client |
| Phase | 1 (no sampling; see GitHub #98) |
The plugin is developer-configured: end users never see “MCP” in the UI. Tools land in the catalog under the namespace mcp__<server>__<tool>, prompts surface as slash commands of the form /mcp.<server>.<prompt>, and resources show up as catalog tools (one generic browse/read pair per server plus auto-registered statics and templates).
Quick start
plugins:
active:
- nexus.mcp.client
# ...your usual agent + provider + IO plugins
nexus.mcp.client:
servers:
- name: fs
transport: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "~/projects"]
env:
NODE_ENV: production
- name: gh
transport: http
url: http://localhost:3001/mcp
headers:
Authorization: "Bearer ${GITHUB_MCP_TOKEN}"
timeout: 60s
tools:
allow: ["search_issues", "get_pr", "review_pr"]
Boot order matters only for capabilities — MCP tools are emitted via tool.register after Ready(), so any plugin that depends on the catalog being populated should subscribe rather than reading it once during init.
Tools
Every MCP tool returned from tools/list is registered into the Nexus catalog as mcp__<server>__<raw_name>. The tool’s MCP input schema becomes the catalog Parameters map verbatim, so the LLM sees the exact schema the server published.
Three generic tools are also registered per server, regardless of what the server returns:
mcp__<server>__list_resources()— returns the JSON catalog of currently available resources.mcp__<server>__read_resource(uri)— reads a resource by URI.- For each static resource (up to
auto_register_max) — a no-argmcp__<server>__resource__<slug>tool that reads that specific URI. - For each resource template —
mcp__<server>__template__<slug>whose input schema mirrors the template variables.
Filter what the catalog sees with tools.allow / tools.deny. Both lists match the raw MCP tool name (no mcp__ prefix), and deny always wins.
Resources
Resources surface as catalog tools rather than a separate event family. This keeps the LLM-callable surface uniform: it can list_resources() to discover, read_resource(uri) to fetch, or call an auto-registered slug for a single resource.
Static-resource auto-registration is capped at resources.auto_register_max (default 50). Above the cap the plugin skips per-resource registration and falls back to the generic list_resources/read_resource pair so the catalog doesn’t bloat.
Slugs are deterministic: slug(title|name|URI) + "_" + sha1(uri)[:8]. They stay stable across server restarts as long as the server returns the same URI.
When resources.subscribe_updates is true (default), the plugin subscribes to every auto-registered static. Each notifications/resources/updated from the server emits an mcp.resource.updated event onto the Nexus bus. No core consumer reads this in Phase 1 — it’s plumbed for future RAG ingest / memory plugins.
Prompts
Prompts surface as slash commands. The command shape is /<command_prefix>.<server>.<prompt>, lowercase, underscores. With command_prefix: mcp (default) and a server gh exposing a prompt review_pr, the slash command is /mcp.gh.review_pr.
Arguments use a hybrid positional + k=v syntax:
/mcp.gh.review_pr 123 verbose=true comment="needs benchmarks"
- Positional values map to the prompt’s declared arguments in order.
k=vvalues can appear anywhere; quoting with"…"allows spaces.- Missing required arguments fail before the command is dispatched.
- Unknown keys fail as well, so typos are surfaced.
When the user fires a slash command, the plugin:
- Vetoes the original
before:io.inputso memory plugins don’t record the literal slash text. - Calls
prompts/geton the right server with the parsed arguments. - Translates the returned
Message[]into a[]events.Messagekeeping each role. - Emits a fresh
io.inputwhosePreloadMessagescarries those messages. The downstream memory plugins append them in order; the agent runs as if the user had typed normally.
This routing depends on the UserInput.PreloadMessages field (schema v2). All in-tree memory plugins (capped, simple, summary_buffer) honour it. Third-party memory plugins that pin to UserInputVersion = 1 continue to work — PreloadMessages is an optional slice on the v2 struct.
Aliases
nexus.mcp.client:
aliases:
review: gh.review_pr
/review topic=plan rewrites to /mcp.gh.review_pr topic=plan before dispatch. Aliases are useful when a single MCP prompt is the canonical entry point for a workflow.
Discovery
IO plugins (and a future /help style command) can list the registered slash commands with a synchronous query:
q := &events.MCPPromptsList{SchemaVersion: events.MCPPromptsListVersion}
_ = bus.Emit("mcp.prompts.list", q)
for _, p := range q.Prompts {
// p.Command, p.Server, p.Prompt, p.Title, p.Description, p.Arguments
}
Lifecycle
lifecycle: engine (default) keeps a single connection alive for the engine’s lifetime. Tools/resources/prompts are registered once at boot. Best for almost every developer scenario.
lifecycle: session connects on io.session.start and disconnects on io.session.end. Use when the MCP server holds per-session state that can’t be expressed via MCP roots (rare today, but legal).
Failures during boot are logged at error but do not block the rest of the engine — a single broken server doesn’t take down a Nexus session.
Transports
stdio (default) launches a subprocess and speaks JSON-RPC over its stdin/stdout. The official modelcontextprotocol/go-sdk handles framing and lifecycle.
http uses the streamable HTTP transport. The SDK negotiates the session header; configure auth headers via headers (injected on every request through a wrapping http.RoundTripper). The legacy SSE transport is deliberately not exposed.
Sampling
MCP sampling (server-asks-host-to-call-an-LLM) is deferred to Phase 2. Tracked in issue #98.
Testing
The integration tests in tests/integration/mcp_client_test.go build the fake MCP server at tests/integration/mcp_fake/ and exercise the plugin end-to-end over stdio. Run with:
go test -tags integration ./tests/integration/ -run TestMCPClient -v
No LLM provider key is required — the tests drive the bus directly and observe the plugin’s catalog, resource, and prompt projections.
Event Types Reference
Complete reference for all event types in Nexus, organized by domain.
Core Events
| Event Type | Payload | Description |
|---|---|---|
core.boot | BootConfig | Engine boot started |
core.ready | (none) | All plugins initialized and ready |
core.shutdown | ShutdownReason | Engine shutting down |
core.tick | TickInfo | Periodic heartbeat |
core.error | ErrorInfo | Error reported by a plugin |
core.config.reload.request | ConfigReloadRequest | External trigger asks the engine to re-read its config |
core.config.reload.result | ConfigReloadResult | Engine response to a reload request (success / error) |
Payloads
BootConfig
| Field | Type | Description |
|---|---|---|
ConfigPath | string | Path to the config file |
Profile | string | Profile name |
ShutdownReason
| Field | Type | Description |
|---|---|---|
Reason | string | Why: "user", "error", or "signal" |
Error | error | Associated error (if any) |
TickInfo
| Field | Type | Description |
|---|---|---|
Sequence | int | Tick counter |
Time | time.Time | When the tick occurred |
ErrorInfo
| Field | Type | Description |
|---|---|---|
Source | string | Plugin that reported the error |
Err | error | The error |
Fatal | bool | Whether this should trigger shutdown |
Retryable | bool | Whether this error class is retryable (429, 5xx) |
RetriesExhausted | bool | Provider’s own retry logic gave up |
RequestMeta | map[string]any | Echo of LLMRequest.Metadata for correlation |
core.error is vetoable — providers emit before:core.error first. The fallback plugin can veto the error to suppress it and retry with an alternate provider.
I/O Events
| Event Type | Payload | Description |
|---|---|---|
io.input | UserInput | User submitted a message |
io.output | AgentOutput | Agent produced output |
io.output.stream | OutputChunk | Streaming output chunk |
io.output.stream.end | StreamRef | Streaming complete |
io.output.clear | (none) | Clear partial streamed content (used by fallback) |
io.status | StatusUpdate | Agent state changed |
io.approval.request | ApprovalRequest | Approval needed for an action |
io.approval.response | ApprovalResponse | User responded to approval |
io.ask | AskUser | Agent asking user a question |
io.ask.response | AskUserResponse | User answered the question |
io.history.replay | HistoryReplay | Replaying conversation on resume |
io.session.start | SessionInfo | Session started |
io.session.end | (none) | Session ended |
Payloads
UserInput
| Field | Type | Description |
|---|---|---|
Content | string | Message text |
Files | []FileAttachment | Attached files |
SessionID | string | Current session |
FileAttachment
| Field | Type | Description |
|---|---|---|
Name | string | Filename |
MimeType | string | MIME type |
Data | []byte | File contents |
AgentOutput
| Field | Type | Description |
|---|---|---|
Content | string | Output text |
Role | string | "assistant", "system", or "tool" |
Metadata | map[string]any | Additional context |
TurnID | string | Associated turn |
OutputChunk
| Field | Type | Description |
|---|---|---|
Content | string | Chunk text |
TurnID | string | Associated turn |
Index | int | Chunk sequence number |
StatusUpdate
| Field | Type | Description |
|---|---|---|
State | string | "idle", "thinking", "tool_running", "streaming" |
Detail | string | Additional info |
ToolID | string | Tool being run (if applicable) |
ApprovalRequest
| Field | Type | Description |
|---|---|---|
PromptID | string | Unique identifier for this approval |
Description | string | What needs approval |
ToolCall | any | The tool call details |
Risk | string | "low", "medium", "high" |
ApprovalResponse
| Field | Type | Description |
|---|---|---|
PromptID | string | Matches the request |
Approved | bool | Whether approved |
Always | bool | Remember this decision |
AskUser
| Field | Type | Description |
|---|---|---|
PromptID | string | Unique identifier |
Question | string | The question text |
TurnID | string | Associated turn |
AskUserResponse
| Field | Type | Description |
|---|---|---|
PromptID | string | Matches the request |
Answer | string | User’s response |
Provider Events
| Event Type | Payload | Description |
|---|---|---|
provider.fallback | ProviderFallback | Provider switch due to failure |
provider.fanout.start | ProviderFanoutStart | Parallel fanout initiated |
provider.fanout.response | ProviderFanoutResponse | Individual provider responded |
provider.fanout.complete | ProviderFanoutComplete | All fanout responses collected |
Payloads
ProviderFallback
| Field | Type | Description |
|---|---|---|
Role | string | Model role being resolved |
FailedProvider | string | Plugin ID of the provider that failed |
FailedModel | string | Model that failed |
Error | string | Error description |
NextProvider | string | Plugin ID of the fallback provider |
NextModel | string | Model being tried next |
Attempt | int | 0-based index in the fallback chain |
ProviderFanoutStart
| Field | Type | Description |
|---|---|---|
FanoutID | string | Unique fanout sequence identifier |
Role | string | Model role being resolved |
Strategy | string | Selection strategy (all, llm_judge, heuristic, user) |
Targets | []ProviderFanoutTarget | Providers being dispatched to |
ProviderFanoutTarget
| Field | Type | Description |
|---|---|---|
Provider | string | Plugin ID |
Model | string | Model identifier |
ProviderFanoutResponse
| Field | Type | Description |
|---|---|---|
FanoutID | string | Fanout sequence identifier |
Provider | string | Plugin ID that responded |
Model | string | Model that responded |
Success | bool | false if provider errored or timed out |
Error | string | Non-empty on failure |
ProviderFanoutComplete
| Field | Type | Description |
|---|---|---|
FanoutID | string | Fanout sequence identifier |
Role | string | Model role |
Strategy | string | Selection strategy used |
Succeeded | int | Number of successful responses |
Failed | int | Number of errors or timeouts |
LLM Events
| Event Type | Payload | Description |
|---|---|---|
llm.request | LLMRequest | Request to an LLM provider |
llm.response | LLMResponse | Complete LLM response |
llm.stream.chunk | StreamChunk | Streaming response chunk |
llm.stream.end | StreamEnd | Streaming complete |
Payloads
LLMRequest
| Field | Type | Description |
|---|---|---|
Role | string | Model role name (resolved by provider) |
Model | string | Explicit model ID (optional, overrides role) |
Messages | []Message | Conversation messages |
Tools | []ToolDef | Available tools |
ToolChoice | *ToolChoice | Tool choice constraint (nil = provider default) |
ToolFilter | *ToolFilter | Tool include/exclude filter (nil = no filtering) |
ResponseFormat | *ResponseFormat | Structured output constraint (nil = no constraint) |
MaxTokens | int | Max response tokens |
Temperature | *float64 | Sampling temperature (nil = provider default) |
Stream | bool | Enable streaming |
Metadata | map[string]any | Additional context (e.g., _source for planner tagging) |
Message
| Field | Type | Description |
|---|---|---|
Role | string | "system", "user", "assistant", "tool" |
Content | string | Message text |
ToolCallID | string | For tool role: which call this responds to |
ToolCalls | []ToolCallRequest | For assistant role: tool calls made |
ToolCallRequest
| Field | Type | Description |
|---|---|---|
ID | string | Unique call identifier |
Name | string | Tool name |
Arguments | string | JSON-encoded arguments |
ToolDef
| Field | Type | Description |
|---|---|---|
Name | string | Tool name |
Description | string | What the tool does |
Parameters | string | JSON Schema for parameters |
ToolChoice
| Field | Type | Description |
|---|---|---|
Mode | string | "auto", "required", "none", "tool" |
Name | string | Tool name (only when Mode is "tool") |
ToolFilter
| Field | Type | Description |
|---|---|---|
Include | []string | Only these tools (empty = all). Takes precedence over Exclude |
Exclude | []string | Remove these tools |
ResponseFormat
| Field | Type | Description |
|---|---|---|
Type | string | "text", "json_object", or "json_schema" |
Name | string | Schema name (required by OpenAI for json_schema type) |
Schema | map[string]any | JSON Schema definition |
Strict | bool | Enforce strict schema adherence |
Metadata Conventions
Certain metadata keys carry special meaning:
| Key | On | Type | Description |
|---|---|---|---|
_source | LLMRequest / LLMResponse | string | Correlates retry requests with responses (used by gates) |
_expects_schema | LLMRequest | string | Schema name to attach via the schema registry |
_structured_output | LLMResponse | bool | true when provider enforced structured output (native or simulated) |
LLMResponse
| Field | Type | Description |
|---|---|---|
Content | string | Response text |
ToolCalls | []ToolCallRequest | Tool calls in the response |
Usage | Usage | Token usage statistics |
CostUSD | float64 | Provider-computed cost in USD for this request |
Model | string | Model that was used |
FinishReason | string | Why the response ended |
Metadata | map[string]any | Additional context |
Alternatives | []LLMResponse | Additional responses from parallel fanout providers (nil for non-fanout) |
Usage
| Field | Type | Description |
|---|---|---|
PromptTokens | int | Input tokens consumed |
CompletionTokens | int | Output tokens generated |
TotalTokens | int | Total tokens |
ReasoningTokens | int | Thinking / reasoning tokens (Gemini 2.5 thoughtTokenCount, etc.) |
CachedTokens | int | Tokens served from a prompt cache |
CacheWriteTokens | int | Tokens written into a prompt cache |
ModalityBreakdown | map[string]int | Per-modality token counts when the provider reports them. Lowercase keys: text, image, audio, video, document. Empty when the provider does not split modalities (Anthropic) or the request was text-only. Cost-attribution consumers read this to bill image / audio turns separately. |
Multimodal tool results
ToolResult carries an OutputParts []MessagePart field for tools that
emit multimodal content (image, audio, document, video). When non-empty,
memory plugins copy the parts onto the resulting tool-role
Message.Parts so the next LLM request includes the multimodal content
alongside (or in place of) Output.
Large payloads should be stored via pkg/engine/blobs and referenced via
MessagePart.URI = "nexus-blob:<sha256>" so the journal stays compact;
small payloads can ride inline as MessagePart.Data. Provider plugins
resolve nexus-blob: URIs at request-assembly time.
StreamChunk
| Field | Type | Description |
|---|---|---|
Content | string | Chunk text |
ToolCall | *ToolCallRequest | Partial tool call (if applicable) |
Index | int | Chunk sequence number |
TurnID | string | Associated turn |
StreamEnd
| Field | Type | Description |
|---|---|---|
TurnID | string | Associated turn |
Usage | Usage | Final token usage |
FinishReason | string | Why the stream ended |
Tool Events
| Event Type | Payload | Description |
|---|---|---|
tool.register | ToolDef | Tool available for use |
before:tool.invoke | ToolCall | Before tool execution (vetoable) |
tool.invoke | ToolCall | Tool invocation |
before:tool.result | ToolResult | Before tool result propagation (vetoable) |
tool.result | ToolResult | Tool execution result |
Payloads
ToolCall
| Field | Type | Description |
|---|---|---|
ID | string | Call identifier |
Name | string | Tool name |
Arguments | map[string]any | Parsed arguments |
TurnID | string | Associated turn |
ToolResult
| Field | Type | Description |
|---|---|---|
ID | string | Matches the call ID |
Name | string | Tool name |
Output | string | Human-readable result text (what the LLM sees) |
Error | string | Error message (if failed) |
OutputFile | string | Path to output file (optional) |
OutputData | []byte | Binary output data (optional) |
OutputStructured | map[string]any | Optional structured payload. When the tool’s ToolDef.OutputSchema is set, this should match that schema. Consumed by typed consumers like run_code’s bindings — Output stays freeform text for the LLM. |
TurnID | string | Associated turn |
ToolDef
| Field | Type | Description |
|---|---|---|
Name | string | Tool name the LLM will use |
Description | string | Description shown to the LLM |
Parameters | map[string]any | JSON Schema for inputs |
OutputSchema | map[string]any | Optional JSON Schema describing the shape of ToolResult.OutputStructured. When set, run_code generates a typed Go struct bound to this schema. |
Class / Subclass / Tags | string / []string | Semantic metadata for filtering |
Code Execution Events
Emitted by nexus.tool.code_exec alongside the standard tool.invoke/tool.result pair. Let other plugins observe programmatic tool-calling scripts without recomputing the run_code envelope from a tool result.
| Event Type | Payload | Description |
|---|---|---|
code.exec.request | CodeExecRequest | Script about to run; carries source, imports, active skills |
code.exec.stdout | CodeExecStdout | Incremental stdout chunk produced while the script runs; Final=true marks the last chunk |
code.exec.result | CodeExecResult | Script finished (success, compile error, runtime error, veto, or timeout) |
CodeExecRequest
| Field | Type | Description |
|---|---|---|
CallID | string | Matches the outer run_code tool call ID |
TurnID | string | Associated turn |
Script | string | Full Go source submitted by the LLM |
Imports | []string | Import paths referenced by the script |
Skills | []string | Names of currently-active skills whose helpers were staged |
CodeExecStdout
| Field | Type | Description |
|---|---|---|
CallID | string | Matches the outer run_code tool call ID |
TurnID | string | Associated turn |
Chunk | string | UTF-8 bytes written to stdout since the last emission; may contain newlines |
Final | bool | True for the closing chunk of this call; code.exec.result follows immediately after |
Truncated | bool | Only meaningful on the final chunk — true when total stdout exceeded max_output_bytes |
Chunks are flushed on every newline and on a ~512-byte threshold so long lines without newlines still reach the UI promptly. Consumers should concatenate Chunk values across events to reconstruct the full stdout stream; the final CodeExecResult.Output also carries the full aggregated string for non-streaming consumers.
CodeExecResult
| Field | Type | Description |
|---|---|---|
CallID | string | Matches the outer run_code tool call ID |
TurnID | string | Associated turn |
Output | string | Captured stdout (capped at max_output_bytes) |
Result | string | JSON-marshaled Run() return value |
Error | string | First error encountered (AST rejection, compile, runtime, timeout) |
Duration | int64 | Execution wall time in milliseconds |
Truncated | bool | Stdout was truncated by the output cap |
Agent Events
| Event Type | Payload | Description |
|---|---|---|
agent.turn.start | TurnInfo | Agent began processing |
agent.turn.end | TurnInfo | Agent finished processing |
agent.plan | Plan | Agent’s current plan |
agent.tool_choice | AgentToolChoice | Dynamic tool choice override |
Payloads
TurnInfo
| Field | Type | Description |
|---|---|---|
TurnID | string | Unique turn identifier |
Iteration | int | Current iteration count |
SessionID | string | Current session |
Plan
| Field | Type | Description |
|---|---|---|
Steps | []PlanStep | Plan steps |
TurnID | string | Associated turn |
PlanStep
| Field | Type | Description |
|---|---|---|
Description | string | What this step does |
Status | string | "pending", "active", "completed", "failed" |
AgentToolChoice
| Field | Type | Description |
|---|---|---|
Mode | string | "auto", "required", "none", "tool" |
ToolName | string | Tool name when Mode is "tool" |
Duration | string | "once" (next request only) or "sticky" (until replaced) |
Subagent Events
| Event Type | Payload | Description |
|---|---|---|
subagent.spawn | SubagentSpawn | Subagent creation requested |
subagent.started | SubagentStarted | Subagent began execution |
subagent.iteration | SubagentIteration | Subagent completed an iteration |
subagent.complete | SubagentComplete | Subagent finished |
Payloads
SubagentSpawn
| Field | Type | Description |
|---|---|---|
SpawnID | string | Unique spawn identifier |
Task | string | Task description |
SystemPrompt | string | Override system prompt |
Tools | []string | Available tools |
ModelRole | string | Model role |
ParentTurnID | string | Parent’s turn ID |
SubagentComplete
| Field | Type | Description |
|---|---|---|
SpawnID | string | Spawn identifier |
Result | string | Final result |
Error | string | Error (if failed) |
Iterations | int | Number of iterations used |
TokensUsed | Usage | Token consumption |
CostUSD | float64 | Accumulated cost in USD |
ParentTurnID | string | Parent’s turn ID |
Memory Events
| Event Type | Payload | Description |
|---|---|---|
memory.store | MemoryEntry | Store a memory entry |
memory.query | MemoryQuery | Query conversation history |
memory.result | MemoryResult | Query results |
memory.compaction.triggered | CompactionTriggered | Compaction started |
memory.compacted | CompactionComplete | Compaction finished |
Payloads
MemoryEntry
| Field | Type | Description |
|---|---|---|
Key | string | Entry identifier |
Content | string | Content to store |
Metadata | map[string]any | Additional context |
SessionID | string | Current session |
MemoryQuery
| Field | Type | Description |
|---|---|---|
Query | string | Search query (empty = all) |
Limit | int | Max results |
SessionID | string | Current session |
CompactionTriggered
| Field | Type | Description |
|---|---|---|
Reason | string | Why compaction triggered |
MessageCount | int | Messages before compaction |
BackupPath | string | Backup file location |
CompactionComplete
| Field | Type | Description |
|---|---|---|
Messages | []Message | New compacted message set |
BackupPath | string | Backup file location |
MessageCount | int | Messages after compaction |
PrevCount | int | Messages before compaction |
Long-Term Memory Events
| Event Type | Payload | Description |
|---|---|---|
memory.longterm.loaded | LongTermMemoryLoaded | Memory index injected into system prompt |
memory.longterm.store | LongTermMemoryStoreRequest | Write or update a memory entry |
memory.longterm.stored | LongTermMemoryStored | Write confirmed |
memory.longterm.read | LongTermMemoryReadRequest | Read a memory entry |
memory.longterm.result | LongTermMemoryReadResult | Read result |
memory.longterm.delete | LongTermMemoryDeleteRequest | Delete a memory entry |
memory.longterm.deleted | LongTermMemoryDeleted | Delete confirmed |
memory.longterm.list | LongTermMemoryQuery | List/filter memories |
memory.longterm.list.result | LongTermMemoryListResult | List result |
LongTermMemoryIndex
| Field | Type | Description |
|---|---|---|
Key | string | Memory key |
Preview | string | First line of content |
Tags | map[string]string | Key-value tags |
Updated | time.Time | Last update timestamp |
LongTermMemoryStoreRequest
| Field | Type | Description |
|---|---|---|
Key | string | Memory key |
Content | string | Markdown content |
Tags | map[string]string | Optional tags |
Plan Events
| Event Type | Payload | Description |
|---|---|---|
plan.request | PlanRequest | Request plan generation |
plan.result | PlanResult | Plan generated |
plan.created | PlanResult | Plan ready for display |
plan.approval.request | (plan data) | User approval needed |
plan.approval.response | (approval) | User responded |
plan.progress | PlanProgress | Step status updated |
Payloads
PlanRequest
| Field | Type | Description |
|---|---|---|
TurnID | string | Associated turn |
SessionID | string | Current session |
Input | string | User’s original input |
PlanResult
| Field | Type | Description |
|---|---|---|
TurnID | string | Associated turn |
PlanID | string | Unique plan identifier |
Steps | []PlanResultStep | Plan steps |
Summary | string | Plan summary |
Approved | bool | Whether approved |
Source | string | "dynamic" or "static" |
PlanResultStep
| Field | Type | Description |
|---|---|---|
ID | string | Step identifier |
Description | string | What this step does |
Instructions | string | Detailed instructions (optional) |
Status | string | Current status |
Order | int | Execution order |
PlanProgress
| Field | Type | Description |
|---|---|---|
TurnID | string | Associated turn |
PlanID | string | Plan identifier |
StepID | string | Step being updated |
Status | string | New status |
Detail | string | Additional info |
Skill Events
| Event Type | Payload | Description |
|---|---|---|
skill.discover | SkillCatalog | Skills catalog assembled |
skill.activate | SkillActivation | Skill activation requested |
before:skill.activate | SkillActivation | Before activation (vetoable) |
skill.loaded | SkillContent | Skill content loaded |
skill.deactivate | SkillRef | Skill deactivation requested |
skill.resource.read | SkillResourceReq | Resource file requested |
skill.resource.result | SkillResourceData | Resource content returned |
Payloads
SkillCatalog
| Field | Type | Description |
|---|---|---|
Skills | []SkillSummary | All discovered skills |
SkillSummary
| Field | Type | Description |
|---|---|---|
Name | string | Skill name |
Description | string | What the skill does |
Location | string | Directory path |
Scope | string | "project", "user", "builtin", "config" |
SkillContent
| Field | Type | Description |
|---|---|---|
Name | string | Skill name |
Body | string | Markdown content |
Resources | []string | Available resource files |
Scope | string | Skill scope |
BaseDir | string | Skill directory |
Schema Events
| Event Type | Payload | Description |
|---|---|---|
schema.register | SchemaRegistration | Register an output schema with the registry |
schema.deregister | SchemaDeregistration | Remove a schema from the registry |
Payloads
SchemaRegistration
| Field | Type | Description |
|---|---|---|
Name | string | Schema name (e.g. "skill.code_review.output") |
Schema | map[string]any | JSON Schema definition |
Source | string | Plugin ID that registered it |
SchemaDeregistration
| Field | Type | Description |
|---|---|---|
Name | string | Schema name to remove |
Source | string | Plugin ID that registered it |
Session Events
| Event Type | Payload | Description |
|---|---|---|
session.file.created | SessionFile | New file in session |
session.file.updated | SessionFile | File updated in session |
SessionFile
| Field | Type | Description |
|---|---|---|
Path | string | File path within session |
Action | string | "created" or "updated" |
Size | int | File size in bytes |
Cancellation Events
| Event Type | Payload | Description |
|---|---|---|
cancel.request | CancelRequest | User requested cancellation |
cancel.active | CancelActive | Cancellation broadcast |
cancel.complete | CancelComplete | Cancellation processed |
cancel.resume | CancelResume | Resume after cancellation |
CancelRequest
| Field | Type | Description |
|---|---|---|
TurnID | string | Turn to cancel |
Source | string | Who requested: "tui", "browser", etc. |
Embeddings Events
| Event Type | Payload | Description |
|---|---|---|
embeddings.request | *EmbeddingsRequest | Embed a batch of texts. Pointer-fill — provider mutates in place. |
Payloads
EmbeddingsRequest
| Field | Type | Description |
|---|---|---|
Texts | []string | Batch of strings to embed (input). Used when Inputs is empty — back-compat with text-only adapters. |
Inputs | []EmbeddingsInput | Polymorphic batch (text + image inputs) for multimodal-aware providers. When non-empty, providers consume Inputs and ignore Texts. |
Model | string | Requested model; provider may echo back actual model used. |
Dimensions | int | Optional truncation hint. Zero = provider default. |
Vectors | [][]float32 | Result vectors, in input order (output). |
Provider | string | Plugin ID of the adapter that answered (output). |
Usage | EmbeddingsUsage | Token usage when reported by the provider (output). |
Error | string | Non-empty on failure (output). |
EmbeddingsInput
| Field | Type | Description |
|---|---|---|
Text | string | Text snippet. Mutually exclusive with Image and ImageURI. |
Image | []byte | Inline image bytes. Mutually exclusive with Text and ImageURI. Requires MimeType. |
ImageURI | string | Image reference: nexus-blob:<sha> (engine blob store) or external URL. Mutually exclusive with Text and Image. |
MimeType | string | IANA media type (e.g. image/png). Required when Image is set; recommended for ImageURI. |
Adapters that don’t support image inputs must return a clear error when
they encounter an image-bearing input rather than silently downgrading.
See nexus.embeddings.cohere_multimodal for a multimodal-aware reference
adapter.
EmbeddingsUsage
| Field | Type | Description |
|---|---|---|
PromptTokens | int | Tokens consumed by the input. |
TotalTokens | int | Total billable tokens. |
Vector Store Events
| Event Type | Payload | Description |
|---|---|---|
vector.upsert | *VectorUpsert | Insert or replace docs in a namespace. |
vector.query | *VectorQuery | Nearest-neighbor lookup in a namespace. |
vector.delete | *VectorDelete | Remove docs by ID. |
vector.namespace.drop | *VectorNamespaceDrop | Remove an entire namespace (idempotent). |
All four are pointer-fill — adapter sets Provider / Error (and Matches on query) in place.
Payloads
VectorDoc
| Field | Type | Description |
|---|---|---|
ID | string | Stable identifier; upsert replaces by ID. |
Vector | []float32 | Embedding. Adapters may require unit-normalized. |
Content | string | Original text (optional but typically stored for re-ranking / display). |
Metadata | map[string]string | String-keyed metadata. |
VectorMatch
| Field | Type | Description |
|---|---|---|
ID | string | Doc ID. |
Content | string | Doc content. |
Metadata | map[string]string | Doc metadata. |
Similarity | float32 | Cosine similarity in [-1, 1]. |
VectorUpsert
| Field | Type | Description |
|---|---|---|
Namespace | string | Target namespace (input). |
Docs | []VectorDoc | Docs to upsert (input). |
Provider | string | Adapter plugin ID (output). |
Error | string | Non-empty on failure (output). |
VectorQuery
| Field | Type | Description |
|---|---|---|
Namespace | string | Target namespace (input). |
Vector | []float32 | Query vector (input). |
K | int | Max results (input). |
Filter | map[string]string | Exact-match metadata filter (input, optional). |
Matches | []VectorMatch | Hits sorted by similarity desc (output). |
Provider | string | Adapter plugin ID (output). |
Error | string | Non-empty on failure (output). |
VectorDelete
| Field | Type | Description |
|---|---|---|
Namespace | string | Target namespace (input). |
IDs | []string | Doc IDs to remove. Unknown IDs are ignored (input). |
Provider | string | Adapter plugin ID (output). |
Error | string | Non-empty on failure (output). |
VectorNamespaceDrop
| Field | Type | Description |
|---|---|---|
Namespace | string | Namespace to drop (input). |
Provider | string | Adapter plugin ID (output). |
Error | string | Non-empty on failure (output). |
RAG Events
| Event Type | Payload | Description |
|---|---|---|
rag.ingest | *RAGIngest | Ingest one file. Pointer-fill. |
rag.ingest.delete | *RAGIngestDelete | Drop a file’s chunks. Pointer-fill. |
rag.ingest.result | *RAGIngest | Notification emitted after rag.ingest completes (read-only). |
memory.vector.store | *VectorMemoryStore | Explicit store into vector memory. Pointer-fill. |
Payloads
RAGIngest
| Field | Type | Description |
|---|---|---|
Path | string | File path (input). |
Namespace | string | Target namespace (input). |
Metadata | map[string]string | Optional metadata merged into every chunk (input). |
Provider | string | Ingest plugin ID (output). |
Chunks | int | Number of chunks upserted (output). |
SkippedCached | int | How many of those came from the embedding cache (output). |
Error | string | Non-empty on failure (output). |
RAGIngestDelete
| Field | Type | Description |
|---|---|---|
Path | string | File path (input). |
Namespace | string | Target namespace (input). |
Provider | string | Ingest plugin ID (output). |
Deleted | int | Reserved; currently always zero (output). |
Error | string | Non-empty on failure (output). |
VectorMemoryStore
| Field | Type | Description |
|---|---|---|
Content | string | Content to store (input). |
Source | string | Short label recorded as metadata (e.g. user, agent, compaction) (input). |
Metadata | map[string]string | Extra metadata merged into the stored doc (input). |
Provider | string | Plugin ID of the memory.vector plugin (output). |
Error | string | Non-empty on failure (output). |
Delegate Events
Emitted by nexus.agent.delegate when a parent agent invokes a posture via
the delegate tool. Payloads are map[string]any records — see
pkg/delegate.Runtime for the canonical fields.
| Event Type | Payload | Description |
|---|---|---|
delegate.start | map | Sub-session is starting. Keys: sub_session_id, posture, posture_ver, task, parent_turn, depth. |
delegate.complete | map | Sub-session finished. Keys: sub_session_id, posture, posture_ver, status (success/partial/error/timeout/cancelled/cache_hit), error, tokens_used, tool_calls_used, elapsed_ms, result, depth. |
Posture Events
Emitted by nexus.agent.postures as posture YAML loads or changes.
| Event Type | Payload | Description |
|---|---|---|
posture.registered | map | A posture was installed or updated. Keys: name, version, source (file path or scan dir). |
posture.removed | map | A posture was removed (file deleted / load error). Keys: name. |
Scene Events
Emitted by nexus.scene on every mutation. agent_id on the payload comes
from Event.Causation.AgentID — sub-agent contributions remain attributable
in the journal.
| Event Type | Payload | Description |
|---|---|---|
scene.created | map | New Scene created. Keys: session_id, scene_id, schema, version, agent_id, content (initial content). |
scene.patched | map | Scene content updated. Keys: session_id, scene_id, version, agent_id, content (full post-merge content). |
scene.deleted | map | Scene removed. Keys: session_id, scene_id, agent_id. |
Tool Stream Events
Emitted by pkg/streamtool.Bridge while a ChannelTool runs. Consumers
that only need the final value still subscribe to tool.result; UIs and
observability collectors that want incremental progress subscribe here.
| Event Type | Payload | Description |
|---|---|---|
tool.stream.progress | map | Status-only update from a streaming tool. Keys: tool_name, tool_id, turn_id, sequence, progress (0.0–1.0 or -1), payload. |
tool.stream.partial | map | Incremental data the consumer can render now. Keys: tool_name, tool_id, turn_id, sequence, payload. |
Thinking Events
| Event Type | Payload | Description |
|---|---|---|
thinking.step | ThinkingStep | Agent reasoning step |
ThinkingStep
| Field | Type | Description |
|---|---|---|
TurnID | string | Associated turn |
Source | string | Plugin that generated this |
Content | string | Thinking content |
Phase | string | "planning", "executing", "reasoning" |
Timestamp | time.Time | When this occurred |
ICM Workflow Events
Emitted by nexus.workflows.icm on top of the generic plan.created /
plan.progress surface. Every payload carries a _schema_version field
(constants in plugins/workflows/icm/icmtypes/types.go).
| Event Type | Payload | Description |
|---|---|---|
icm.run.started | ICMRunStarted | Workspace loaded; run created; before stage 1 dispatches. |
icm.run.completed | ICMRunCompleted | All stages finished without halt. |
icm.run.halted | ICMRunHalted | Stage error policy halted, gate rejected, or run context cancelled. |
icm.stage.started | ICMStageStarted | Stage execution begins, before any human_gate: start. |
icm.stage.completed | ICMStageCompleted | Artifact written; any end gate resolved. |
icm.stage.failed | ICMStageFailed | Stage halted (dispatch error, rejected gate, or loop.on_exhausted: error). |
icm.stage.iteration | ICMStageIteration | One per loop iteration, immediately before the iteration’s invocation. |
icm.turn | ICMTurn | After each turn within an invocation (richer-UI feed; basic UIs already see plan.progress). |
icm.fanout.item | ICMFanoutItem | Item lifecycle boundary in a fan-out stage (active → completed | failed). |
icm.predicate.failed | ICMPredicateFailed | Any predicate evaluation returns verdict=false. Pass paths are not emitted. |
ICMRunStarted
| Field | Type | Description |
|---|---|---|
RunID | string | Run identifier (r_<id>); also the on-disk dir under the plugin’s session data dir. |
InstanceID | string | Plugin instance ID (nexus.workflows.icm or nexus.workflows.icm/<suffix>). |
WorkspaceRoot | string | Absolute path to the loaded workspace. |
WorkspaceName | string | Last folder name of the workspace path. |
Stages | int | Stage count. |
ICMRunCompleted
| Field | Type | Description |
|---|---|---|
RunID | string | Run identifier. |
StagesRun | int | Number of stages that completed. |
AggregatePath | string | Optional run aggregate path (set when a top-level aggregate is produced). |
ElapsedSeconds | int64 | Wall-clock seconds from icm.run.started. |
ICMRunHalted
| Field | Type | Description |
|---|---|---|
RunID | string | Run identifier. |
Reason | string | Free-text halt reason. |
HaltedAtStage | string | Stage ID where the halt fired (empty for pre-stage halts). |
Cancelled | bool | true when the halt was a context cancellation rather than a gate reject. |
ElapsedSeconds | int64 | Wall-clock seconds from icm.run.started. |
ICMStageStarted
| Field | Type | Description |
|---|---|---|
RunID | string | Run identifier. |
StageID | string | Stage folder name (e.g. 02_brief). |
PostureName | string | Derived posture name registered for this stage (icm.<runID>.<stage_id>). |
Order | int | 1-based stage order in the workspace. |
ICMStageCompleted
| Field | Type | Description |
|---|---|---|
RunID | string | Run identifier. |
StageID | string | Stage folder name. |
ArtifactPath | string | Path to the final artifact. |
IterationsRun | int | Loop iterations executed (0 for non-looping stages). |
ConvergenceFailed | bool | true when the loop exhausted without satisfying until. |
ICMStageFailed
| Field | Type | Description |
|---|---|---|
RunID | string | Run identifier. |
StageID | string | Stage folder name. |
Reason | string | Free-text failure reason. |
ICMStageIteration
| Field | Type | Description |
|---|---|---|
RunID | string | Run identifier. |
StageID | string | Stage folder name. |
ItemID | string | Fan-out item ID (empty for non-fan-out stages). |
Iteration | int | 1-based iteration index. |
MaxIterations | int | loop.max_iterations. |
ExitFailures | []ConditionResult | Previous iteration’s failing until predicates (empty on iteration 1). |
ICMTurn
| Field | Type | Description |
|---|---|---|
RunID | string | Run identifier. |
StageID | string | Stage folder name. |
ItemID | string | Fan-out item ID (empty for non-fan-out stages). |
Iteration | int | Loop iteration index (0 for non-looping stages). |
Turn | int | 1-based turn index. |
MaxTurns | int | turns.max. |
LastFailures | []ConditionResult | Previous turn’s failing validators (drives until_valid retry). |
ICMFanoutItem
| Field | Type | Description |
|---|---|---|
RunID | string | Run identifier. |
StageID | string | Stage folder name. |
ItemID | string | Per-item ID (from fan_out.item_id gojq or fallback index). |
Index | int | 1-based item index. |
Total | int | Total items in the fan-out. |
Status | string | "active" | "completed" | "failed". |
Error | string | Failure reason when Status="failed". |
ICMPredicateFailed
| Field | Type | Description |
|---|---|---|
RunID | string | Run identifier. |
StageID | string | Stage folder name. |
ItemID | string | Fan-out item ID (empty for non-fan-out stages). |
Container | string | "output.validators" | "loop.until" | "verifier". |
PredicateName | string | Predicate name: (or <type>_<index> fallback). |
PredicateType | string | "schema" | "regex" | "native" | "command" | "llm" | "human". |
Feedback | string | Predicate-supplied failure feedback. |
ConditionResult (shared by ICMStageIteration.ExitFailures and ICMTurn.LastFailures)
| Field | Type | Description |
|---|---|---|
Type | string | Predicate type. |
Name | string | Predicate name. |
Verdict | string | "pass" | "fail". |
Feedback | string | Predicate-supplied feedback. |
Score | *float64 | Optional numeric score (LLM judges typically populate this). |
Configuration Reference
Authoritative reference for every YAML configuration key recognized by the Nexus
engine and its plugins. Tables are derived from each plugin’s Init() (and any
parser helpers it calls) — not from prose docs. If you change a config key in
source, update this page in the same commit.
Maintenance rule. Any addition, removal, rename, default change, or type change to a configuration key — at the engine level or in any plugin — must be reflected in this file. Per-plugin pages may add narrative, but this page is the single source of truth.
Conventions
- Type column uses YAML-native names (
string,int,bool,float,duration,list,map).durationis parsed by Go’stime.ParseDuration(e.g.30s,1m,5m). - Default column shows the value used when the key is absent.
*(none)*means no value is set;*(required)*means the plugin will fail to start without it;*(env)*means the value is read from an environment variable. - Path expansion. Every filesystem path supplied via configuration —
engine
sessions.root, pluginpath,dir,file,cache_dir,scan_paths,system_prompt_file,schema_file,patterns_file,word_files,base_dir,working_dir,path_dirs, ingestwatch[].path, etc. — is funneled throughengine.ExpandPath. Bare~resolves to the user’s home directory;~/fooresolves to<home>/foo. Relative paths are resolved against the engine’s working directory and not modified.
Validation
Configuration is validated strictly at boot, after YAML loading and before
any plugin’s Init() runs. The engine compiles the top-level schema plus every
active plugin’s schema (when the plugin advertises one) and validates each
config block against it. Validation errors abort boot — they are aggregated
across every plugin so a single boot attempt surfaces every issue at once,
sorted, with the offending key path on each line:
config validation failed:
- plugins.nexus.gate.token_budget.warning_threshold: unknown key "warning_threshold"
- plugins.nexus.tool.web.timeoutt: unknown key "timeoutt" (did you mean "timeout"?)
2 errors; aborting boot
Unknown keys fail boot. Plugin schemas declare additionalProperties: false
at every object level, so a typo in a key name is a hard error rather than a
silently-ignored value. The validator emits a “did you mean” suggestion when a
close match exists in the schema’s declared keys (Levenshtein distance ≤ 3 and
strictly less than half the candidate’s length). This guarantees that the only
keys you can write in YAML are the ones the engine actually reads — silent
typos are not possible for plugins that ship a schema.
Deprecated keys log a warning but do not fail boot. Schemas mark these with
deprecated: true. Move off the key when convenient; deprecation warnings are
the engine’s signal that a future minor release may delete the shim.
Plugins without a schema (legacy or third-party) are skipped with a debug
log. They are not blocked from booting. The engine’s own top-level keys
(core, engine, capabilities, plugins.active, journal) are always
validated against the engine schema.
Schema authoring
Plugins expose their schema by implementing the optional
engine.ConfigSchemaProvider interface (defined in
pkg/engine/config_validation.go). Conventions:
- The schema lives at
plugins/<id>/schema.jsonand is//go:embed-ed by a smallschema.goin the same package; the plugin’sConfigSchema()method returns the embedded bytes. - Schemas declare
"$schema": "https://json-schema.org/draft/2020-12/schema"and must set"additionalProperties": falseon every object level so unknown keys are caught. - Mark deprecated keys with
"deprecated": true(a sibling oftype,description, etc.). The validator walks the schema once and warns when a deprecated key is present in the user’s config. - The schema is canonical for the plugin’s config map — what
Init()actually reads. Schema drift from real consumption is a bug; the smoke test inpkg/engine/configs_smoke_test.govalidates every shipped YAML against every active plugin’s schema and is the canary for that drift.
Top-level structure
core: # engine-level settings
engine: # engine resilience knobs (shutdown drain, ...)
capabilities: # capability → plugin-ID pinning (optional)
journal: # durable per-session event log (always on; tunables only)
plugins:
active: [] # plugin IDs (with optional /instance suffix)
<plugin.id>: # per-plugin config map
key: value
| Key | Type | Default | Description |
|---|---|---|---|
core | map | (see core section) | Engine-level settings (logging, sessions, models). |
engine | map | (see engine section) | Engine resilience knobs (shutdown drain budget). |
capabilities | map | (empty) | Pin capability names to specific provider plugin IDs (e.g. search.provider: nexus.search.brave). Overrides default resolution (first active provider). |
journal | map | (see journal section) | Tuning knobs for the always-on event journal. The journal cannot be disabled. |
plugins.active | list | [] | Plugin IDs to activate. Order doesn’t matter — Requires() and Dependencies() are resolved automatically. Multi-instance plugins use a slash suffix: nexus.agent.subagent/researcher. |
plugins.<id> | map | (none) | Per-plugin configuration. Keys other than active are treated as plugin IDs. |
Core engine
core
| Key | Type | Default | Description |
|---|---|---|---|
log_level | string | info | Global log level: debug, info, warn, error. |
tick_interval | duration | 1s | Interval for the internal core.tick heartbeat. |
max_concurrent_events | int | 100 | Maximum concurrent event handlers across the bus. |
logging.bootstrap_stderr | bool | false | Register a stderr sink at engine construction so pre-sink slog records appear on the terminal. Rejected at validation time when any of nexus.io.tui, nexus.io.browser, nexus.io.wails is active. |
logging.buffer_size | int | DefaultLogRingSize | Capacity of the log/event ring buffers. Values <= 0 use the default. |
sessions.root | string | ~/.nexus/sessions | Base directory for session workspaces. |
sessions.retention | string | 30d | Retention policy for old sessions. |
sessions.id_format | string | timestamp | Session ID format: timestamp, datetime_short. |
agent_id | string | (empty) | Partitions per-agent storage and other per-agent state. Set by multi-agent embedders (the desktop shell). Empty in CLI / single-agent embedders, which collapses agent-scope storage to app-scope. |
storage.root | string | ~/.nexus | Data root for app- and agent-scope per-plugin storage. App-scope .db files land at <root>/plugins/<pluginID>/store.db; agent-scope at <root>/agents/<agent_id>/plugins/<pluginID>/store.db. |
storage.busy_timeout_ms | int | 5000 | SQLite busy_timeout PRAGMA per handle (milliseconds). |
storage.cache_size_kb | int | 2048 | SQLite cache_size PRAGMA per handle (negative-form, in KiB). |
storage.pool_max_idle | int | 2 | *sql.DB.SetMaxIdleConns per handle. |
storage.pool_max_open | int | 4 | *sql.DB.SetMaxOpenConns per handle. |
models | map | (empty) | Model role registry — see core.models below. |
core.models
Maps role names → model configurations. Roles can be:
- single model — map with
provider,model,max_tokens, - fallback chain — list of single-model maps (tried in order on
non-retryable error or exhausted retries; coordinated by
nexus.provider.fallback), - fanout role — map with
fanout: trueand aproviders:list (dispatched in parallel bynexus.provider.fanout).
| Key (per role) | Type | Default | Description |
|---|---|---|---|
default | string | balanced | Name of the role used when a request specifies no role. |
<role>.provider | string | (required) | Plugin ID of the LLM provider (e.g. nexus.llm.anthropic). |
<role>.model | string | (required) | Model identifier as understood by the provider. |
<role>.max_tokens | int | (provider default) | Maximum response tokens. |
<role>.fanout | bool | false | If true, treat as fanout role; providers: list is dispatched in parallel. |
<role>.providers | list | (required if fanout: true) | List of model configs for fanout dispatch. |
Example:
core:
models:
default: balanced
reasoning:
provider: nexus.llm.anthropic
model: claude-opus-4-7
max_tokens: 16384
balanced:
- provider: nexus.llm.anthropic
model: claude-sonnet-4-6
max_tokens: 8192
- provider: nexus.llm.openai # fallback
model: gpt-4o
max_tokens: 8192
panel:
fanout: true
providers:
- provider: nexus.llm.anthropic
model: claude-sonnet-4-6
- provider: nexus.llm.gemini
model: gemini-2.5-pro
A role missing from core.models whose name contains a hyphen is treated as a
raw model ID with no provider (backward-compat). Otherwise resolution fails.
Engine
Engine-level resilience knobs that don’t belong under core (which is for
runtime settings like log level and tick interval) and aren’t journal-specific.
engine:
shutdown:
drain_timeout: 30s
config_watch:
enabled: false # opt in to fsnotify hot-reload
debounce: 1s
| Key | Type | Default | Description |
|---|---|---|---|
shutdown.drain_timeout | duration | 30s | Maximum time the engine waits for in-flight bus dispatches to complete on Shutdown before the plugin teardown phase begins. Acts as a floor: a plugin implementing engine.DrainOverride can extend (but not shorten) the effective window so a single batch poller or MCP server can flush without operators bumping the global setting. Sub-second values are accepted but rarely useful. |
config_watch.enabled | bool | false | When true, the CLI starts an fsnotify watcher on the -config path and calls Engine.ReloadConfig on every debounced edit. Default off because production deploys often touch the config file mid-rollout and operators rarely want auto-reload during such windows. SIGHUP and the browser admin endpoint remain available regardless. |
config_watch.debounce | duration | 1s | Window across which fsnotify write/create events on the same file are coalesced into a single reload. Editors commonly fire two or three Write events per save; 1s is well above that storm but short enough that the operator perceives the reload as instant. |
Hot reload
The engine supports applying a new config to a running process without restarting any unaffected plugins. The flow is two-phase:
- Validate phase (atomic). The new config is run through the same
schema validation as boot; capability provider identity is pinned (a
plugin advertising
memory.historycannot be replaced by another provider mid-flight); the diff between current and new active sets is computed. Any failure here returns an error and leaves the engine untouched. - Apply phase (best-effort). The diff is walked: removed plugins
shut down, in-place reloaders accept their new config, restart-only
plugins are torn down and re-initialized, and added plugins run the
full lifecycle. Engine-level fields (
drain_timeout,config_watch) are swapped atomically before per-plugin work.
A reload that fails midway through the apply phase logs the error and
surfaces it to the caller; the engine is left in a best-effort consistent
state. True rollback is not attempted because “undoing” a Shutdown is
not generally possible.
Triggers:
SIGHUPto the CLI process re-reads the original-configpath and applies the result. (SIGINTandSIGTERMcontinue to terminate the engine.)POST /admin/reload-configon the browser plugin’s HTTP server. Body is empty (re-read original path) or{"path": "/abs/path/to/new.yaml"}for ad-hoc paths. No auth layer yet — alpha-only; front with a reverse proxy if exposed.fsnotifywatcher on the original path. Off by default; opt in viaengine.config_watch.enabled: true. Debounced byengine.config_watch.debounceto absorb editor save bursts.
Plugin opt-in: ConfigReloader. A plugin that implements
type ConfigReloader interface {
ReloadConfig(old, new map[string]any) error
}
receives the in-place hook on a config-only change instead of going
through Shutdown → Init → Ready. Implementations must be
transactional from the bus’s perspective: returning an error must leave
the plugin in its prior state. Plugins that don’t implement the
interface go through the full restart path; both work, the in-place
hook is just an optimization for plugins where a restart would drop
in-progress work (active streams, bound listeners) the operator would
notice.
Capability provider identity is pinned. Hot-reload rejects any
config change that would resolve a currently-bound capability (e.g.
memory.history) to a different concrete provider. The session has
in-flight state bound to the existing provider; a silent swap would
strip the operator’s history. Restart the engine to change capability
providers.
Journal
The journal is the engine’s always-on durable event log. Every dispatched
bus event lands as a JSONL envelope at
<sessions.root>/<session_id>/journal/events.jsonl with a monotonic per-
session sequence number, the parent dispatch’s seq (best-effort), and the
veto outcome for before:* events. The journal cannot be disabled — it is
core infrastructure underpinning crash recovery, deterministic replay, and
observability projections.
journal:
fsync: turn-boundary # turn-boundary | every-event | none
retain_days: 30
rotate_size_mb: 4
exclude_events: # event types the journal must not record
- core.tick
| Key | Type | Default | Description |
|---|---|---|---|
fsync | string | turn-boundary | Disk-flush policy. turn-boundary fsyncs once per agent.turn.end (good throughput, recovers to last completed turn). every-event fsyncs after every envelope (strongest crash guarantee). none skips explicit fsync (test-only). |
retain_days | int | 30 | Age in days past which a session’s journal directory is removed on engine boot. 0 disables sweeping. In-flight sessions are never touched. |
rotate_size_mb | int | 4 | Active segment size threshold (MiB). When agent.turn.end lands and the active segment exceeds this, it is compressed into events-NNN.jsonl.zst and the active segment is truncated. |
exclude_events | []string | ["core.tick"] | Event types the journal must not record. Excluded events still dispatch to bus subscribers (otel, eval, custom plugins); only the durable log skips them, and their seq is not consumed so on-disk envelopes stay gap-free. Default suppresses the engine heartbeat. Set to [] to record everything. |
Disk layout
~/.nexus/sessions/<id>/journal/
header.json # schema_version, created_at, fsync_mode, session_id
events.jsonl # active segment (append-only)
events-001.jsonl.zst # rotated, zstd-compressed
events-002.jsonl.zst
cache/ # args-keyed tool result cache
<tool_id>/
<sha256>.json # one file per (tool, canonical_args) pair
Tool result cache
Every tool.invoke / tool.result pair is recorded under journal/cache/
keyed by sha256(tool_id || canonical_args). During replay, the
short-circuit helper consults the cache first — same args produce the
same result regardless of dispatch order, so replay survives memory-state
divergence between the original and replay runs. On cache miss, the
helper falls back to the FIFO stash seeded by the coordinator from the
journal’s tool.result events.
The canonical args hash sorts keys recursively, so two semantically equivalent argument maps with different key iteration order map to the same cache file.
Journal projections
Plugins that need to derive files from event streams register a
projection via Journal.SubscribeProjection(types, handler). The
handler fires on the writer’s drain goroutine after the envelope lands
on disk, so derived files always lag the durable record by zero
envelopes. Projections also drive post-mortem regeneration:
journal.ProjectFile(dir, types, handler) walks an existing journal
and feeds the same handler — a derived file deleted between runs will
rebuild from the journal at the next boot.
The shipped nexus.observe.thinking plugin no longer uses this hook
itself: its thinking.step and plan.progress events are already in
the journal alongside every other event, so the plugin acts purely as
a UI feature flag for shells that want to surface thinking. Custom
plugins that need their own derived view should adopt the projection
pattern.
Deterministic replay
bin/nexus -config <path> -replay <session-id> re-runs a journaled session
without external calls. The Anthropic / OpenAI / Gemini providers and the
side-effecting tools (shell, file, code_exec, web, pdf, ask_user),
along with the nexus.control.hitl plugin’s hitl.responded events,
detect replay mode and emit the next journaled llm.response /
tool.result from a FIFO stash seeded from the source journal in seq
order. The replay coordinator drives io.input events; the live agent
loop reacts as if the inputs were fresh.
Replay produces functional equivalence (same final assistant outputs,
same memory state) rather than byte-identical event re-emission. Side-
effecting plugins expose a LiveCalls() counter that stays at zero
during replay — tests assert this to catch regressions.
Crash recovery
bin/nexus -config <path> -recall <session-id> resumes a session whose
journal ended mid-turn. The engine detects the partial turn via
coord.IsPartialTurn(), restores conversational memory from
context/conversation.jsonl, and re-emits the io.input that started
the unfinished turn so the live ReAct loop restarts it.
Phase 3 minimum: the partial turn restarts from scratch rather than
mid-step resume. Mid-step resume (replay-stash-short-circuit the
completed prefix, then live-fire the unanswered tool.invoke) is a
future PR. Re-firing the input after a crash mints fresh seqs that
append to the same journal alongside the orphaned partial-turn events;
a subsequent --replay of a crash-resumed session sees both the
orphaned and the re-fired io.input.
Plugin activation
plugins:
active:
- nexus.io.tui
- nexus.llm.anthropic
- nexus.agent.react
- nexus.agent.subagent/researcher # multi-instance suffix
- nexus.agent.subagent/writer
Each entry in active may be followed by /<instance> to register a second
copy of a multi-instance plugin (e.g. subagents). The base plugin ID + instance
suffix forms the full ID used for per-plugin config:
plugins:
nexus.agent.subagent/researcher:
model_role: reasoning
tool_name: spawn_researcher
A plugin with no configuration still parses cleanly without an explicit entry, but you may declare an empty map for clarity:
plugins:
nexus.tool.file: {}
nexus.observe.thinking: {}
Agents
nexus.agent.react
Source: plugins/agents/react/plugin.go.
| Key | Type | Default | Description |
|---|---|---|---|
planning | bool | false | Emit plan.request before iterating; defers to a planner plugin. |
model_role | string | (default) | Role name from core.models. |
system_prompt | string | (none) | Inline system prompt (overrides system_prompt_file). |
system_prompt_file | string | (none) | Path to file containing the system prompt. |
parallel_tools | bool | false | Run multiple tool calls from a single LLM response in parallel. |
max_concurrent | int | 4 | Concurrency ceiling when parallel_tools: true. |
tool_choice | string | map | (none) | Constrain tool selection. See “Tool choice” below. |
Iteration limits are not an agent setting — enforce them with
nexus.gate.endless_loop. ReAct’s required capabilities (memory.history,
control.cancel, tool.catalog) are auto-activated by Requires() when no
provider for those capabilities is already in plugins.active.
Tool choice
tool_choice accepts:
- a string shorthand —
tool_choice: required(orauto,any,none), - a map —
tool_choice: { mode: tool, name: read_file }, - a sequence —
tool_choice: { sequence: [{ mode: required }, { mode: auto }] }applied per iteration; the last entry sticks.
Dynamic overrides arrive via agent.tool_choice events with duration: once
(consumed after one iteration) or sticky (until cleared).
nexus.agent.planexec
Source: plugins/agents/planexec/plugin.go.
| Key | Type | Default | Description |
|---|---|---|---|
execution_model_role | string | balanced | Role used to execute each step. |
replan_on_failure | bool | true | Re-plan remaining work when a step fails (max 2 replans). |
approval | string | never | Plan approval mode: always (block until user approves) or never. |
system_prompt | string | (none) | Inline system prompt. |
system_prompt_file | string | (none) | Path to file containing the system prompt. |
Step iteration and step counts are managed internally by the planner plugin
that emits plan.result; they are not configured here.
nexus.agent.subagent
Source: plugins/agents/subagent/plugin.go. Multi-instance: register multiple
copies via nexus.agent.subagent/<suffix>.
| Key | Type | Default | Description |
|---|---|---|---|
model_role | string | (default) | Role used for the subagent’s LLM calls. |
system_prompt | string | (none) | Inline system prompt. |
system_prompt_file | string | (none) | Path to file containing the system prompt. |
tool_name | string | spawn_<suffix> or spawn_subagent | Name of the spawn tool registered with the catalog. |
tool_description | string | (auto) | Description shown to the parent agent. |
Depends on nexus.agent.react.
nexus.agent.orchestrator
Source: plugins/agents/orchestrator/plugin.go.
| Key | Type | Default | Description |
|---|---|---|---|
max_workers | int | 5 | Concurrency cap for worker subagents. |
max_subtasks | int | 8 | Hard cap on subtasks (excess truncated). |
worker_max_iterations | int | 10 | Iteration limit per worker (enforced via gate.endless_loop). |
orchestrator_model_role | string | reasoning | Role used for decomposition. |
worker_model_role | string | balanced | Role used by workers. |
synthesis_model_role | string | balanced | Role used for the final synthesis. |
fail_fast | bool | false | Cancel remaining workers on the first failure. |
system_prompt | string | (none) | Inline system prompt. |
system_prompt_file | string | (none) | Path to file containing the system prompt. |
Depends on nexus.agent.subagent.
nexus.agent.postures
Source: plugins/agents/postures/plugin.go. Loads AgentPosture YAML files
from the configured directories and advertises the posture.registry
capability consumed by nexus.agent.delegate. fsnotify watches each directory
for live edits; active sub-sessions keep their old posture, new invocations
resolve the new one. See Postures for the
AgentPosture schema.
| Key | Type | Default | Description |
|---|---|---|---|
scan_dirs | []string | [] | Directories scanned for *.yaml / *.yml posture files. Each entry runs through engine.ExpandPath so ~ expands. |
debounce_ms | int | 250 | fsnotify reload debounce in milliseconds. |
nexus.agent.delegate
Source: plugins/agents/delegate/plugin.go. Exposes the delegate tool that
the LLM calls to invoke a registered posture. Requires the posture.registry
capability (typically provided by nexus.agent.postures). Enforces budgets
and recursion depth defined on each posture; results cached by posture
version + task + context hash so posture edits invalidate stale entries.
See Sub-agent delegation.
| Key | Type | Default | Description |
|---|---|---|---|
max_depth | int | 3 | Hard cap on sub-agent recursion depth across all postures. Individual postures may set a lower cap via max_recursion_depth. |
cache_size | int | 256 | Capacity of the in-process LRU result cache (entries, not bytes). Zero disables eviction; the cache grows unbounded. |
cache | bool | true | Set false to disable result caching entirely. |
nexus.agent.agui_remote
Source: plugins/agents/aguiremote/plugin.go. Surfaces one or more remote
AG-UI agents as delegate/subagent targets. Each configured agent registers an
LLM-facing tool (default delegate_agui_<name>); when the parent agent calls
it, the plugin builds an AG-UI RunAgentInput from the delegated task, runs the
remote agent over the AG-UI wire (HTTP POST + SSE) via the reusable AG-UI
client, maps the remote run’s event stream onto the Nexus bus (text deltas →
io.output; tool activity + message boundaries → subagent.*), and returns the
remote run’s terminal outcome as the tool result. Failures (remote RunError,
timeout, transport error, auth rejection, unresolved interrupt) surface as a
clean tool error. Per-call timeout enforces budget; results are cached by
endpoint + task + context hash. See Sub-agent delegation.
| Key | Type | Default | Description |
|---|---|---|---|
agents | list | (required) | Non-empty list of remote AG-UI agents to expose. Each entry is a mapping (see below). |
timeout_seconds | int | 120 | Default per-call timeout (seconds) applied to every remote agent. Overridable per agent and per call. |
cache_size | int | 128 | Capacity of the in-process LRU result cache (entries, not bytes). Zero disables eviction; the cache grows unbounded. |
cache | bool | true | Set false to disable result caching entirely. |
Each agents[] entry:
| Key | Type | Default | Description |
|---|---|---|---|
name | string | (required) | Human-friendly identifier; used to derive the default tool name. |
endpoint | string | (required) | Full AG-UI POST endpoint URL (e.g. https://host/agui). |
tool_name | string | delegate_agui_<name> | Override the LLM-facing tool name. |
description | string | (auto) | Override the tool description shown to the LLM. |
bearer_token | string | (none) | Static bearer token for the Authorization header. Prefer bearer_token_env. |
bearer_token_env | string | (none) | Name of an environment variable holding the bearer token. Read at Init; used only when bearer_token is unset. |
timeout_seconds | int | (plugin default) | Per-agent default timeout (seconds), overriding the plugin-level timeout_seconds. |
nexus.scene
Source: plugins/scene/plugin.go. Owns the per-session Scene store and
registers the scene_create / scene_patch / scene_get / scene_list /
scene_delete tools. Every patch is journaled to
<session>/plugins/nexus.scene/scenes.jsonl so the replay primitive can
reconstruct historical scene state. See Scenes.
No config keys today; activate the plugin in plugins.active and the
default tools register at boot.
LLM providers
All providers share the same retry block schema (see “Retry” subtable below).
nexus.llm.anthropic
Source: plugins/providers/anthropic/plugin.go + auth.go, pricing.go,
cache.go, thinking.go, multimodal.go, citations.go,
structured_outputs.go, files.go, retry.go.
| Key | Type | Default | Description |
|---|---|---|---|
debug | bool | false | Persist request/response bodies to the session for debugging. |
auth_mode | string | api_key | One of api_key, bedrock, vertex. |
api_key | string | (env) | Direct API key (used when auth_mode: api_key). |
api_key_env | string | ANTHROPIC_API_KEY | Environment variable to read the API key from. |
bedrock.region | string | (env AWS_REGION) | AWS region for Bedrock. |
bedrock.access_key_id | string | (env AWS_ACCESS_KEY_ID) | AWS access key. |
bedrock.access_key_id_env | string | AWS_ACCESS_KEY_ID | Override the env var name. |
bedrock.secret_access_key | string | (env AWS_SECRET_ACCESS_KEY) | AWS secret. |
bedrock.secret_access_key_env | string | AWS_SECRET_ACCESS_KEY | Override the env var name. |
bedrock.session_token | string | (env AWS_SESSION_TOKEN) | Optional STS session token. |
bedrock.session_token_env | string | AWS_SESSION_TOKEN | Override the env var name. |
vertex.project / project_id | string | (env GOOGLE_CLOUD_PROJECT) | GCP project. |
vertex.region / location | string | us-east5 | Vertex region. |
vertex.sa_key_file / service_account_json | string | (env GOOGLE_APPLICATION_CREDENTIALS) | Path to the service-account JSON. |
vertex.sa_key_file_env / service_account_json_env | string | GOOGLE_APPLICATION_CREDENTIALS | Override the env var name. |
cache.enabled | bool | false | Enable prompt caching. |
cache.system | bool | true | Mark the system prompt for caching when enabled. |
cache.tools | bool | true | Mark the tools array for caching when enabled. |
cache.message_prefix | int | 0 | Number of leading user messages to mark for caching. |
cache.ttl | string | 5m | Cache TTL: 5m (ephemeral) or 1h (extended). |
thinking.enabled | bool | false | Enable extended thinking (Sonnet 4+, Opus 4+). |
thinking.budget_tokens | int | 8192 | Thinking token budget; -1 for dynamic, 0 to disable, 1024+ fixed. |
thinking.include_thoughts | bool | true | Surface thinking content via thinking.step events. |
multimodal.pdf_beta | bool | false | Send the pdfs-2024-09-25 beta header for legacy PDF support. |
citations.enabled | bool | false | Enable citations on document blocks. |
structured_outputs.mode | string | tool | tool (synthetic tool) or native (response_format). |
structured_outputs.beta_header | string | (none) | Optional beta header when mode: native. |
files.enabled | bool | false | Use the Anthropic Files API for oversize attachments. |
files.upload_threshold | int | 40960 | Minimum bytes before a file is uploaded; smaller files are inlined. |
files.cache_uploads | bool | true | Deduplicate identical uploads within a session. |
files.delete_on_shutdown | bool | false | Delete uploaded files when the engine shuts down. |
retry.* | — | (see Retry block) | Backoff configuration. |
pricing.<model>.* | map | (embedded table) | Override per-model token pricing — see “Pricing override”. |
Retry block (shared by all providers)
| Key | Type | Default | Description |
|---|---|---|---|
retry.enabled | bool | true | Enable retry on 5xx / 429. |
retry.max_retries | int | 3 | Maximum attempts. |
retry.initial_delay | duration | 1s | First backoff delay. |
retry.max_delay | duration | 60s | Maximum delay between retries. |
retry.backoff | string | exponential | constant, linear, exponential, or jitter. |
retry.multiplier | float | 2.0 | Multiplier for linear/exponential. |
retry.statuses | int list | Anthropic: [429, 500, 502, 503, 529]OpenAI/Gemini: [429, 500, 502, 503] | HTTP statuses to retry. |
Pricing override
| Key | Type | Default | Description |
|---|---|---|---|
pricing.<model>.input_per_million | float | (embedded default) | Cost per million input tokens. |
pricing.<model>.output_per_million | float | (embedded default) | Cost per million output tokens. |
pricing.<model>.cache_read_per_million | float | (derived from input) | Anthropic: 0.10×input; OpenAI: 0.5×input. |
pricing.<model>.cache_write_5m_per_million | float | (derived: 1.25×input) | Anthropic only. |
pricing.<model>.cache_write_1h_per_million | float | (derived: 2.0×input) | Anthropic only. |
nexus.llm.openai
Source: plugins/providers/openai/plugin.go.
| Key | Type | Default | Description |
|---|---|---|---|
debug | bool | false | Persist request/response bodies to the session. |
auth_mode | string | openai | openai, azure_key, or azure_aad. |
api_key | string | (env) | Direct API key (auth_mode: openai, also fallback for Azure Files API). |
api_key_env | string | OPENAI_API_KEY | Environment variable for the key. |
base_url | string | https://api.openai.com/v1 | Override for proxies / OpenAI-compatible endpoints. |
azure.endpoint | string | (required for Azure) | Azure OpenAI endpoint URL. |
azure.api_key | string | (env AZURE_OPENAI_API_KEY) | Azure key (when auth_mode: azure_key). |
azure.api_key_env | string | AZURE_OPENAI_API_KEY | Override the env var name. |
azure.api_version | string | 2024-12-01-preview | Azure OpenAI API version. |
azure.use_msi | bool | false | Use Managed Service Identity (auth_mode: azure_aad); otherwise falls back to Azure CLI auth. |
files.enabled | bool | false | Use the Files API. |
files.purpose | string | assistants | File purpose category. |
files.upload_threshold | int | 40960 | Minimum bytes to upload. |
files.cache_uploads | bool | true | Deduplicate within a session. |
files.delete_on_shutdown | bool | false | Delete on shutdown. |
reasoning.enabled | bool | false | Enable o-series reasoning. |
reasoning.budget_tokens | int | 10000 | Reasoning token budget. |
force_reasoning | bool | false | Force reasoning even for non-o-series models (experimental). |
multimodal.vision | bool | true | Allow image inputs (GPT-4V). |
retry.* | — | (shared Retry block) | Backoff configuration. |
pricing.<model>.* | map | (embedded table) | Override per-model pricing. |
nexus.llm.gemini
Source: plugins/providers/gemini/plugin.go.
| Key | Type | Default | Description |
|---|---|---|---|
debug | bool | false | Persist request/response bodies. |
api_key | string | (env GEMINI_API_KEY or GOOGLE_API_KEY) | Public Generative Language API key. |
api_key_env | string | (tries both env vars above) | Override the env var name. |
vertex.project | string | (env GOOGLE_CLOUD_PROJECT) | Project ID for Vertex AI. |
vertex.region / location | string | us-central1 | Vertex region. |
vertex.sa_key_file | string | (env GOOGLE_APPLICATION_CREDENTIALS) | Service-account JSON path. |
vertex.sa_key_file_env | string | GOOGLE_APPLICATION_CREDENTIALS | Override the env var name. |
thinking.enabled | bool | false | Enable thinking on Gemini 2.5+. |
thinking.budget_tokens | int | 8000 | Thinking token budget. |
thinking.include_thoughts | bool | true | Surface thinking via thinking.step. |
code_execution | bool | false | Enable Gemini’s built-in code-execution tool. |
cache.enabled | bool | false | Enable prompt caching (Gemini 2.0+). |
cache.min_tokens | int | 1000 | Minimum tokens required for caching. |
cache.ttl | string | 5m | Cache TTL: 5m or 1h. |
retry.* | — | (shared Retry block) | Backoff configuration. |
pricing.<model>.* | map | (embedded table) | Override per-model pricing. |
nexus.provider.fallback
Source: plugins/providers/fallback/plugin.go. No plugin-level config — the
fallback chain is defined by listing multiple providers under a single role in
core.models. This plugin coordinates the re-emission to the next provider on
non-retryable errors.
nexus.provider.fanout
Source: plugins/providers/fanout/plugin.go.
| Key | Type | Default | Description |
|---|---|---|---|
strategy | string | all | Selection strategy: all (return first to arrive), llm_judge, heuristic, user. |
deadline_ms | int | 30000 | Milliseconds to wait before forcing selection. |
heuristic.prefer | string | longest | Used when strategy: heuristic: longest, shortest, fastest, cheapest. |
heuristic.require_finish | bool | false | Only consider responses with finish_reason: end_turn. |
judge.role | string | (none) | Model role for the judge LLM call when strategy: llm_judge. |
A role becomes a fanout role when its core.models entry sets fanout: true;
the fanout plugin watches before:llm.request for those roles. For
strategy: user, the plugin emits provider.fanout.choose and waits for
provider.fanout.chosen from the IO layer.
Search providers (search.provider capability)
Each search-provider plugin handles search.request events and writes results
back into the payload. They share the same shape:
| Plugin | Source |
|---|---|
nexus.search.brave | plugins/search/brave/plugin.go |
nexus.search.anthropic_native | plugins/search/anthropic_native/plugin.go |
nexus.search.openai_native | plugins/search/openai_native/plugin.go |
nexus.search.gemini_native | plugins/search/gemini_native/plugin.go |
| Key | Type | Default | Notes |
|---|---|---|---|
api_key | string | (env, see below) | Direct API key. |
api_key_env | string | provider default (see below) | Override the env var name. |
model | string | provider default (see below) | Only on native search providers. |
base_url | string | provider default (see below) | Override the upstream endpoint. Available on brave, openai_native, and gemini_native. Primarily useful for httptest-driven integration tests; OpenAI-compatible proxies can also be wired here. |
timeout | duration | 15s (Brave), 30s (others) | HTTP request timeout. |
Provider defaults:
| Provider | api_key_env | model | base_url default |
|---|---|---|---|
nexus.search.brave | BRAVE_API_KEY | n/a | https://api.search.brave.com/res/v1/web/search |
nexus.search.anthropic_native | ANTHROPIC_API_KEY | claude-haiku-4-5-20251001 | n/a (no override) |
nexus.search.openai_native | OPENAI_API_KEY | gpt-4o-mini | https://api.openai.com/v1/responses |
nexus.search.gemini_native | GEMINI_API_KEY / GOOGLE_API_KEY | gemini-2.5-flash | https://generativelanguage.googleapis.com/v1beta |
If multiple providers register the search.provider capability, pin one
explicitly via the top-level capabilities: block.
Tools
nexus.tool.shell
Source: plugins/tools/shell/plugin.go. Routes commands through
pkg/engine/sandbox so the kernel surface is a single audited boundary.
| Key | Type | Default | Description |
|---|---|---|---|
working_dir | string | (session files dir) | Working directory for executions. |
timeout | duration | 30s | Per-command timeout. |
sandbox.backend | string | host | Sandbox tier: host (current behaviour). Future: gvisor, firecracker, landlock. |
sandbox.allowed_commands | list | (none — all allowed) | Whitelist of base command names. |
sandbox.path_dirs | list | (none) | Directories prepended to PATH. |
sandbox.env_restrict | bool | false | Strip sensitive env vars (AWS, Google, Azure, Anthropic API keys) before execution. |
sandbox.timeout | duration | 30s | Per-command default; timeout above wins per-call. |
nexus.tool.file
Source: plugins/tools/fileio/plugin.go. Registers read_file, write_file,
check_file_size, list_files, read_image, read_document.
read_image and read_document return a MessagePart on
ToolResult.OutputParts; the memory plugin copies parts onto the resulting
tool-role Message.Parts so the next LLM request sees the multimodal
content. Provider plugins resolve MessagePart.URI = "nexus-blob:<sha>"
references from the per-session blob store at ~/.nexus/sessions/<id>/blobs/
when the payload was stored, or use the inline MessagePart.Data when it
was inlined.
| Key | Type | Default | Description |
|---|---|---|---|
base_dir | string | (session files dir) | Base directory for file operations. |
allow_external_writes | bool | false | Permit reads/writes outside base_dir. |
blob_store.byte_budget | int | 2147483648 (2 GiB) | Soft cap on total stored blob bytes per session. 0 = unbounded. Applied via LRU sweep after each blob put. |
blob_store.inline_threshold | int | 262144 (256 KiB) | Payloads at or below this size are inlined on the MessagePart instead of being stored as a blob. |
tools.<tool_name> | bool | true for each | Per-tool enable/disable: read_file, write_file, check_file_size, list_files, read_image, read_document. |
nexus.tool.catalog
Source: plugins/tools/catalog/plugin.go. No configuration. Provides the
tool.catalog capability — a shared registry queried via
tool.catalog.query. Required by nexus.agent.react.
nexus.tool.web
Source: plugins/tools/web/plugin.go. Registers web_search, web_fetch,
and fetch_page_image. Requires the search.provider capability for
web_search. fetch_page_image requires screenshot_provider config —
without it, the tool surfaces a clear error at invoke time.
| Key | Type | Default | Description |
|---|---|---|---|
search.default_count | int | 10 | Default result count for web_search. |
search.default_safe_search | string | moderate | off, moderate, strict. |
search.default_language | string | (none) | BCP-47 language tag (e.g. en, es-MX). |
fetch.user_agent | string | Nexus/0.1 (+https://...) | User-Agent header for web_fetch. |
fetch.timeout | duration | 20s | HTTP timeout. |
fetch.max_size | int | 5242880 (5 MB) | Maximum response body size. |
fetch.default_extract | string | readability | readability or raw. |
fetch.allowed_domains | list | (none — allow all) | Allowlist of domains. |
fetch.blocked_domains | list | (none) | Blocklist of domains. |
fetch.follow_redirects | bool | true | Follow HTTP redirects. |
fetch.max_redirects | int | 5 | Maximum redirect chain length. |
screenshot_provider.url | string | (required for fetch_page_image) | Endpoint of the external screenshot service (urlbox, screenshotapi.net, browserless, …). |
screenshot_provider.method | string | POST | GET or POST. |
screenshot_provider.api_key_env | string | (none) | Env var holding the bearer token / API key. POST sends Authorization: Bearer <key>; GET appends api_key=<key> to the query. |
screenshot_provider.url_param_name | string | url | Field name carrying the target URL. |
screenshot_provider.request_template | map | (empty) | Extra fields merged into the JSON body (POST) or query string (GET). |
screenshot_provider.headers | map | (empty) | Fixed headers sent with every provider request. |
blob_store.byte_budget | int | 2147483648 (2 GiB) | Soft cap on total stored blob bytes per session for fetch_page_image. 0 = unbounded. |
blob_store.inline_threshold | int | 262144 (256 KiB) | Payloads at or below this size are inlined on the MessagePart instead of stored as a blob. |
nexus.tool.knowledge_search
Source: plugins/tools/knowledge_search/plugin.go. Requires
embeddings.provider and vector.store.
| Key | Type | Default | Description |
|---|---|---|---|
tool_name | string | knowledge_search | Name of the registered tool. |
top_k | int | 5 | Default chunks to return (LLM may override; capped at 50). |
include_metadata | bool | true | Include vector metadata alongside chunks. |
namespaces | list | (required) | Allowed vector store namespaces. |
default_namespaces | list | (required) | Namespaces searched when the LLM doesn’t specify. |
The active embeddings.provider plugin owns the model choice — there is
no consumer-side override. Configure the model once on the provider
plugin (e.g. nexus.embeddings.openai.model).
nexus.tool.pdf
Source: plugins/tools/pdf/plugin.go. Registers read_pdf. Two modes
selectable per call via mode argument or default_mode config:
text(default) — extract text viapdftotext(poppler-utils). RequirespdftotextonPATH(orpdftotext_binconfig).document— return the raw PDF bytes as afileMessagePartonToolResult.OutputPartsfor native multimodal providers (Anthropic, Gemini). No poppler call.first_page,last_page, andlayoutarguments are ignored in this mode.
If default_mode is document and pdftotext is missing, the plugin
boots; text mode then surfaces an actionable error per call.
| Key | Type | Default | Description |
|---|---|---|---|
pdftotext_bin | string | pdftotext | Path or name of the pdftotext binary. |
pdfinfo_bin | string | pdfinfo | Path or name of pdfinfo (optional). |
timeout | duration | 30s | Per-extraction timeout. |
save_to_session | bool | false | Persist extracted text to session files. |
save_file_name | string | (derived from PDF) | Custom filename for the saved text. |
default_mode | string | text | text or document. Default read_pdf mode when the LLM doesn’t supply one. |
nexus.tool.screenshot
Source: plugins/tools/screenshot/plugin.go. Registers take_screenshot.
Captures the full screen as PNG and emits an image MessagePart on
ToolResult.OutputParts. Capture path is platform-specific:
darwin:screencapture -t png -x <tmpfile>linux:gnome-screenshot -f <tmpfile>, thengrim <tmpfile>, then ImageMagick’simport -window root <tmpfile>as fallbacks- other platforms: emits
ToolResult.Error: "screenshot not supported on this platform"
| Key | Type | Default | Description |
|---|---|---|---|
timeout | duration | 15s | Per-capture subprocess timeout. |
blob_store.byte_budget | int | 2147483648 (2 GiB) | Soft cap on total stored blob bytes per session. 0 = unbounded. |
blob_store.inline_threshold | int | 262144 (256 KiB) | Payloads at or below this size are inlined on the MessagePart instead of stored as a blob. |
nexus.tool.opener
Source: plugins/tools/opener/plugin.go. Registers open_path.
| Key | Type | Default | Description |
|---|---|---|---|
open_cmd | string | platform default (open macOS, xdg-open Linux, start Win) | Override the platform “open” command. |
timeout | duration | 10s | Per-open timeout. |
nexus.control.hitl
Source: plugins/control/hitl/plugin.go. The unified human-in-the-loop
primitive. Registers the LLM-facing ask_user tool with an extended
schema (prompt, mode, choices, default_choice_id,
deadline_seconds) and routes hitl.requested / hitl.responded
events between requesters (the tool, gates, memory plugins) and IO
surfaces. Replaces the prior nexus.tool.ask. See Human-in-the-Loop
plugin docs.
| Key | Type | Default | Description |
|---|---|---|---|
registry.enabled | bool | false | Mirror every hitl.requested to disk and watch for response files written by nexus hitl respond, webhook handlers, etc. |
registry.dir | string | ~/.nexus/hitl | Filesystem directory the registry uses for <id>.request.yaml / <id>.response.yaml pairs. Tilde expansion via engine.ExpandPath. Created at boot if missing. |
nexus.control.hitl_synthesizer
Source: plugins/control/hitl_synthesizer/plugin.go. Optional
companion to nexus.control.hitl that renders context-aware approval
prompts via a small/cheap LLM. Advertises the
hitl.prompt_synthesizer capability; emitters opt in by setting
HITLRequest.PromptSynthesizer = "hitl.prompt_synthesizer" and
leaving Prompt empty. Subscribes to before:hitl.requested
(canonical vetoable entry point, pointer payload — every in-tree HITL
emitter publishes here first) and to hitl.requested as a backward
compat fallback for out-of-tree emitters that publish a *HITLRequest
pointer directly, ahead of every IO plugin so the rendered text is in
place before the operator sees the prompt.
Synthesised prompts are cached on disk under
<session>/plugins/nexus.control.hitl_synthesizer/cache.jsonl, keyed by
(action_kind, sha256(action_ref)). See
HITL Prompt Synthesizer docs.
| Key | Type | Default | Description |
|---|---|---|---|
model_role | string | quick | Model role (resolved via core.models) used for synthesis. |
max_action_ref_chars | int | 1500 | ActionRef truncation budget (in JSON characters) before sending to the model. |
cache_enabled | bool | true | Toggle the on-disk cache. Disable for debugging or strict-determinism runs. |
fallback_prompt | string | Approve action: {{.action_kind}} | Go text/template over {action_kind, action_ref, requester_plugin, request_id} used when synthesis fails. |
nexus.tool.code_exec
Source: plugins/tools/codeexec/plugin.go. Registers run_code (Go).
Two compilers selected via compiler:
yaegi-host(default) — in-process Yaegi interpreter. Full dynamic bindings (tools.*,parallel.*, skill helpers); no kernel isolation.yaegi-wasm— embedded Yaegi runner inside a wazero-managed Wasm sandbox. Capability-gated I/O vianexus_sdk/{http,fs,exec,env}. v1 forfeitstools.*,parallel.*, and skill helpers — the bridge SDK does not surface them.
Multimodal helper (host compiler only): scripts may
import "nexus" and call nexus.ReturnImage(data []byte, mimeType string)
to attach images to the resulting tool.result (alongside main.Run’s
JSON return). Multiple calls stack in script order; the routing follows
the same inline / blob-store threshold pattern used by other multimodal
tools.
| Key | Type | Default | Description |
|---|---|---|---|
compiler | string | yaegi-host | yaegi-host or yaegi-wasm. The latter requires sandbox.backend: wasm. |
timeout_seconds | int | 30 | Script timeout in seconds. |
max_output_bytes | int | 65536 | Maximum captured output. |
max_workers | int | runtime.NumCPU() | Concurrency cap for parallel.* (yaegi-host only). |
persist_scripts | bool | true | Write executed scripts to session files. |
reject_goroutines | bool | true | Reject scripts that spawn goroutines. |
allowed_packages | list | (stdlib whitelist) | Importable stdlib packages. |
blob_store.byte_budget | int | 2147483648 (2 GiB) | Soft cap on total stored blob bytes per session for nexus.ReturnImage payloads. 0 = unbounded. |
blob_store.inline_threshold | int | 262144 (256 KiB) | Payloads at or below this size are inlined on the MessagePart instead of being stored as a blob. |
sandbox.backend | string | host | Required wasm for compiler: yaegi-wasm. Other backends (host) reject KindGoWasm requests. |
sandbox.cache_dir | string | (none) | Persistent wazero compilation cache. Recommended for fast cold-start across processes. |
sandbox.timeout | duration | 30s | Default per-call wasm timeout. |
sandbox.net.policy | string | deny | deny or allow_hosts. Empty allow_hosts = deny all. |
sandbox.net.allow_hosts | list | (empty) | Exact-match hostname allowlist for nexus_sdk/http. |
sandbox.fs_mounts | list | (empty) | List of {host, guest, mode} triples. mode is ro (default) or rw. Backs nexus_sdk/fs. |
sandbox.exec_allowed | list | (empty) | Allowlist of commands invokable from nexus_sdk/exec.Run. Empty = deny. |
sandbox.env | map | (empty) | Sandbox-scoped env values returned by nexus_sdk/env.Get. Never the host’s real env. |
The engine substitutes ${session_id} in any string under the sandbox:
block at session start, so per-session host paths can be hard-coded:
host: ~/.nexus/sessions/${session_id}/files.
Memory
nexus.memory.simple
Source: plugins/memory/simple/plugin.go. No configuration. Provides
memory.history. Unbounded, in-memory, no persistence.
nexus.memory.capped
Source: plugins/memory/capped/plugin.go. Provides memory.history. This is
the default memory.history provider auto-activated by nexus.agent.react.
| Key | Type | Default | Description |
|---|---|---|---|
max_messages | int | 100 | Sliding window size; older messages dropped (with tool-pair safety). |
persist | bool | true | Persist to context/conversation.jsonl in the session workspace. |
nexus.memory.summary_buffer
Source: plugins/memory/summary_buffer/plugin.go. Provides both
memory.history and memory.compaction.
| Key | Type | Default | Description |
|---|---|---|---|
strategy | string | message_count | Trigger: message_count, token_estimate, turn_count. |
message_threshold | int | 50 | Used when strategy: message_count. |
token_threshold | int | 30000 | Used when strategy: token_estimate. |
turn_threshold | int | 10 | Used when strategy: turn_count. |
chars_per_token | float | 4.0 | Token estimation ratio. |
max_recent | int | 8 | Messages kept verbatim; older messages are summarized. |
model_role | string | quick | Role used for the summary call. |
model | string | (none) | Explicit model ID (ignored if model_role is set). |
prompt | string | (default) | Inline summary prompt. The default prompt is reasoning-preservation aware: it instructs the summariser to wrap segments in <summary topic="…" compressed-from-turns="…">…</summary> and end with a ## Preserved Kinds: trailer. Overriding loses both behaviours. |
prompt_file | string | (none) | Path to a summary prompt file (overrides prompt). |
quality_retry | bool | false | When true, the plugin re-runs the summariser once with a stricter prompt if the trailer omits any required preserved kind. Off by default for backwards compatibility. |
require_preserved_kinds | []string | ["decision", "rationale"] | Kinds whose presence in the trailer is required when quality_retry: true. Allowed values: decision, rationale, error, next_step, technical_detail. |
nexus.memory.compaction
Source: plugins/memory/compaction/plugin.go. Provides memory.compaction as
an external coordinator (separate from history buffers).
| Key | Type | Default | Description |
|---|---|---|---|
strategy | string | message_count | Trigger: message_count, token_estimate, turn_count. |
message_threshold | int | 50 | Used when strategy: message_count. |
token_threshold | int | 30000 | Used when strategy: token_estimate. |
turn_threshold | int | 10 | Used when strategy: turn_count. |
chars_per_token | float | 4.0 | Token estimation ratio. |
model_role | string | quick | Role used for the compaction LLM call. |
model | string | (none) | Explicit model ID. |
prompt | string | (default) | Inline compaction prompt. |
prompt_file | string | (none) | Path to a prompt file. |
protect_recent | int | 4 | Recent messages exempt from compaction. |
persist | bool | true | Persist snapshots and archives to the session workspace. |
require_approval.enabled | bool | false | Emit hitl.requested before committing the summary back into history. Off = unchanged behavior. |
require_approval.default_choice | string | (none) | Choice ID picked when the deadline expires (e.g. reject). Empty = treat timeout as cancelled. |
require_approval.timeout | duration | (none) | Optional deadline (5m, 30s, …). |
require_approval.match.size_threshold_bytes | int | (any) | Only require approval when the summary is at least this many bytes. |
nexus.memory.longterm
Source: plugins/memory/longterm/plugin.go. Provides memory.longterm.
Registers LLM tools: memory_write, memory_read, memory_list,
memory_delete.
| Key | Type | Default | Description |
|---|---|---|---|
scope | string | agent | agent, global, or both. |
path | string | ~/.nexus/memory/ | Base directory for memory files. |
agent_id | string | (auto) | Agent identifier when scope includes agent. |
auto_load | bool | true | Load memory index at startup and inject into the system prompt. |
auto_save_instructions | string | (none) | Instructions appended to the system prompt (e.g. “save important decisions”). |
require_approval.enabled | bool | false | Emit hitl.requested before persisting writes. Off by default; on = every write blocks until an operator responds. |
require_approval.default_choice | string | (none) | Choice ID picked when the deadline expires (e.g. reject). Empty = treat timeout as cancelled. |
require_approval.timeout | duration | (none) | Optional deadline (5m, 30s, …). |
require_approval.match.key_glob | string | (any) | Only require approval when the entry key matches this glob. |
require_approval.match.size_threshold_bytes | int | (any) | Only require approval when the content is at least this many bytes. |
nexus.memory.vector
Source: plugins/memory/vector/plugin.go. Provides memory.vector. Requires
embeddings.provider and vector.store.
| Key | Type | Default | Description |
|---|---|---|---|
namespace | string | memory-{instanceID} | Vector store namespace. |
top_k | int | 5 | Recalled matches per query. |
min_similarity | float | 0.0 | Minimum cosine similarity (0 disables filtering). |
auto_store_compaction | bool | true | Store summaries when memory.compacted fires. |
auto_store_user_input | bool | false | Store user messages on every input (opt-in). |
section_priority | int | 45 | Priority of the recalled-memory section in the system prompt. |
recall_via_hybrid | bool | false | When search.hybrid is active, route recall queries through it instead of direct vector lookup. Off by default — adds the lexical leg’s latency to every user input. |
store_images | bool | false | Embed image attachments on UserInput.Files (any MimeType starting with image/) via the multimodal embeddings.provider and store the resulting vector under image_namespace. Requires a multimodal adapter (e.g. nexus.embeddings.cohere_multimodal); text-only adapters will reject and the path no-ops. Off by default — opt-in. |
image_namespace | string | <namespace>-images | Vector store namespace for image embeddings. Kept separate from the text namespace so similarity queries can target one or the other. |
require_approval.enabled | bool | false | Emit hitl.requested before each vector.upsert. Off = unchanged behavior. |
require_approval.default_choice | string | (none) | Choice ID picked when the deadline expires (e.g. reject). Empty = treat timeout as cancelled. |
require_approval.timeout | duration | (none) | Optional deadline (5m, 30s, …). |
require_approval.match.namespace_glob | string | (any) | Only require approval when the configured namespace matches this glob. |
require_approval.match.size_threshold_bytes | int | (any) | Only require approval when the document content is at least this many bytes. |
nexus.memory.tool_result_clear
Source: plugins/memory/tool_result_clear/plugin.go. Live curator that
replaces stale tool-result bodies in outgoing LLMRequest.Messages with an
inline <tool_result … cleared="true" …/> envelope. The original
call/result pairing stays in history so the agent retains the fact of the
call. Runs at priority 12 on before:llm.request (after
nexus.discovery.progressive).
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Toggle the curator. |
age_turns | int | 5 | Clear tool results older than this many turns when also exceeding the size threshold. |
size_bytes_threshold | int | 1000 | Skip clearing for result bodies smaller than this many bytes. |
preserve_recent_kinds | []string | ["error", "user_question"] | Result kinds that are never cleared regardless of age. |
drop_strategy | string | replace_with_envelope | replace_with_envelope keeps the call/result pair with a marker body; full_drop removes the message entirely (risks tool_use/tool_result pairing breakage). |
Emits memory.tool_result_cleared (per cleared call) and memory.curated
(stability descriptor for the cache-aware prompt builder).
nexus.memory.tool_def_pruner
Source: plugins/memory/tool_def_pruner/plugin.go. Drops individual tool
definitions from outgoing LLMRequest.Tools when they have been idle past
unused_turns_threshold. Pairs with nexus.discovery.progressive —
progressive scopes by class, this scopes per tool. Runs at priority 14 on
before:llm.request.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Toggle the pruner. |
unused_turns_threshold | int | 6 | Drop a tool definition after this many consecutive turns without an invocation. |
never_prune | []string | ["discover","ask_user"] | Tool names exempt from pruning (e.g. discovery’s meta-tool, HITL ask-user). |
Emits memory.tool_def_pruned and memory.curated. The MemoryCurated
event marks cache_invalidates: true because the tool list is part of the
session-cached prefix.
nexus.memory.topic_pruner
Source: plugins/memory/topic_pruner/plugin.go. Detects topic boundaries
in user input and emits memory.topic_shift_detected. Two signals are
combined:
- Explicit-phrase matching (“different question”, “new topic”, “let’s move on”, …) — cheap, deterministic.
- Embedding similarity drop against the rolling topic centroid — runs only
when an
embeddings.provideris active.
The plugin does not itself rewrite history; it surfaces the shift so other plugins (summary buffer, compaction) can react. Topic boundaries are journalled for replay determinism.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Toggle the pruner. |
similarity_threshold | float | 0.55 | Cosine similarity below which a new user input flags a topic shift. Used only when an embeddings.provider is active. |
keep_last_topic_full | bool | true | Reserved — informs downstream consumers whether the most recent topic should remain verbatim. |
explicit_phrases | []string | ["different question", "different topic", "new topic", "new question", "let's move on", "moving on", "change of subject", "switching gears", "unrelated:", "separately,", "on a different note"] | Lowercase substrings that signal a topic shift. Replacing the list disables the defaults. |
Emits memory.topic_shift_detected and memory.curated. Same-turn
duplicate signals are debounced.
Embeddings
nexus.embeddings.openai
Source: plugins/embeddings/openai/plugin.go. Provides embeddings.provider.
| Key | Type | Default | Description |
|---|---|---|---|
api_key | string | (required, or via env) | OpenAI API key. |
api_key_env | string | OPENAI_API_KEY | Override env var name. |
base_url | string | https://api.openai.com/v1/embeddings | Endpoint (Azure / OpenAI-compatible proxies). |
model | string | text-embedding-3-small | Default model. |
timeout | duration | 30s | HTTP timeout. |
nexus.embeddings.mock
Source: plugins/embeddings/mock/plugin.go. Provides embeddings.provider.
Deterministic hash-based vectors; opt-in via plugins.active.
| Key | Type | Default | Description |
|---|---|---|---|
dimensions | int | 128 | Vector dimensionality. |
model | string | mock-embedding | Model ID string returned to callers. |
nexus.embeddings.cohere_multimodal
Source: plugins/embeddings/cohere_multimodal/plugin.go. Provides
embeddings.provider via Cohere Embed v3 (POST /v2/embed). Multimodal:
accepts text and image inputs in a single batch through
EmbeddingsRequest.Inputs. Opt-in: registered but not in the default
plugins.active list — wire it explicitly when image embeddings are
required (e.g. nexus.memory.vector with store_images: true).
For an EmbeddingsInput carrying ImageURI with the nexus-blob:
scheme, the plugin resolves bytes via the per-session blob store. When
the engine boots without a session (rare, mostly tests), the plugin
errors clearly — callers must inline bytes via EmbeddingsInput.Image
instead.
| Key | Type | Default | Description |
|---|---|---|---|
api_key | string | (required, or via env) | Cohere API key. |
api_key_env | string | COHERE_API_KEY | Override env var name. |
base_url | string | https://api.cohere.com | Cohere API base URL. The plugin appends /v2/embed itself. |
model | string | embed-english-v3.0 | Cohere embedding model. |
input_type | string | search_document | Cohere input_type (e.g. search_document, search_query, classification, clustering, image). |
timeout | duration | 30s | HTTP timeout. |
Vector store
nexus.vectorstore.chromem
Source: plugins/vectorstore/chromem/plugin.go. Provides vector.store.
| Key | Type | Default | Description |
|---|---|---|---|
path | string | ~/.nexus/vectors | Directory for persistent storage (one subdir per namespace). |
compress | bool | false | Gzip-compress JSON on disk. |
Lexical store
nexus.vectorstore.sqlite_fts
Source: plugins/vectorstore/sqlite_fts/plugin.go. Provides search.lexical.
BM25 ranking via SQLite FTS5 — pure Go, no CGO. Backing storage comes from
the engine’s per-plugin storage capability; the scope: knob picks where the
underlying store.db lands.
| Key | Type | Default | Description |
|---|---|---|---|
scope | string | session | Storage scope for the FTS index: session, agent, app. Knowledge-base-style corpora that survive across sessions should use agent or app. |
Each namespace becomes a separate FTS5 virtual table (lex_<safe_namespace>)
inside the scoped store.db. The provider auto-creates tables on first
upsert; missing-namespace queries return zero results without error.
RAG
nexus.rag.hybrid
Source: plugins/rag/hybrid/plugin.go. Provides search.hybrid — a fusion
orchestrator that runs vector + lexical retrieval in parallel and combines
results via Reciprocal Rank Fusion or weighted score combination.
| Key | Type | Default | Description |
|---|---|---|---|
fusion | string | rrf | Fusion strategy: rrf (rank-only, weight-free) or weighted (linear combination over min-max-normalized per-backend scores). |
rrf_k | int | 60 | RRF smoothing constant. Lower values weight top ranks more heavily. |
weights.vector | float | 0.7 | Per-backend bias for weighted fusion. |
weights.lexical | float | 0.3 | Per-backend bias for weighted fusion. |
retrieve_k | int | 50 | Per-backend candidate count gathered before fusion. |
fuse_to | int | 20 | Default post-fusion top-N when the caller does not specify K. |
reranker.enabled | bool | false | Apply a post-fusion reranker pass via the search.reranker capability. Off by default — enable when a reranker provider is active and the latency budget allows. |
Requires embeddings.provider, vector.store, and search.lexical. Per-query
LexicalBias (range -1..1) on the hybrid.query event tilts fusion weights
without rewriting config — positive favors lexical, negative favors vector.
When search.hybrid is active, nexus.tool.knowledge_search automatically
routes through it instead of querying the vector store directly.
nexus.memory.vector opts in via recall_via_hybrid: true (off by default
because the lexical leg adds latency on every user input).
Rerankers (search.reranker capability)
Three providers ship; activate one (rarely more than one). The hybrid
orchestrator’s reranker.enabled: true knob switches them on; without that,
plugins can still emit reranker.rerank events directly.
nexus.rag.reranker.cohere
Source: plugins/rag/reranker/cohere/plugin.go. Cohere Rerank v2 API.
| Key | Type | Default | Description |
|---|---|---|---|
api_key | string | (none) | Cohere API key. Mutually exclusive with api_key_env. |
api_key_env | string | COHERE_API_KEY | Env var to read the key from when api_key is unset. |
model | string | rerank-english-v3.0 | Cohere reranker model identifier. |
timeout_ms | int | 10000 | HTTP timeout in milliseconds. |
api_base | string | (Cohere v2 endpoint) | Override for testing / private deployments. |
nexus.rag.reranker.jina
Source: plugins/rag/reranker/jina/plugin.go. Jina AI Reranker API.
| Key | Type | Default | Description |
|---|---|---|---|
api_key | string | (none) | Jina API key. Mutually exclusive with api_key_env. |
api_key_env | string | JINA_API_KEY | Env var to read the key from when api_key is unset. |
model | string | jina-reranker-v2-base-multilingual | Jina reranker model identifier. |
timeout_ms | int | 10000 | HTTP timeout in milliseconds. |
api_base | string | (Jina v1 endpoint) | Override for testing / private deployments. |
nexus.rag.reranker.local
Source: plugins/rag/reranker/local/plugin.go. Pure-Go TF-IDF cosine
reranker. No API calls, no model files, no extra dependencies. Quality is
materially below a real cross-encoder; use it for offline / cost-sensitive
deployments and as the zero-dep fallback. Future phase will add an ONNX-
backed BGE Reranker behind a build tag.
| Key | Type | Default | Description |
|---|---|---|---|
min_token_length | int | 2 | Drop tokens shorter than this during scoring. |
disable_stopwords | bool | false | Skip the built-in English stopword filter. |
nexus.rag.citations
Source: plugins/rag/citations/plugin.go. Provides rag.citations. Parses
citation tags or Anthropic-native source attributions out of LLM responses
and emits the structured llm.response.cited event for IO renderers to
footnote.
| Key | Type | Default | Description |
|---|---|---|---|
mode | string | auto | Citation source: tag (parses <cite source="..." chunk="N"/> markers), anthropic_native (reads LLMResponse.Citations[] populated by Anthropic), or auto (uses native when present, falls back to tag). |
strict | bool | true | When true, citations whose (source, chunk) does not match a chunk recorded in the current turn’s retrieval context are dropped. When false, they are kept and tagged with TrustTier="unverified". |
section_priority | int | 60 | Priority of the citation-contract section in the system prompt (only used in tag/auto modes). |
Subscribes to rag.retrieved (emitted by nexus.tool.knowledge_search and
nexus.memory.vector) to build the per-turn validation set, then to
llm.response to do the parsing. Emits llm.response.cited.
nexus.rag.ingest
Source: plugins/rag/ingest/plugin.go. Backs the nexus ingest CLI subcommand
and the rag.ingest event handler.
| Key | Type | Default | Description |
|---|---|---|---|
chunker.size | int | 1000 | Characters per chunk. |
chunker.overlap | int | 200 | Character overlap between chunks. |
cache_dir | string | ~/.nexus/vectors/_cache | Embedding cache directory (hash → vector). The contextual-prefix cache lands at <cache_dir>/_prefix/. |
backfill | bool | true | Walk watched directories at startup and ingest pre-existing files. |
watch | list | (empty) | File watch entries; each is {path, glob, namespace}. |
watch[].path | string | (required) | Directory to watch. |
watch[].glob | string | (empty — match all) | Glob pattern for files to ingest. |
watch[].namespace | string | (required) | Vector store namespace. |
contextual_retrieval.enabled | bool | false | Per-chunk LLM-generated situating prefix (Anthropic contextual retrieval). Adds one LLM call per uncached chunk during ingest; ~49% reported recall improvement. Stored content stays the raw chunk; only the embed/lexical text is prefixed. |
contextual_retrieval.model_role | string | (role default) | Model role used for prefix generation (resolved via core.models). |
contextual_retrieval.max_chars_doc_window | int | 2000 | Max characters of surrounding document context handed to the LLM. |
contextual_retrieval.max_chars_prefix | int | 400 | Truncate generated prefix to this many characters before concatenation. |
contextual_retrieval.timeout_ms | int | 30000 | Per-call timeout. On timeout the prefix is dropped and the raw chunk is used. |
Requires embeddings.provider and vector.store. When search.lexical is
also active, ingest dual-writes each chunk into the lexical store with the
same (namespace, doc_id) pair the vector store uses.
To migrate an existing chromem-only corpus to dual-mode: add
nexus.vectorstore.sqlite_fts to plugins.active and re-run
nexus ingest --lexical=true PATH. The embedding cache short-circuits the
vector pass while the lexical store is freshly populated.
I/O
nexus.io.tui
Source: plugins/io/tui/plugin.go. No configuration. Bubble Tea terminal UI.
nexus.io.browser
Source: plugins/io/browser/plugin.go.
| Key | Type | Default | Description |
|---|---|---|---|
host | string | localhost | HTTP listen address. |
port | int | 8080 | HTTP listen port. |
open_browser | bool | true | Auto-open the browser tab on startup (no-op when the OS lacks an opener). |
nexus.io.agui
Source: plugins/io/agui/plugin.go. AG-UI (“Agent-User Interaction”) serve
transport. Clients POST a RunAgentInput to /agui and receive a
text/event-stream SSE response (one stream per run), using the pkg/agui wire
format rather than the browser/wails Envelope. Safe by default: binds loopback,
optional bearer-token auth, and configurable CORS for browser AG-UI clients.
| Key | Type | Default | Description |
|---|---|---|---|
bind | string | 127.0.0.1:8090 | host:port the HTTP listener binds to. Defaults to loopback so the endpoint is not network-exposed without explicit opt-in. |
bearer_token | string | (empty) | Inline bearer token. When set (and non-empty), Authorization: Bearer <token> is required on every request. Takes precedence over bearer_token_env. |
bearer_token_env | string | (empty) | Name of an environment variable to read the bearer token from. Used only when bearer_token is empty. |
cors_origins | list | (empty) | Allowed CORS origins for browser clients. A single * echoes any request Origin; an explicit list echoes only matching origins. Empty means no CORS header (same-origin only), the safe default for a loopback listener. Also accepts a comma-separated string. |
emit_state | bool | false | Opt-in AG-UI shared-state emission. When true, the transport mirrors the session’s scene store (nexus.scene) as an AG-UI shared-state document and emits a StateSnapshot at run start plus ordered StateDelta events (RFC 6902 JSON Patch) as scenes mutate. Off by default because it adds scene-event subscriptions and per-mutation diffing overhead most clients do not need. Requires the nexus.scene plugin to be active to produce any state. |
Round-trip: a POST /agui maps the request messages to a Nexus
io.input (the trailing user message drives the turn; earlier messages ride
as PreloadMessages; threadId is recorded as the session id, runId
identifies the turn). The plugin subscribes to the same bus events as the
browser transport and translates them to canonical AG-UI SSE:
agent.turn.start→StepStarted, llm.stream.chunk→TextMessage*,
tool.call/tool.result→ToolCall*, thinking.step→Reasoning*,
agent.turn.end→StepFinished/RunFinished. The stream flushes incrementally
and terminates at RunFinished (or RunError on failure/disconnect).
Non-canonical events: Nexus bus events with no canonical AG-UI equivalent
(workflow.progress, subagent.started/iteration/complete,
code.exec.stdout) consistently ride the AG-UI Custom event, with name set
to the bus event type and value the JSON-encoded payload. This is a
documented superset — conformance clients that only understand canonical events
can ignore Custom without losing the run’s canonical lifecycle.
Scope: one in-flight run per listener (single engine/session per listener,
mirroring nexus.io.browser). A second POST while a run is active receives a
terminal RunStarted+RunError stream rather than interleaving.
Shared state (emit_state: true): the transport tracks the scene store’s
scene.created / scene.patched / scene.deleted bus events (each carrying the
scene’s full post-mutation content) into a shared-state document keyed by
scene_id. A StateSnapshot of the current document is emitted immediately
after RunStarted; each subsequent scene mutation during the run emits a
StateDelta whose delta is an RFC 6902 JSON Patch from the prior document to
the new one, so a client applying the deltas in order reconstructs the snapshot.
The document is session-scoped and persists across runs on the listener (a later
run’s snapshot reflects scenes created earlier). Inbound state
(RunAgentInput.state, same scene-keyed shape) is applied at run start — and on
a resume/continuation run — before the initial StateSnapshot, seeding the
scene store via a scene_create tool.invoke per scene so the agent observes it
through scene_get / scene_list. Conflict semantics are
client-state-seeds-then-agent-wins: the client seed lands before the agent’s
first turn, then agent-side scene_patch mutations are last-writer and flow back
out as StateDelta. See
Shared state.
nexus.io.realtime
Source: plugins/io/realtime/plugin.go. WebSocket bidirectional transport
for low-latency clients (browser front-ends, native voice clients) that
want raw stream.delta deltas, tool previews, voice audio chunks, and
cancel envelopes without going through the nexus.io.browser UI hub.
| Key | Type | Default | Description |
|---|---|---|---|
listen_addr | string | :7676 | TCP address the WebSocket server binds to. |
path | string | /ws | URL path the WebSocket handler is mounted at. |
max_clients | int | 16 | Concurrent connection cap. New dials past the cap receive HTTP 503. |
Outbound envelopes (server → client, JSON): stream.delta, stream.end,
tool.preview, audio.chunk, cancel.complete, hitl.request.
Inbound envelopes (client → server, JSON): input, audio.chunk,
cancel, approval.
No auth in v1. Origin checks and bearer-token validation are tracked follow-ups; operators running this on a public network must front it with a reverse proxy that does its own authentication.
nexus.io.broker
Source: plugins/io/broker/plugin.go. Dial-back IO transport for Nexus
instances spawned by the session broker (cmd/nexus-broker). Unlike
nexus.io.browser / nexus.io.realtime, which LISTEN, this plugin DIALS OUT
to the broker’s instance gateway over WebSocket — the broker is the only
listening socket. On Ready it dials broker_addr, sends a register frame
keyed by lease_id, announces readiness, and reports the engine session id
(for later -recall resume) before bridging IO frames in both directions.
Config keys fall back to environment variables the broker injects at spawn, so operators normally set neither by hand:
| Key | Type | Default | Description |
|---|---|---|---|
broker_addr | string | $NEXUS_BROKER_ADDR | WebSocket URL of the broker’s instance dial-back endpoint (e.g. ws://127.0.0.1:8080/instance). Falls back to the NEXUS_BROKER_ADDR env var. When empty the plugin stays dormant (no dial). |
lease_id | string | $NEXUS_BROKER_LEASE_ID | Lease id assigned by the broker at spawn; echoed in the register frame. Falls back to the NEXUS_BROKER_LEASE_ID env var. When empty the plugin stays dormant. |
Outbound IO messages (instance → broker → client, JSON inside the frame
payload): output, stream.delta, stream.end, status, approval.request,
hitl.request, cancel.complete.
Inbound IO messages (client → broker → instance): input,
approval.response, hitl.response, cancel.
The connection reconnects with exponential backoff until shutdown. On an
inbound shutdown frame (sent by the broker for POST /release and later
idle/crash teardown) the plugin emits io.session.end, which drives a clean
engine Stop that flushes and persists the session before the process exits;
the reconnect loop is latched off so the graceful teardown is not undone. There
is no auth in the plugin itself — the broker gateway owns lease validation
and any transport-level authentication.
nexus.io.voice
Source: plugins/io/voice/plugin.go. Bus-driven voice IO bridge:
consumes voice.audio.input.chunk events (typically from
nexus.io.realtime), runs simple energy-based VAD plus ASR via the
OpenAI Whisper API, and emits io.input. Consumes llm.response,
runs TTS via the OpenAI /audio/speech endpoint, and emits
voice.audio.output.chunk frames back. Implements barge-in: a
speech-energy input chunk arriving while a TTS turn is in flight emits
cancel.request{Source: "voice"}.
Local-model providers (local_whisper, faster_whisper,
distil_whisper for ASR; kokoro, local_*, *_local for TTS) are
recognized by the schema but rejected at Init with a clear error
pointing at issue #92, where the local-model bootstrapping work is
tracked separately.
| Key | Type | Default | Description |
|---|---|---|---|
asr.provider | string | openai_whisper | Only openai_whisper is wired in this PR. Local-model values rejected pending #92. |
asr.api_key_env | string | OPENAI_API_KEY | Env var that holds the API key. |
asr.api_key | string | (none) | Inline API key. Overrides api_key_env. |
asr.model | string | whisper-1 | Whisper model id. |
asr.endpoint | string | OpenAI default | Override URL for the transcription endpoint (test injection). |
tts.provider | string | openai | Only openai is wired in this PR. Local-model values rejected pending #92. |
tts.api_key_env | string | OPENAI_API_KEY | Env var that holds the API key. |
tts.api_key | string | (none) | Inline API key. Overrides api_key_env. |
tts.model | string | tts-1 | TTS model id. |
tts.voice | string | alloy | Voice preset id. |
tts.streaming | bool | true | Always true in v1; reserved for future non-streaming mode. |
tts.endpoint | string | OpenAI default | Override URL for the speech endpoint (test injection). |
tts.chunk_bytes | int | 8192 | Frame size in bytes for emitted output chunks. |
vad.threshold | number | 0.02 | RMS energy threshold (normalized 0..1) above which the buffer is considered speech. |
vad.silence_ms | integer | 600 | Milliseconds of below-threshold audio that triggers an utterance flush. |
barge_in.enabled | bool | true | Cancel an in-flight TTS turn when new speech is detected. |
barge_in.threshold | number | vad.threshold | RMS threshold above which an incoming chunk is treated as barge-in. |
text_fallback | bool | true | Allow io.input from non-voice transports to flow through unchanged. |
VAD energy is computed as RMS over little-endian PCM int16 samples for
audio/wav / audio/pcm / audio/l16. For compressed containers
(webm/opus, mpeg/mp3) the bytes are not PCM and we fall back to a
byte-level energy heuristic until a proper decode is added — flagged
with a TODO(#91) in plugins/io/voice/vad.go.
nexus.io.test
Source: plugins/io/test/plugin.go. Non-interactive testing transport.
| Key | Type | Default | Description |
|---|---|---|---|
inputs | list | (empty) | Scripted user inputs (fed sequentially). |
input_delay | duration | 500ms | Delay between inputs. |
approval_mode | string | approve | approve, deny, per-prompt. |
approval_rules | list | (empty) | Per-prompt rules: each `{match: |
hitl_responses | list | (empty) | Scripted answers to hitl.requested events. Bare strings are treated as free_text; {choice_id: ..., free_text: ...} maps populate the corresponding response fields. |
mock_responses | list | (empty) | Synthetic LLM responses. Each {content, tool_calls: [{name, arguments}]}. When set, the plugin vetoes real llm.request events. |
timeout | duration | 60s | Session timeout. |
read_stdin | bool | true | Read stdin when no other input source is available. |
nexus.io.wails
Source: plugins/io/wails/plugin.go. Wails-native transport. The runtime is
installed by the embedder via Hub().SetRuntime() before engine.Boot; this
plugin only configures event bridging.
| Key | Type | Default | Description |
|---|---|---|---|
subscribe | list | (empty) | Event types to bridge bus → frontend. Empty triggers legacy hardcoded chat-event subscriptions for parity with nexus.io.browser. |
accept | list | (empty) | Event types accepted from the frontend → bus. |
nexus.io.oneshot
Source: plugins/io/oneshot/plugin.go. Scripting/batch mode with JSON
transcript output.
| Key | Type | Default | Description |
|---|---|---|---|
input | string | (none) | Inline prompt (lowest precedence). |
input_file | string | (none) | Path to a prompt file. |
output_file | string | (none) | Path to write the JSON transcript. |
pretty | bool | true | Pretty-print JSON output. |
read_stdin | bool | true | Read stdin when available. |
Prompt resolution precedence: NEXUS_ONESHOT_PROMPT env > input > input_file
stdin.
Native Realtime API integration — deferred
OpenAI Realtime and Gemini Multimodal Live are entire new wire protocols
separate from the standard chat/generate endpoints. They are not
part of the multimodal-foundation PR (#93) and are tracked as a
follow-up under issue #91. Until they land, voice-mode use the
ASR → LLM → TTS pipeline implemented in plugins/io/voice/. See
Native Realtime API integration — deferred
for the full rationale and follow-up scope.
Observers
nexus.observe.thinking
Source: plugins/observe/thinking/plugin.go. No configuration. Marker
plugin: presence in plugins.active lets terminal and browser shells
enable thinking-related UI. The events themselves are journaled
automatically and can be read live via journal.Writer.SubscribeProjection
or post-mortem via journal.ProjectFile.
nexus.observe.otel
Source: plugins/observe/otel/plugin.go. OTLP exporter (one root span per
session, one span per event).
| Key | Type | Default | Description |
|---|---|---|---|
endpoint | string | (none) | OTLP endpoint, e.g. http://localhost:4317. |
protocol | string | grpc | grpc or http/protobuf. |
service_name | string | nexus | OpenTelemetry service name. |
exclude_events | list | (empty) | Event types to skip; supports prefix wildcards (llm.stream.*). |
nexus.observe.sampler
Source: plugins/observe/sampler/plugin.go. Off by default. Captures a
fraction of live session journals (and every failed session when
failure_capture is on) into a local directory so the eval pipeline can
score them later. The plugin must be both registered (it is — automatically
via pkg/engine/allplugins) and listed in plugins.active and
configured with enabled: true for any capture to happen. Omitting the
config block, or setting enabled: false, makes the plugin a no-op:
Subscriptions() returns empty, no bus traffic, no disk writes.
plugins:
active:
- nexus.observe.sampler
nexus.observe.sampler:
enabled: false
rate: 0.0
failure_capture: true
out_dir: ~/.nexus/eval/samples
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Master switch. When false, the plugin draws no bus traffic and writes no files even if it appears in plugins.active. |
rate | float | 0.0 | Fraction of normal sessions captured at io.session.end, in [0, 1]. 0.0 disables rate sampling; 1.0 captures every session. Validated at Init; out-of-range values fail boot when enabled: true. |
failure_capture | bool | true | When true, sessions whose metadata/session.json status is anything other than active or completed are captured regardless of rate. Use false to disable failure capture entirely. |
out_dir | string | ~/.nexus/eval/samples | Directory where samples land. Path expansion via engine.ExpandPath. Each sample is written to <out_dir>/<session-id>/journal/ plus a <out_dir>/<session-id>/metadata.json sibling. |
The plugin emits an eval.candidate event per capture (payload defined in
plugins/observe/sampler/events.go) so downstream tooling — for example,
nexus eval list-candidates once it lands — can enumerate fresh samples.
The pluggable Redactor interface (plugins/observe/sampler/redact.go) is
the hook for future PII scrubbing. v1 ships only the IdentityRedactor
(byte-pass-through). Tests inject custom redactors via the package-private
Plugin.SetRedactor API; production runs leave it on the default.
Caveat: rotated journal segments. When a non-identity redactor is configured, the active
events.jsonlsegment is rewritten line-by-line through it. Compressed*.jsonl.zstrotated segments are byte-copied as-is in v1 — handling them transparently requires zstd round-trips that are deferred to a follow-up.
Planners
nexus.planner.dynamic
Source: plugins/planners/dynamic/plugin.go.
| Key | Type | Default | Description |
|---|---|---|---|
approval | string | auto | always (block until user approves), never (auto-execute), auto (LLM decides). |
plan_prompt | string | (default) | Inline planning prompt. |
plan_prompt_file | string | (none) | Path to a planning prompt file. |
model_role | string | (default) | Role used for plan generation. |
model | string | (none) | Explicit model ID (backward-compat; prefer model_role). |
max_steps | int | 10 | Hard cap; excess steps from the LLM are truncated. |
nexus.planner.static
Source: plugins/planners/static/plugin.go. Approval auto-defaults to never
(static plans don’t call an LLM).
| Key | Type | Default | Description |
|---|---|---|---|
approval | string | never | always or never. |
summary | string | Static execution plan | Free-form plan summary. |
steps | list | (required) | Step list. |
steps[].description | string | (required) | Step description. |
steps[].instructions | string | (none) | Step-specific instructions. |
Workflows
Generic workflow surface
Workflow plugins (currently nexus.workflows.icm; planned to extend to other
multi-stage runners) emit a workflow-agnostic event class so IO plugins can
render a dedicated progress surface (a sticky panel in the TUI right rail; a
status indicator chip in the browser) without subscribing to plugin-specific
event taxonomies.
Event: workflow.progress — payload events.WorkflowProgress
(pkg/events/workflow.go).
| Field | Type | Description |
|---|---|---|
workflow_id | string | Producer plugin instance ID (nexus.workflows.icm, nexus.workflows.icm/script, …). |
workflow_name | string | Human-readable workflow label (workspace name for ICM). |
run_id | string | Identifier for this particular run. |
stage / stage_label | string | Machine ID + display label for the current stage. Empty at run start / end. |
stage_index / stage_total | int | 1-based position in the stage sequence. |
iteration / max_iterations | int | Loop iteration counters. 0 when the stage is not looping. |
turn / max_turns | int | Inner-turn counters. 0 when not tracked. |
items_done / items_total | int | Fan-out progress. 0 when not a fan-out stage. |
current_item | string | Most recently completed item ID for fan-out. |
status | string | One of started, running, iterating, item_done, completed, failed, halted. |
detail | string | Short free-form one-liner suitable for display. |
failures | list of strings | Names of predicates whose failure prevented this iteration / turn from converging. |
ICM emits workflow.progress alongside its detailed icm.* events: the
icm.* family feeds scrollback audit rows; workflow.progress feeds the
dedicated status surface. Future workflow plugins can emit only the generic
event and inherit the same UI treatment without per-plugin subscriptions.
The TUI (nexus.io.tui) and browser (nexus.io.browser) subscribe to
workflow.progress automatically when active.
nexus.workflows.icm
Source: plugins/workflows/icm/plugin.go. File-driven multi-stage workflow
runner. A workspace is a folder containing operator.md, workspace.md, and
a stages/ tree of contracts; each stage runs as a sub-agent dispatched via
the posture registry. Multi-instance: pin distinct workspaces per instance
via the nexus.workflows.icm/<suffix> form (e.g.
nexus.workflows.icm/script). See docs/src/plugins/workflows-icm.md for
the full plugin guide.
Requires the posture.registry capability (provided by
nexus.agent.postures). Strongly recommended companions:
nexus.control.hitl (human gates + judge approvals), nexus.skills
(workspace skills authoring tooling).
| Key | Type | Default | Description |
|---|---|---|---|
workspace | string | (required) | Path to the ICM workspace folder. Expanded via ~. Loaded + validated at boot; load errors fail boot. |
default_judge_posture | string | (empty) | Registered posture name used for type: llm predicates that do not name an explicit model: posture. Required when any predicate uses type: llm. |
default_workflow_posture | string | (empty) | Optional base posture name. Stages without an agent.posture: inherit Model / AllowedTools / Budget / MaxRecursionDepth from this posture before applying stage-level overrides. |
cache_size | int | 0 | Per-run delegate cache capacity. 0 disables caching (recommended — ICM stages typically have tool side effects + predicate retries that make cross-run caching hostile). |
inline_artifact_limit_bytes | int | 32768 | Maximum size for inlining an artifact body into the XML payload. Above this threshold ICM emits <artifact_ref/> and the LLM uses read_file. |
loop_max_restarts | int | 3 | Per-stage cap on loop.on_exhausted: human_gate restart choices. 0 = unlimited. Prevents infinite restart cycles when a workspace cannot converge. |
input_filename | string | input.txt | Filename written into <runID>/00_input/ when io.input carries direct content (not a file path). |
treat_input_as_path_if_exists | bool | true | When true, io.input.Content is interpreted as a file path if os.Stat succeeds and the file is copied into 00_input/; otherwise the content is written verbatim. |
workspace_inputs_dir | string | (empty) | Optional directory whose regular files are copied into <runID>/00_input/ at run start, before io.input content is processed. Useful for static fixtures. |
auto_include_skill_reference_tool | bool | true | When true, ICM automatically appends the read_skill_reference[_<suffix>] tool to each derived stage posture whose contract declares inputs.skills. Set false to require explicit listing in agent.tools. |
predicate_command_timeout_seconds | int | 30 | Default timeout for type: command predicates when neither the predicate nor the stage budget specifies one. |
emit_progress_thinking_steps | bool | true | When true, ICM emits thinking.step events with Phase="icm.<stage_id>" so UIs that render thinking surfaces show inline stage transitions. |
Events
Subscribes:
io.input— entry point. Each input begins a new workflow run.hitl.responded— resumes a run paused at a human gate ortype: humanpredicate.
Emits (workflow lifecycle):
icm.run.started/icm.run.completed/icm.run.halted— overall run boundaries.icm.stage.started/icm.stage.completed/icm.stage.failed— per-stage transitions.icm.stage.iteration— fires once per loop iteration with the prior iteration’sexit_failures.icm.turn— fires once per inner turn with the turn’s validator failures.icm.fanout.item— per-item lifecycle in a fan-out stage (active,completed,failed).icm.predicate.failed— fires for every predicate evaluation whose verdict isfail.plan.created/plan.progress— generic plan surface mirrored for any UI that already renders ReAct plans.workflow.progress— engine-generic structured progress (see Generic workflow surface below).hitl.requested— human gates andtype: humanpredicates dispatch through HITL.
nexus.skills
Source: plugins/skills/plugin.go. Registers the activate_skill LLM tool.
| Key | Type | Default | Description |
|---|---|---|---|
scan_paths | list | (empty) | Directories scanned for SKILL.md files. No implicit defaults — discovery is gated entirely by this list. |
trust_project | string | ask | Trust level for project skills: ask, always, never. |
max_active_skills | int | 10 | Hard cap on concurrently active skills. |
catalog_in_system_prompt | bool | true | Inject the skill catalog into the system prompt at priority 50. |
disabled_skills | list | (empty) | Skill names to disable even if discovered. |
System
nexus.system.dynvars
Source: plugins/system/dynvars/plugin.go. Registers a system-prompt section at
priority 100 that lists runtime variables. Each flag defaults to false —
opt-in only.
| Key | Type | Default | Description |
|---|---|---|---|
date | bool | false | Include Current date: YYYY-MM-DD. |
time | bool | false | Include Current time: HH:MM:SS. |
timezone | bool | false | Include the local timezone abbreviation. |
cwd | bool | false | Include the engine working directory. |
session_dir | bool | false | Include the session workspace root. |
os | bool | false | Include os/arch. |
Control
nexus.control.cancel
Source: plugins/control/cancel/plugin.go. No configuration. Provides the
control.cancel capability used by ReAct and other agents to interrupt
in-flight work; also handles the /resume slash command via io.input at
priority 5 (ahead of memory plugins).
Routers
Plugins that subscribe before:llm.request and rewrite request.Model
based on the request’s metadata, tags, or an LLM-classifier judgment.
Routers run at priority 50 (metadata) / 45 (classifier) — above gates,
below the engine’s tag seeder. Both stand down when the request already
carries _target_provider (a fallback retry) or _routed_by (an upstream
rule already chose a model).
nexus.router.metadata
Source: plugins/router/metadata/plugin.go. Declarative rules over the
request’s Metadata (_source, task_kind, iteration) and Tags
(tenant, project, source_plugin, …). First matching rule wins;
the terminal default_model / default_role fires when no rule matches.
| Key | Type | Default | Description |
|---|---|---|---|
rules | list | (empty) | Ordered rule list. See below. |
default_model | string | (none) | Fallback model id when no rule matches. |
default_role | string | (none) | Fallback role when no rule matches. |
Each entry under rules:
| Key | Type | Default | Description |
|---|---|---|---|
name | string | rule#N | Optional label recorded on req.Metadata["_routed_rule"]. |
match | map | (required) | Match conditions. Keys: metadata.<key>, tags.<key>, role, model. Values: bare string (equality), or `{lt |
use | string | (one of) | Concrete model id to assign. |
role | string | (one of) | Role name to assign (resolved against core.models). |
nexus.router.classifier
Source: plugins/router/classifier/plugin.go. Small LLM judges the
difficulty of the user’s most recent prompt and picks one of
candidate_roles. The decision is cached by prompt-prefix hash (LRU).
Cache hits rewrite LLMRequest.Role synchronously; misses route to
fallback_role immediately and warm the cache asynchronously via a
probe llm.request tagged _source: nexus.router.classifier.
| Key | Type | Default | Description |
|---|---|---|---|
classifier_role | string | (required) | Model role (resolved via core.models) used for the classification probe. |
candidate_roles | list | (required) | Cheapest-first list of model roles the classifier picks among. |
fallback_role | string | (none) | Model role used on cache miss while the cache warms. |
prompt | string | (default) | Classifier prompt template (%s for the candidate-role list and prompt). |
prefix_chars | int | 256 | Number of leading prompt characters folded into the cache key. |
cache_classification | bool | true | Whether to cache decisions at all. |
cache_max_entries | int | 1024 | LRU capacity. |
latency_budget_ms | int | 800 | Drop the warm if the probe doesn’t return within this window. |
Discovery
nexus.discovery.progressive
Source: plugins/discovery/progressive/plugin.go. Hierarchical tool discovery
— the LLM sees class-level summaries and drills into specific classes via a
discover meta-tool. Intercepts before:llm.request (priority 8) and
tool.invoke (priority 40).
| Key | Type | Default | Description |
|---|---|---|---|
scope | string | session | session, turn, or hybrid. |
idle_prune_turns | int | 5 | Turns of inactivity before a class is pruned (scope: hybrid only). |
classless_behavior | string | include | include (always reveal classless tools) or exclude. |
always_include | list | (empty) | Class names that are always fully revealed. |
default_depth | string | class | class (summaries only) or full (all tools). |
LLM batch
nexus.llm.batch
Source: plugins/llm/batch/plugin.go. Cross-provider batch coordinator
(Anthropic Messages Batches, OpenAI Batch API). Subscribes
llm.batch.submit; emits llm.batch.status and llm.batch.results.
| Key | Type | Default | Description |
|---|---|---|---|
poll_interval | duration | 5m | How often to poll provider batch status. |
data_dir | string | ~/.nexus/batches | Directory for persisted batch state (resumed across restarts). |
default_max_tokens | int | 1024 | Default max_tokens applied when a batched request didn’t pin one. |
providers.anthropic.api_key | string | (env) | Anthropic API key. |
providers.anthropic.api_key_env | string | ANTHROPIC_API_KEY | Env var to read the Anthropic key from. |
providers.openai.api_key | string | (env) | OpenAI API key. |
providers.openai.api_key_env | string | OPENAI_API_KEY | Env var to read the OpenAI key from. |
anthropic_api_key_env | string | (none) | Backward-compat: flat top-level Anthropic key env var. |
openai_api_key_env | string | (none) | Backward-compat: flat top-level OpenAI key env var. |
v1 limitations (intentional): direct-API auth only (no Bedrock/Vertex/Azure); text-only requests (no multimodal/thinking/caching/citations); single-provider per submit; no cancellation API.
MCP integration
nexus.mcp.client
Source: plugins/mcp/client/. Bridges one or more external Model Context
Protocol (MCP) servers into Nexus. Tools land in the catalog under
mcp__<server>__<tool>, static resources auto-register as no-arg tools,
resource templates become parameterised tools, and prompts surface as slash
commands. See docs/src/plugins/mcp-client.md for the user-facing guide.
Top-level keys:
| Key | Type | Default | Description |
|---|---|---|---|
servers | list | (none) | One entry per MCP server. See per-server keys below. |
defaults | map | (none) | Inherited by every entry in servers unless overridden inline. |
aliases | map | (none) | Optional alias map: short slash command → <server>.<prompt>. Aliases use the configured command_prefix chain; e.g. review: gh.review_pr makes /review rewrite to /mcp.gh.review_pr. |
defaults
| Key | Type | Default | Description |
|---|---|---|---|
lifecycle | string | engine | When servers connect/disconnect. engine = connect on engine boot, disconnect on shutdown. session = connect on io.session.start, disconnect on io.session.end. |
timeout | duration | 30s | Per-RPC timeout used for tools/call, resources/read, prompts/get, etc. |
command_prefix | string | mcp | First segment of the slash command Nexus registers per prompt. With the default a server named fake and a prompt named greet becomes /mcp.fake.greet. |
resources.enabled | bool | true | Toggle the entire resource surface for the server. |
resources.auto_register_static | bool | true | When true, every static resource becomes a no-arg catalog tool. |
resources.auto_register_template | bool | true | When true, every resource template becomes a catalog tool whose inputSchema mirrors the template’s variables. |
resources.auto_register_max | int | 50 | If a server returns more static resources than this, the static auto-registration is skipped and only the generic list_resources/read_resource tools are exposed. |
resources.subscribe_updates | bool | true | Subscribe to resources/updated for each auto-registered static. Notifications produce mcp.resource.updated events. |
prompts.enabled | bool | true | Toggle the prompt slash-command surface for the server. |
servers[]
| Key | Type | Default | Description |
|---|---|---|---|
name | string | (required) | Lowercase alpha-numeric identifier used to namespace every catalog entry and slash command ([a-z0-9][a-z0-9_-]*). |
transport | string | stdio | stdio (subprocess via the SDK) or http (streamable HTTP). |
command | string | (required for stdio) | Executable to launch. Resolved on PATH; users wanting ~ expansion can write the full path. |
args | list | (none) | Argument list passed to command. |
env | map | (none) | Environment variables exported to the subprocess. ${VAR} references are expanded from the host environment. |
env_passthrough | list | (none) | Names of host environment variables forwarded verbatim (skipped silently when not set on the host). |
url | string | (required for http) | Base URL of the streamable HTTP MCP endpoint. |
headers | map | (none) | HTTP headers attached to every request. ${VAR} references expand from the host environment. |
lifecycle | string | inherited from defaults | engine or session. |
timeout | duration | inherited from defaults | Overrides defaults per server. |
tools.allow | list | (none) (all allowed) | If set, only listed raw MCP tool names are forwarded to the catalog. |
tools.deny | list | (none) | Raw MCP tool names to drop unconditionally. Deny takes precedence over allow. |
resources.* | map | inherited from defaults | Same keys as defaults.resources. |
prompts.enabled | bool | inherited from defaults | Disable per server when desired. |
Events
Subscribes:
tool.invoke— dispatches MCP tool calls for any registeredmcp__<server>__*name.before:io.input— intercepts slash commands; vetoes the original input, then re-emits a freshio.inputwhosePreloadMessagescarry the expanded prompt.io.session.start/io.session.end— drivelifecycle: sessionconnections.mcp.prompts.list— synchronous query that fillsevents.MCPPromptsList.Promptsso IO plugins can render/help-style listings.
Emits:
tool.register,tool.result,before:tool.result— the catalog projection.io.input— replacement input carryingPreloadMessagesafter a prompt expansion.io.output— system-role error messages when a slash command fails to parse or dispatch.mcp.resource.updated— fired when a subscribed static resource changes.mcp.tools.refreshed,mcp.prompts.refreshed— bookkeeping events emitted after each per-server reconcile.
Deferred for phase 2 (see issue #98):
- MCP sampling (server-initiated LLM calls).
- OAuth dynamic client registration for the HTTP transport.
- SSE legacy transport.
- Roots beyond the session files directory.
Apps
nexus.app.helloworld
Source: plugins/apps/helloworld/plugin.go. Built-in placeholder agent /
proof-of-concept for the bus-bridge pattern.
| Key | Type | Default | Description |
|---|---|---|---|
greeting | string | Hello | Greeting prefix used when responding to hello.request events. |
Gates
Gates are vetoable handlers that subscribe to before:* events and may block
or transform them. See .claude/docs/gates.md
for the underlying veto mechanics.
Pipeline ordering on before:* events
Handlers on a before:* event run in ascending Priority (lower runs first);
dispatch breaks at the first veto. Handlers that share a priority fall back
to subscription order — the first Subscribe call runs first, and the
bus uses a stable sort so this tiebreak is deterministic across rebuilds
and reorders of plugins.active. The engine logs one WARN at boot for
every (before:*, priority) tuple shared by two or more handlers — re-space
the priorities or accept the registration-order tiebreak knowingly.
The shipped gates encode an explicit policy in their priorities so safety outcomes don’t depend on activation order. The values below are the authoritative pipeline; treat them as a contract when adding a new gate.
before:io.output — mutate-then-veto pipeline:
| Priority | Gate | Role |
|---|---|---|
| 8 | nexus.gate.content_safety | Redact (mutate) first, or veto if action=block |
| 9 | nexus.gate.json_schema | Validate / retry on post-redaction content; may mutate |
| 10 | nexus.gate.stop_words | Final ban check on the content that will ship |
| 12 | nexus.gate.output_length | Truncate-retry mutation last |
before:llm.request — cheap-structural → mutators → input-scanners → HITL:
| Priority | Gate | Role |
|---|---|---|
| 6 | nexus.gate.endless_loop | Iteration counter; structural exit |
| 7 | nexus.gate.token_budget | Budget reservation; structural |
| 8 | nexus.tool.discovery.progressive | Mutates tool list (drill-down) |
| 9 | nexus.gate.rate_limiter | Pause until quota available |
| 10 | nexus.gate.tool_filter | Mutates tool list (allow/block) |
| 11 | nexus.gate.prompt_injection | Pattern-scan input |
| 12 | nexus.gate.stop_words | Pattern-scan input |
| 13 | nexus.gate.approval_policy | May trigger HITL — most expensive |
| 15 | nexus.gate.context_window | Compaction trigger |
nexus.gate.endless_loop
Source: plugins/gates/endless_loop/plugin.go.
| Key | Type | Default | Description |
|---|---|---|---|
max_iterations | int | 25 | Maximum LLM calls per turn (gate-/planner-sourced calls excluded). |
warning_at | int | 0 | Emit a warning when this count is reached (0 disables). |
nexus.gate.stop_words
Source: plugins/gates/stop_words/plugin.go. Gates both before:llm.request
(user messages) and before:io.output.
| Key | Type | Default | Description |
|---|---|---|---|
words | list | (empty) | Inline banned words. |
word_files | list | (empty) | Files of newline-separated words. |
case_sensitive | bool | false | Case-sensitive matching. |
message | string | Content blocked: contains prohibited terms. | Veto message. |
nexus.gate.token_budget
Source: plugins/gates/token_budget/plugin.go. Multi-dimensional ceilings
(session / tenant / source_plugin) with block, warn, or downgrade-model
actions. The legacy single-ceiling shape (max_tokens) still works as a
session total-token ceiling.
| Key | Type | Default | Description |
|---|---|---|---|
max_tokens | int | (unset) | Backward-compat session total-token ceiling. |
message | string | Token budget exhausted for this session. | Default veto message for the legacy ceiling. |
on_exceed | string | block | Default action when a ceiling fires (block | warn | downgrade-model). Each ceiling can override. |
downgrade_candidates | list | (empty) | Model IDs the downgrade-model action picks the cheapest entry from (priced via pkg/engine/pricing). |
pricing | map | (merged provider defaults) | Per-model overrides applied to the unified pricing table; same shape as the per-provider pricing block. |
estimate_factor | float | 1.5 | Multiplier on the prompt-length token estimate the gate deducts upfront at before:llm.request (reserve/commit). Tightens the TOCTOU window under concurrent fan-out by booking estimated headroom before any in-flight request returns; the response handler then subtracts the reservation and adds the actual usage so the net effect is exactly the realized spend. Increase to err on the side of overshoot-prevention; decrease to tolerate more in-flight headroom. |
ceilings | list | (empty) | List of ceiling rules. See below. |
Each entry under ceilings:
| Key | Type | Default | Description |
|---|---|---|---|
dimension | string | session | One of session, tenant, source_plugin. |
match | string | (none) | For tenant/source_plugin: only this bucket. |
window | string | session | session (lifetime of the session) or day (rolling UTC midnight). |
on_exceed | string | top-level default | Per-rule override for the gate’s on_exceed. |
max_input_tokens | int | (unset) | Veto/downgrade once cumulative input tokens reach this value. |
max_output_tokens | int | (unset) | Same for completion tokens. |
max_total_tokens | int | (unset) | Same for total tokens. |
max_usd | float | (unset) | Same for USD spend. |
max_usd_per_session | float | (unset) | Convenience alias for max_usd with window: session. |
max_usd_per_day | float | (unset) | Convenience alias for max_usd with window: day. |
message | string | (reason) | Override message emitted on block/warn. |
Tenant ceilings persist via app-scope SQLite (~/.nexus/plugins/nexus.gate.token_budget/store.db). Other dimensions are in-memory per session.
nexus.gate.rate_limiter
Source: plugins/gates/rate_limiter/plugin.go. Vetoes before:llm.request
when the per-window budget is exhausted; the agent’s gate.llm.retry
subscriber re-issues the request after the limiter signals the budget has
freed up. The pre-Phase-3 time.Sleep behavior was removed in alpha — there
is no compat shim.
| Key | Type | Default | Description |
|---|---|---|---|
mode | string | reject | reject (veto, schedule a single one-shot retry once the window ages out) or queue (buffer up to queue.max_pending retry slots; a drainer goroutine emits gate.llm.retry at the configured rate; excess is rejected outright). |
requests_per_minute | int | 60 | Requests allowed per window_seconds. |
window_seconds | int | 60 | Sliding window length. |
pause_message | string | Rate limit reached. Pausing for {seconds}s... | Output template; {seconds} is interpolated. |
queue.max_pending | int | 100 | Maximum buffered retry slots in mode: queue. Ignored in reject mode. |
nexus.gate.tool_timeout
Source: plugins/gates/tool_timeout/plugin.go. Per-call deadline gate. On
tool.invoke it starts a timer; on expiry it emits a tool.timeout
observability event plus a synthetic tool.result carrying an error message
that names the exact override key. A before:tool.result veto suppresses any
late real result for the same call ID so the agent’s pendingToolCalls
counter stays consistent. Note: Go cancellation is cooperative — the original
tool goroutine may keep running until it honors its own context. The gate’s
job is to unblock the agent, not preempt the tool.
Per-tool override keys may be exact tool names (web_fetch) or
path.Match-style globs (mcp.*).
Resolution: an exact key wins; among glob matches the longest pattern wins;
otherwise default_timeout applies.
The synthetic error message format is fixed and intended to be read by operators:
tool <name> exceeded timeout <duration>; raise via gates.tool_timeout.per_tool.<name>: <duration>
| Key | Type | Default | Description |
|---|---|---|---|
default_timeout | duration string | 30s | Applied when no per_tool key matches. |
per_tool | map[string]duration | {} | Per-tool overrides keyed by exact tool name or path.Match glob. |
nexus.gate.prompt_injection
Source: plugins/gates/prompt_injection/plugin.go. Regex-only — no LLM.
| Key | Type | Default | Description |
|---|---|---|---|
action | string | block | block or warn. |
patterns | list | (default set) | Inline regex patterns added to defaults. |
patterns_file | string | (none) | File of newline-separated regexes. |
message | string | Input blocked: potential prompt injection detected. | Block message. |
nexus.gate.json_schema
Source: plugins/gates/json_schema/plugin.go. Validates before:io.output
against a JSON Schema; on failure, asks the LLM to retry.
| Key | Type | Default | Description |
|---|---|---|---|
schema | string | object | (required) | JSON Schema as inline object or string. |
schema_file | string | (none) | Path to a schema file (takes precedence over schema). |
max_retries | int | 3 | Retry attempts. |
retry_prompt | string | (default) | Retry instruction; supports {schema} and {error} templates. |
nexus.gate.output_length
Source: plugins/gates/output_length/plugin.go. Asks the LLM to retry with a
shorter response; allows through after exhausted retries (with a warning).
| Key | Type | Default | Description |
|---|---|---|---|
max_chars | int | 5000 | Maximum response length. |
max_retries | int | 2 | Retry attempts. |
retry_prompt | string | (default) | Retry prompt; supports {length} and {limit} templates. |
nexus.gate.content_safety
Source: plugins/gates/content_safety/plugin.go. Built-in checks all default
to enabled.
| Key | Type | Default | Description |
|---|---|---|---|
action | string | block | block or redact. |
message | string | Content blocked: contains sensitive information ({checks}). | Block/redact message; {checks} lists triggered checks. |
scan_tool_results | bool | false | Also subscribe to before:tool.result and apply checks to tool output. Required to cover sub-agent / delegate output (which reaches the parent via tool.result, not io.output). Off by default because legitimate external tools (web_fetch, knowledge_search) often surface phone numbers / addresses that aren’t leaks; enable for orchestrator-style topologies. |
check_pii_email | bool | true | Detect email addresses. |
check_pii_phone | bool | true | Detect phone numbers. |
check_pii_ssn | bool | true | Detect US SSNs. |
check_secrets_api_key | bool | true | Detect API-key-like strings. |
check_secrets_private_key | bool | true | Detect private-key blocks. |
check_secrets_password | bool | true | Detect password-shaped fields. |
check_credit_card | bool | true | Detect credit-card numbers. |
check_ip_internal | bool | true | Detect RFC1918 / internal IPs. |
custom_patterns | list | (empty) | Each {name, pattern}. |
nexus.gate.context_window
Source: plugins/gates/context_window/plugin.go. Triggers compaction via
memory.compact.request when the estimated context approaches the limit.
| Key | Type | Default | Description |
|---|---|---|---|
max_context_tokens | int | 100000 | Provider context window limit. |
trigger_ratio | float | 0.85 | Trigger compaction at this fraction (0.0–1.0). |
chars_per_token | float | 4.0 | Token estimation ratio. |
nexus.gate.tool_filter
Source: plugins/gates/tool_filter/plugin.go. Modifies request.ToolFilter on
before:llm.request. include takes precedence over exclude.
| Key | Type | Default | Description |
|---|---|---|---|
include | list | (empty) | Allowlist of tool names. |
exclude | list | (empty) | Blocklist of tool names. |
nexus.gate.approval_policy
Source: plugins/gates/approval_policy/plugin.go. Policy-driven approvals
on before:tool.invoke and before:llm.request. The gate evaluates a
config-supplied list of rules, and on first match emits a hitl.requested
event and blocks waiting on hitl.responded. The operator’s choice
resolves to passthrough (allow), veto (reject), or passthrough-with-edits.
| Key | Type | Default | Description |
|---|---|---|---|
rules | list | (empty) | Ordered list of approval rules. First match wins. |
Each rule is a map with the following keys:
| Key | Type | Default | Description |
|---|---|---|---|
match | map | (empty) | Field/value tests against the action payload. String values are glob (*, ?); dotted keys address nested fields (e.g. args.command). |
mode | string | choices | One of free_text, choices, both. |
choices | list | (see) | List of {id, label, kind} (or bare-string id). When omitted in choices mode, defaults to [{id: allow, kind: allow}, {id: reject, kind: reject}]. |
default_choice | string | (empty) | Choice id auto-selected when the timeout elapses. Without a default, a timeout vetoes the action. |
prompt | string | (auto) | Go text/template string rendered against the action payload. Falls back to Approve <kind>: <target> when unset (or empty when prompt_synthesizer is set so the synthesizer can fill it in). |
prompt_synthesizer | string | (none) | Capability ID of a registered prompt synthesizer (e.g. hitl.prompt_synthesizer). When set, the gate emits the request with HITLRequest.PromptSynthesizer populated and an empty Prompt, letting the synthesizer render an LLM-authored approval question via the canonical before:hitl.requested entry point. |
timeout | string | (none) | Go duration (e.g. 5m). When unset, the gate blocks indefinitely. |
Match keys recognized by the runtime payload:
action_kind—tool.invokeorllm.request.tool— the tool name (only meaningful fortool.invoke).args.<dotted>— any nested key inside the tool’s argument map.model— the LLM model id (only meaningful forllm.request).role— the LLM model role (only meaningful forllm.request).
Example:
nexus.gate.approval_policy:
rules:
- match: { action_kind: tool.invoke, tool: shell, args.command: "rm*" }
mode: choices
choices: [allow, reject]
timeout: 5m
default_choice: reject
- match: { action_kind: llm.request, model: "claude-opus-*" }
mode: choices
prompt: "About to call expensive model {{ .model }}. Approve?"
Eval harness
The eval: block configures the offline eval harness invoked via the
nexus eval subcommand. The engine itself ignores this block — only
cmd/nexus/eval.go reads it. Per-flag overrides on the CLI take precedence
over config values, which take precedence over built-in defaults.
eval:
cases_dir: tests/eval/cases
reports_dir: tests/eval/reports
judge:
model: claude-haiku-4-5
temperature: 0
n_samples: 1
cache: true
baseline:
fail_on_score_drop: 0.05
fail_on_latency_p95_drop: 0.20
| Key | Type | Default | Description |
|---|---|---|---|
cases_dir | string | tests/eval/cases | Directory containing case bundles (<id>/case.yaml, input/, journal/, assertions.yaml). Path expansion via engine.ExpandPath. |
reports_dir | string | tests/eval/reports | Directory where nexus eval run writes per-run report directories (<run-id>/report.json, <run-id>/summary.txt, <run-id>/_sessions/). Path expansion via engine.ExpandPath. |
judge.model | string | claude-haiku-4-5 | Model used by the LLM judge for --full semantic assertions. Declared in v1; consumed in Phase 5. |
judge.temperature | float | 0 | Judge sampling temperature. Declared in v1; consumed in Phase 5. |
judge.n_samples | int | 1 | Number of judge samples per assertion; majority-threshold kicks in at >=3. Declared in v1; consumed in Phase 5. |
judge.cache | bool | true | Enable provider prompt cache for judge calls. Declared in v1; consumed in Phase 5. |
baseline.fail_on_score_drop | float | 0 | Absolute pass-rate drop (0–1) that fails nexus eval baseline. 0 disables the gate. CLI flag: --fail-on-score-drop. |
baseline.fail_on_latency_p95_drop | float | 0 | Relative latency p95 increase (per case) that fails nexus eval baseline. 0 disables the gate. CLI flag: --fail-on-latency-p95-drop. |
Subcommand overview
| Command | Description |
|---|---|
nexus eval run [--case <id>] [--cases-dir <path>] [--tags <csv>] [--model <role>] [--deterministic] [--full] [--parallel <n>] [--report-dir <path>] [--config <path>] | Run one or all cases under the cases dir; writes a JSON report. Exits 0 on all-pass, 1 if any case failed. |
nexus eval baseline --against <path> [--report <path>] [--fail-on-score-drop <f>] [--fail-on-latency-p95-drop <f>] [--out <path>] [--config <path>] | Diff a fresh report against a stored baseline; honors thresholds for CI exit codes. --against path can be a report.json file or its containing run-id directory; does not descend a parent that contains multiple runs. |
nexus eval promote --session <id-or-path> --case <new-id> [--cases-dir <path>] [--owner <name>] [--tags <csv>] [--description <text>] [--no-edit] [--force] [--config <path>] | Convert a real session under ~/.nexus/sessions/ into a deterministic eval case. See docs/src/eval/promotion.md. |
nexus eval record --from-session <id-or-path> --case <new-id> [...] | Alias of eval promote — same flag set, same behaviour. |
nexus eval --inspect-mode [--timeout=DURATION] | Single-shot JSON-on-stdin/stdout protocol for external harnesses (Inspect AI, Braintrust, custom CI). Reads one request from stdin, writes one response to stdout. Mutually exclusive with subcommands. Deadline via --timeout flag, NEXUS_EVAL_INSPECT_TIMEOUT env, or 60s default. Wire format documented at docs/src/eval/inspect-protocol.md. |
Environment variables
| Variable | Default | Description |
|---|---|---|
NEXUS_EVAL_INSPECT_TIMEOUT | 60s | Per-request deadline for nexus eval --inspect-mode. Parsed as time.Duration (e.g. 30s, 5m). The --timeout flag overrides this; an empty value falls back to the default. Source: cmd/nexus/eval.go:514-537. |
NEXUS_EVAL_INSPECT_KEEP_SESSIONS | (unset) | When set to any non-empty value, retains the per-call temporary sessions root (os.MkdirTemp directory) for debugging instead of deleting it on exit. Off by default — directory is removed after the response is written. Source: pkg/eval/protocol/runner.go:53-60. |
Cost CLI
nexus cost report aggregates cost-attribution data from session
journals (idea 09). Costs come from llm.response.cost_usd which
providers emit using pkg/engine/pricing — the CLI is provider-agnostic.
| Command | Purpose |
|---|---|
nexus cost report [--session <id>] [--tenant <t>] [--group-by <dim>] [--since <duration>] [--json] [--config <path>] | Aggregate llm.response records by tag dimension. |
Flags:
--session <id>— limit to one session id. Default: every session undersessions.root.--tenant <t>— onlyTags["tenant"] == t.--group-by <dim>— one ofsession_id(default),tenant,project,user,source_plugin,model,task_kind.--since <duration>— only events newer thannow - <duration>(e.g.24h,7d).--json— emit JSON instead of the default table.
Tags are populated by:
- The engine’s
before:llm.requestseeder (session_id, plustenant/project/userfromSessionMeta.Labels). - Each
llm.request-emitting plugin (source_plugin, plustask_kindonreq.Metadata). - Plugins routing decisions (
_routed_by,_routed_rule,_downgraded_by,_downgraded_fromonreq.Metadata).
Session broker (nexus-broker)
The nexus-broker binary (cmd/nexus-broker) is a standalone service, not
an engine plugin. It reads its own YAML config file (default path
broker.yaml, override with -config <path>) and fronts OS-isolated Nexus
instances behind an HTTP/WebSocket gateway.
# broker.yaml
listen_addr: ":8080"
nexus_binary_path: "nexus"
max_concurrent: 8
idle_timeout: 5m
queue_wait_timeout: 30s
release_grace: 10s
| Key | Type | Default | Description |
|---|---|---|---|
listen_addr | string | :8080 | host:port the broker’s HTTP/WS gateway binds to. GET /healthz returns {"status":"ok"}. |
nexus_binary_path | string | nexus | Path to the nexus binary the broker exec()s to spawn instances. Funneled through ExpandPath (supports ~). |
max_concurrent | int | 8 | Maximum number of live instances (one per lease). Each POST /claim acquires a capacity slot before spawning, and the slot is freed on every teardown path (manual POST /release, idle, crash, and any failed/aborted claim), so the live count can never exceed this cap or drift. A claim that arrives at capacity does not fail outright: it parks in a FIFO wait queue bounded by queue_wait_timeout (see below). Set max_concurrent to 0 (or any non-positive value) to mean unlimited (no cap). |
idle_timeout | duration | 5m | How long an instance may sit with no real client input before the broker releases it. “Activity” is only an inbound io frame flowing client → instance (user input); instance → client output, pings, and control frames do not reset the timer. The release reuses the POST /release teardown path (shutdown frame → release_grace → force-kill → reap), so the session is persisted and the client WS closes with the going-away status. A background sweeper polls at min(idle_timeout/4, 15s) (floored at 50ms). Set idle_timeout to 0 (or any non-positive value) to disable idle reaping entirely. |
queue_wait_timeout | duration | 30s | How long an over-capacity POST /claim parks in the FIFO capacity wait queue before giving up. When max_concurrent is full, a claim waits in arrival order; the moment a slot frees (via POST /release, idle, or crash teardown) it is handed directly to the oldest waiter, which then spawns — no fresh claim can barge ahead of a longer-queued one, and the waiters reuse the same single slot counter (no second accounting path). A waiter that exceeds queue_wait_timeout returns HTTP 503 {"error":"capacity wait timed out"} (distinct message from the immediate {"error":"no capacity"}). If the client disconnects while queued, the waiter is dropped from the queue and holds no slot. Set queue_wait_timeout to 0 (or any non-positive value) to disable waiting: an at-capacity claim is then rejected immediately with HTTP 503 {"error":"no capacity"} (no instance spawned). |
release_grace | duration | 10s | How long a release (manual POST /release, and later idle/crash teardown) waits for an instance to shut its engine down cleanly before the broker force-kills it. The graceful path always persists the session; the kill is the orphan-prevention backstop. |
The spawned-instance side is configured by the nexus.io.broker plugin
(broker_addr, lease_id) — see nexus.io.broker in the
I/O section. Both keys fall back to the NEXUS_BROKER_ADDR /
NEXUS_BROKER_LEASE_ID environment variables the broker injects at spawn
(defined as brokerframe.EnvBrokerAddr / brokerframe.EnvLeaseID).
POST /claim (HTTP API, not YAML)
POST /claim mints a lease, spawns an instance with the supplied config, waits
for it to dial back and signal ready, and returns the lease coordinates. The
request is a small JSON envelope; session_id is optional.
// request body
{
"config": "engine:\n name: example\n", // required: full nexus config (YAML text)
"session_id": "prior-session-id" // optional: resume a persisted session
}
When session_id is set the broker spawns the instance with -recall <id> so
the engine reloads that session and replays its history; when omitted it starts
a fresh session. An unknown/invalid session_id makes the engine fail to boot,
so the instance never signals ready and the claim returns 502 (“instance
exited before signalling ready”) rather than silently starting a new session.
// success response (200)
{
"lease_id": "…", // lease handle for this instance
"ws_url": "ws://host:port/lease/<lease>", // client WebSocket endpoint
"session_id": "…" // engine session id: the generated id for a
// new session (capture it to -recall later),
// or the requested id echoed back on resume
}
POST /release/{lease_id} (HTTP API, not YAML)
POST /release/{lease_id} tears a live instance down gracefully. The broker
sends a shutdown frame to the instance, whose nexus.io.broker plugin emits
io.session.end so the engine performs a clean Stop — flushing and
persisting the session before exit. The broker then waits up to release_grace
for the process to exit and force-kills it if that window elapses (no
orphan). The lease is removed and its slot freed. The session directory under
~/.nexus/sessions/<id>/ is left intact and remains resumable via -recall.
| Outcome | Status | Body |
|---|---|---|
| Released (graceful or killed) | 200 | {"status":"released","lease_id":"…"} |
| Unknown / already-released lease | 404 | {"error":"unknown lease"} |
| Missing lease id in path | 400 | {"error":"release requires a lease id"} |
Release is idempotent: releasing an already-gone lease returns 404 rather than
erroring, and concurrent releases of the same lease collapse to a single
teardown.
GET /leases (HTTP API, not YAML)
GET /leases is a read-only introspection surface: it reports the capacity and
queue aggregates plus a snapshot of every live lease, sorted by created_at
then lease_id. It performs no mutation.
// response (200)
{
"max_concurrent": 8, // configured cap (0 = unlimited)
"slots_in_use": 2, // live instances currently holding a slot
"queue_depth": 0, // claims parked in the FIFO capacity wait queue
"leases": [
{
"lease_id": "…",
"session_id": "…", // omitted until reported by the instance
"pid": 41234,
"state": "active", // "spawning" | "active" | "draining"
"reason": "manual release", // teardown reason once draining; omitted otherwise
"last_activity": "2026-06-25T12:00:00Z", // RFC3339
"created_at": "2026-06-25T11:59:30Z" // RFC3339
}
]
}
Surface states: spawning (lease exists, instance not yet registered),
active (registered, frames can flow), draining (a teardown has latched).
Full narrative, the new-vs-resume flow, a WebSocket connect sketch, and the v1 deployment caveats live in the Session Broker guide.
Cross-references
- Plugin System — plugin lifecycle,
Requires()vsDependencies(), capability resolution. - Gates — vetoable event mechanics shared by every gate plugin.
- Tool System — tool choice, parallel dispatch, structured output.
- RAG — embeddings, vector store, ingestion.
- I/O Transport — browser vs Wails, parity rule.
- Desktop Shell — embedder API.
Sandboxing
Nexus tools that shell out (tools/shell) or execute agent-emitted code
(tools/code_exec) route through a single execution-isolation abstraction at
pkg/engine/sandbox. Backends slot in via the Sandbox interface; the v1
release ships host and wasm, with landlock, gvisor, and
firecracker deferred.
Threat model
Agent-emitted code is the load-bearing case. Trail of Bits’ Oct 2025 prompt-injection-to-RCE chain documented multiple production incidents where a compromised LLM context yielded host shell access through under-isolated tool execution. For non-developer end users (the desktop shell ships agents to laptops), “sandbox in name only” is a regression to those incidents waiting to happen.
The wasm backend closes that risk for tools/code_exec by interpreting
agent code inside a wazero-managed Wasm module. The module sees no kernel
syscalls — every escape is a host-side bridge function with explicit
capability gates.
Backends
host (default for tools/shell)
Runs commands directly via os/exec against the host kernel. The
configured allowed_commands allowlist and working_dir apply, but a
permissive allowlist is a host compromise away from arbitrary code
execution. Use this only when commands are trusted (developer machines, CI
runners, allowlists tight enough that escape is implausible).
nexus.tool.shell:
sandbox:
backend: host
allowed_commands: [git, go, npm]
working_dir: ~/.nexus/sessions/${session_id}/files
wasm (recommended for tools/code_exec)
Embeds a Yaegi-compiled-to-Wasm interpreter into the engine binary at build time. The wasm module sees no fs / net / syscalls unless the configured policy explicitly grants them, in which case the calls go through a host bridge function gated per-operation. Bridge surfaces:
| SDK package | Capability tag | Gate config |
|---|---|---|
nexus_sdk/http | cap_net_http | sandbox.net.allow_hosts (exact-match hostnames) |
nexus_sdk/fs | cap_fs_read, cap_fs_write | sandbox.fs_mounts (host→guest bindings, ro/rw) |
nexus_sdk/exec | cap_exec | sandbox.exec_allowed (command allowlist) |
nexus_sdk/env | none | sandbox.env (sandbox-scoped key/value map) |
nexus.tool.code_exec:
compiler: yaegi-wasm
sandbox:
backend: wasm
cache_dir: ~/.nexus/sandbox/wasm/cache
timeout: 30s
net:
allow_hosts: ["api.openai.com", "api.anthropic.com"]
fs_mounts:
- host: ~/.nexus/sessions/${session_id}/files
guest: /workspace
mode: rw
exec_allowed: [git]
env:
WORKSPACE: /workspace
Empty net.allow_hosts denies all HTTP. Empty fs_mounts denies all FS.
Empty exec_allowed denies all subprocess invocation.
Snippet authoring under wasm
package main
import (
"context"
"fmt"
"errors"
nhttp "nexus_sdk/http"
nfs "nexus_sdk/fs"
)
func Run(ctx context.Context) (any, error) {
resp, err := nhttp.Get("https://api.example.com/data")
if err != nil {
if errors.Is(err, nhttp.ErrCapDenied) {
return nil, fmt.Errorf("net.allow_hosts denies api.example.com")
}
return nil, err
}
if err := nfs.WriteFile("/workspace/out.json", resp.Body); err != nil {
return nil, err
}
return resp.Status, nil
}
The nexus_sdk/* packages mirror the shape of net/http, os, os/exec,
and os.Getenv so familiar Go reflexes work. The bridge layer flattens
the ABI: Bodies are []byte, not io.Reader. Streaming reads / writes
are not supported in v1.
What you give up under wasm
- Raw TCP /
net.Dial— not provided. Add a targeted bridge function (nexus_sdk/websocket, etc.) when a real caller needs it. cgo— never. Not a Wasm thing.- Full
database/sqldriver libraries — most native drivers won’t run in Wasm without their own bridges. Use HTTP-shaped database backends (REST, BigQuery) or run database access viatools/shellwith appropriate gating. tools.*typed bindings,parallel.*constructs, skill helpers — v1 surface forfeits these on the wasm path. They remain available undercompiler: yaegi-hostfor trusted skill-author workflows.
Performance
| Operation | Cost |
|---|---|
| WasmBackend cold start (one-time per session) | ~5–9 s wazero AOT compile of the 39 MiB embedded runner |
With cache_dir set, subsequent process startup | ~50–200 ms |
| Per-snippet (warm backend) | ~30–50 ms wall, ~5–30 ms interpretation overhead + bridge round-trips |
Set cache_dir to a stable path (e.g., ~/.nexus/sandbox/wasm/cache) to
amortise the wazero AOT cost across processes.
Future tiers (not in v1)
landlock— Linux-only hardening belowtools/shell.gvisor—runscsubprocess for strongertools/shellisolation under Linux.firecracker— Per-snippet microVM for hosted multi-tenant deployments.
The full-Go (GOOS=wasip1) compile path with auto-bootstrapped Go SDK is
tracked in #71 and ships
only on real demand for full Go stdlib semantics (generics, full
reflect, text/template).
Native Realtime API Integration — Deferred
Both OpenAI Realtime API and Gemini Multimodal Live are entire new
wire protocols separate from the standard chat/generate endpoints already
used by plugins/providers/openai/ and plugins/providers/gemini/. They
require per-provider WebSocket clients with their own session lifecycle,
tool-use envelopes, audio-streaming framing, and turn-taking semantics —
each on the order of a full provider plugin’s implementation surface.
That is too large to ship in the multimodal-foundation PR (#93),
which limits scope to the building blocks: blob store, multimodal
MessagePart plumbing, the EmbeddingsRequest.Inputs shape, the Cohere
multimodal embeddings adapter, and the nexus.memory.vector
store_images opt-in. Native realtime adapters are tracked under issue
#91 as a follow-up.
Until they land, voice-mode users go through the standard pipeline:
ASR (e.g. Whisper) → llm.request → TTS
implemented as the plugins/io/voice/ transport (Phase 4 of #91). The
voice IO plugin is a portable design — when realtime providers are
wired, the same agent configs keep working; only the inner LLM call is
replaced with a streaming session.
What’s needed when these land
For each provider:
- WebSocket client (
gorilla/websocketor stdlib upgrader on the server side; both providers run server-sent WebSockets) honoring the documented session-init handshake. - A new
nexus.io.realtime.<provider>transport plugin OR a new LLM provider variant that consumesvoice.audio.input.chunkevents and emitsvoice.audio.output.chunkevents. Decision deferred to the follow-up issue; both shapes work, but the IO-plugin shape keeps the rest of the engine unaware of the streaming wire details. - Tool-use bridging: realtime APIs surface tool calls inline in the
audio stream — those need to translate into the existing
tool.call/tool.resultevents with no behavior change for tool plugins. - Cancel + barge-in plumbing onto
nexus.control.cancel.
Eval Harness Overview
New here? Start with the quickstart for an end-to-end session→case→CI walkthrough.
Nexus ships a first-class eval workflow built directly on top of the durable journal. The harness records, replays, and scores agent sessions offline — no API key required for the deterministic path — and gates regressions in CI.
This document covers the concept and the moving pieces. For YAML keys see
configuration/reference.md;
for the case.yaml / assertions.yaml schema see
case-format.md.
Why evals (in Nexus terms)
Once the journal exists, every session is replayable. An eval is a journal plus a bundle of assertions that say “this trace is the desired behaviour”. Phase 1 shipped the runner; Phase 2 ships the CLI, the multi-case runner, the baseline differ, and five seed cases.
Two modes:
| Mode | Replay short-circuit | LLM judge | When |
|---|---|---|---|
--deterministic | yes | skipped | every PR; gates merge |
--full | yes | run, temperature=0, cached | nightly; gates release |
The --deterministic contract is binding: PRs never block on judge flake.
--full runs judge calls but still drives them through the journal stash —
side effects are short-circuited even when the rubric is being graded.
--full is reserved for the LLM-judge mode — it is currently a placeholder.
Passing --full prints a warning and runs in deterministic mode
(cmd/nexus/eval.go:108-113). Full plumbing lands in a follow-up.
What a case is
A case is a directory under tests/eval/cases/<id>/:
tests/eval/cases/<id>/
case.yaml # name, description, tags, owner, freshness, model_baseline
input/
config.yaml # engine config under test (typically mock provider)
inputs.yaml # scripted user inputs (record-side; live agent reads from journal)
journal/ # full copy of the source session journal
header.json
events.jsonl
assertions.yaml # deterministic + (Phase 5) semantic assertions
_record/ # optional: go-tagged recorder that regenerates journal/
main.go
journal/ is a 1:1 copy of ~/.nexus/sessions/<source-session>/journal/ at
promotion time. There is no second fixture format.
The runner reads case.yaml, builds the engine from input/config.yaml,
overrides core.sessions.root to a tempdir, calls engine.Replay(), and
collects events for assertion evaluation.
What a report is
A report is one JSON document per nexus eval run invocation:
tests/eval/reports/<run-id>/
report.json # schema_version=1; per-case + summary
summary.txt # human-readable counterpart
_sessions/<id>/ # per-case session workspace (transcript, config snapshot)
schema_version is stable: the baseline differ (pkg/eval/baseline) keys
off field names. Bumping the version is a deliberate event with an explicit
migration note.
How a run flows
- Discovery. The CLI walks
cases_dir, parses eachcase.yaml, filters by--tags. - Per-case engine. Each case constructs its own engine from
input/config.yaml.core.sessions.rootis overridden to a per-run tempdir under<reports_dir>/<run-id>/_sessions/. - Replay.
journal.NewCoordinatorseeds the FIFO stash withllm.response/tool.result/io.ask.responsepayloads from the journal, then re-emitsio.inputevents in seq order. The live agent reacts as if the inputs were fresh; side-effecting plugins detectengine.Replay.Active()and pop the stash instead of calling out. - Observation. After replay finishes, the runner reads the live session’s freshly-written journal as the authoritative observed event stream. A wildcard collector is kept as a fallback only (wildcard dispatch order is post-order, which would skew sequence assertions).
- Assertion evaluation. Each
Assertion.Evaluate(observed, golden)produces anAssertionResult. The case passes iff every assertion passes. - Aggregation.
pkg/eval/report.Aggregaterolls Results into aReportand writes JSON + summary.
How baseline gating works
nexus eval baseline --against <path> loads two reports (file or directory)
and computes a Diff. Per-case it records pass/fail movement, latency p50/
p95 deltas, and token deltas. Per-run it records new/missing cases and the
pass-rate delta.
Pointing
--againstat the right directory.nexus eval run --report-dir Xwrites the report toX/<run-id>/report.json(e.g.X/20260502T143022Z/report.json).baseline --againstaccepts either the run-id subdirectory or areport.jsonfile directly — it does not auto-descend a parent. Use--against X/<run-id>or, when there’s only one run under the parent,--against X/*.
Two thresholds drive the exit code:
eval.baseline.fail_on_score_drop— absolute pass-rate drop threshold.0disables.eval.baseline.fail_on_latency_p95_drop— relative p95-latency increase threshold (per case).0disables.
Plus a hard rule: any case that flipped pass → fail is treated as a
regression and fails the baseline run. The Diff.Breached field is the
machine-readable record of which gate (if any) tripped.
Determinism contract
The journal is the source of truth. During replay:
- LLM providers (Anthropic / OpenAI / Gemini / mock) check
engine.Replay.Active()and emit the next stashedllm.responseinstead of calling the API. - Side-effecting tools (
shell,file,code_exec,web,pdf,ask_user) follow the same pattern withtool.result. - Boot-time emissions (
skill.discover,tool.register, etc.) re-fire live during every replay — they are deterministic by construction. - Live-emitted derived events (
plan.created,plan.result,agent.turn.start/end) are also re-emitted live.
This means a case’s assertions can name any of those event types and they
will all be present in the observed stream during a clean replay. What is
not re-emitted is anything that depended on a side effect that didn’t
happen — most notably, provider.fallback only fires when the primary
errored. The provider-fallback seed case demonstrates how to write
assertions for the boot+config-validation half of that scenario; the live
error path is covered by the integration test under tests/integration/.
Future phases
| Phase | Adds | Status |
|---|---|---|
| 1 | Core runner, 7 deterministic assertions, 1 seed case | Shipped |
| 2 | CLI, multi-case, baseline diff, 5 seed cases | Shipped |
| 3 | nexus eval record / promote (failure → case in one command) | Shipped — see promotion.md |
| 4 | plugins/observe/sampler/ (online sample capture) | Shipped — see Online sampling |
| 5 | --inspect-mode JSON protocol for external harnesses | Shipped — see External harness integration. --full LLM judge remains stubbed. |
The case directory layout finalized in Phase 2 is forward-compatible with
Phase 3 promotion — record writes the same shape that Phase 2 reads. The
report’s schema_version: "1" is the contract Phase 5’s protocol mode and
external harnesses (Inspect AI, Braintrust) will pin against.
Online sampling
Phase 4 adds the nexus.observe.sampler
plugin: an opt-in observer that snapshots a configurable fraction of live
sessions (plus every failed session, when failure_capture is on) into
~/.nexus/eval/samples/<id>/. Captured directories share the
case-compatible journal layout, so they feed straight into the promotion
pipeline once an operator picks one out of the sample set.
Activation is two opt-ins (off by default):
plugins:
active:
- nexus.observe.sampler
nexus.observe.sampler:
enabled: true
rate: 0.05
failure_capture: true
out_dir: ~/.nexus/eval/samples
See the sampler plugin doc for the
capture-decision rules, the eval.candidate event contract, the
pluggable redactor hook, and the integration story with
nexus eval promote.
External harness integration
Phase 5 adds nexus eval --inspect-mode: a single-shot, headless,
stdin/stdout JSON protocol that lets external eval harnesses (AISI
Inspect AI, Braintrust, custom CI tooling) drive Nexus from any
language. Pipe a JSON request in, parse a JSON response out, exit code
indicates success.
echo '{"schema":1,"config_path":"configs/coding.yaml","user_input":"hello"}' \
| nexus eval --inspect-mode
The wire format is the durable contract. Schema-stability snapshot tests
in pkg/eval/protocol/ enforce that the byte layout cannot drift
silently — every change requires updating the snapshot deliberately, and
the version field (schema: 1) is the migration marker external shims
pin against.
Nexus does not ship a Python or Node shim. The protocol is the
contract; any out-of-tree integration is a thin subprocess wrapper. See
inspect-protocol.md for the full reference,
field-by-field documentation, error code table, and a worked Python
shim example you can copy into your harness.
Quickstart — From Session to Green CI
This is the linear walkthrough: capture a real session, promote it into a case, tighten the rubric, run it locally, lock in a baseline, and wire the result into CI. By the end you have one regression test for one bad trace, gating future PRs.
For schema details see case-format.md. For background
concepts see overview.md.
1. Capture a session
Boot Nexus normally and run a session that demonstrates the failure or the behaviour you want to lock in:
bin/nexus -config configs/coding.yaml
Drive it through whatever interaction surfaces the bug. When the session
ends (Ctrl-D in the TUI, or close the browser tab), the journal is flushed
to disk under ~/.nexus/sessions/<id>/.
To find the session ID of the run you just made:
ls -t ~/.nexus/sessions/ | head -1
The IDs are timestamped (20260501T143022Z-style), so the most-recent
directory is the one you want.
2. Promote the session into a case
nexus eval promote copies the session journal verbatim into a case
directory and synthesizes a starter assertions.yaml:
bin/nexus eval promote \
--session 20260501T143022Z \
--case my-regression \
--no-edit
Useful flags:
--owner you@example.com— recorded incase.yaml.--tags react,coding,regression— used by--tagsfiltering at run time.--description "Reproduces the off-by-one in the file diff path."— appears in run summaries.
The promoter prints any warnings (failed-session journals, non-replayable events) to stderr — read them. They tell you where the case will be brittle on replay.
3. Edit the rubric
Open the synthesized assertions file:
$EDITOR tests/eval/cases/my-regression/assertions.yaml
The starter file uses broad event_count_bounds and one
event_sequence_distance. Tighten the bounds where you have signal —
e.g. drop the slack on tool.invoke counts to exactly the number you
care about, or shrink the sequence-distance threshold from 0.30 to
0.15. For latency, lower the p50_ms / p95_ms budgets to whatever
the captured run actually exhibits, plus a small headroom.
See case-format.md for the seven assertion kinds
and their fields.
4. Run the case
bin/nexus eval run --case my-regression
Expected output is a one-liner human summary on stdout plus the report location on stderr:
PASS my-regression 3 assertions 234ms
report: tests/eval/reports/20260501T144517Z/report.json
The full report (JSON) and a human counterpart (summary.txt) land
under tests/eval/reports/<run-id>/. Inspect them when something fails.
5. Establish a baseline
Once the case is green and you trust the trace, copy the run-id directory aside as the reference baseline:
cp -r tests/eval/reports/20260501T144517Z tests/eval/reports/baseline
From any future run, diff against the baseline:
bin/nexus eval baseline \
--against tests/eval/reports/baseline \
--report tests/eval/reports/20260502T081200Z
Run-id subdir convention.
eval run --report-dir Xwrites the report toX/<run-id>/report.json—Xitself does not contain areport.json.baseline --againstaccepts either the run-id subdirectory or areport.jsonfile directly, but does not auto-descend a parent. Point at the run-id dir, not its parent.
6. Wire to CI
Both subcommands use exit codes that gate cleanly:
nexus eval runexits1on any failed case,0otherwise.nexus eval baselineexits1on a threshold breach (fail_on_score_drop,fail_on_latency_p95_drop, or anypass→failflip),0otherwise.
A natural Makefile target:
eval-deterministic:
bin/nexus eval run
bin/nexus eval baseline --against tests/eval/reports/baseline
Wire that into your CI’s PR pipeline. The deterministic path needs no LLM credentials — it replays journals.
Case Format
This page is the schema reference for the files inside a
tests/eval/cases/<id>/ bundle. Conceptual background lives in
overview.md. Top-level eval: keys live in
configuration/reference.md.
Directory layout
tests/eval/cases/<id>/
case.yaml # case metadata
input/
config.yaml # engine config (typically mock provider)
inputs.yaml # scripted user inputs
journal/ # session journal (header + events)
header.json
events.jsonl
assertions.yaml # deterministic + (Phase 5) semantic assertions
_record/ # optional: build-tagged recorder for the journal
main.go # //go:build evalrecord
The directory’s basename becomes the case ID. Hidden directories and
underscore-prefixed children (_record) are ignored by the discovery walk.
case.yaml
| Key | Type | Required | Description |
|---|---|---|---|
name | string | yes | Human-readable case name. Loader rejects empty values. |
description | string | no | Multi-line free text; appears in summaries. |
tags | list | no | Strings used by --tags CLI filtering. Filter is a superset match. |
owner | string | no | Email or handle of the case owner — useful when promoting from real failures. |
freshness_days | int | no | Soft hint that the journal should be re-recorded after N days. Not enforced in v1. |
model_baseline | string | no | The model the journal was originally captured against (e.g. mock, claude-sonnet-4-6). Phase 5 uses this for cross-version drift gating. |
recorded_at | datetime | no | ISO 8601 timestamp of last journal recording. |
Reference: pkg/eval/case/case.go:46-54.
input/config.yaml
A standard Nexus engine config — same schema as any configs/*.yaml. Two
constraints worth noting:
- The runner overrides
core.sessions.rootautomatically, so the case-supplied value is irrelevant in practice (the runner uses a per-run tempdir). - The case must use mock-mode providers or real-credential-free configs.
No
ANTHROPIC_API_KEY, noOPENAI_API_KEY— replay short-circuits hide the actual provider call, but plugin construction must still succeed without one.
input/inputs.yaml
inputs:
- "Why won't main.go build?"
- "Show me the fixed version."
This file is the canonical record of the user side of the dialogue. The
runner does not read it directly during replay — the journal coordinator
re-emits the journaled io.input events instead. inputs.yaml exists for
human auditability and for Phase 3 promotion round-tripping.
journal/
A drop-in copy of ~/.nexus/sessions/<id>/journal/. header.json is
required (provides schema_version for compatibility checks); events.jsonl
contains the full event stream as JSONL envelopes. Rotated segments
(events-NNN.jsonl.zst) are supported transparently by the journal
reader.
For seed cases that are easier to hand-craft than to record, drop a
//go:build evalrecord recorder in _record/main.go:
//go:build evalrecord
package main
import (
"context"
"time"
"github.com/frankbardon/nexus/pkg/engine/journal"
"github.com/frankbardon/nexus/pkg/events"
)
func main() {
w, _ := journal.NewWriter("tests/eval/cases/<id>/journal", journal.WriterOptions{
FsyncMode: journal.FsyncEveryEvent,
BufferSize: 16,
SessionID: "<id>-golden",
})
t0 := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
envs := []journal.Envelope{
{Seq: 1, Ts: t0, Type: "io.session.start", Payload: map[string]any{...}},
{Seq: 2, Ts: t0.Add(10 * time.Millisecond), Type: "io.input", Payload: events.UserInput{Content: "..."}},
// ...
}
for i := range envs { w.Append(&envs[i]) }
ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)
_ = w.Close(ctx)
}
Run with:
go run -tags evalrecord ./tests/eval/cases/<id>/_record/
assertions.yaml
Top-level shape:
deterministic: # list of assertion entries
- kind: <one of the 7 deterministic kinds>
...spec fields...
semantic: [] # reserved for Phase 5
Every entry must have a kind field. Unknown kinds error out at load time
— typos are loud, not silent.
event_emitted
Pass when at least Min and at most Max envelopes match the type and
optional where payload filter. The default is “at least 1, no upper”.
- kind: event_emitted
type: tool.invoke
where:
name: read_file
count: { min: 1, max: 1 }
where does shallow equality on the payload (case-insensitive on field
names). Reference: pkg/eval/case/assertions.go:36-49.
event_count_bounds
Per-event-type count range across the observed stream. Use this for turn-frame expectations.
- kind: event_count_bounds
bounds:
agent.turn.start: { min: 2, max: 2 }
agent.turn.end: { min: 2, max: 2 }
io.input: { min: 2, max: 2 }
Reference: pkg/eval/case/assertions.go:71-74.
event_sequence_strict
Exact match against an ordered pattern, optionally filtered to a subset of event types. The escape hatch — fragile across model upgrades; use sparingly.
- kind: event_sequence_strict
filter: [io.input, agent.turn.start, agent.turn.end, llm.response, tool.invoke, tool.result]
pattern:
- io.input
- agent.turn.start
- llm.response
- tool.invoke
- tool.result
- llm.response
- agent.turn.end
Reference: pkg/eval/case/assertions.go:76-80.
event_sequence_distance
Levenshtein ratio (0.0 = identical, 1.0 = totally different) between the
observed and golden filtered streams. The permissive form of
event_sequence_strict; default-pick for trace-drift gating.
- kind: event_sequence_distance
threshold: 0.15
filter: [io.input, agent.turn.start, agent.turn.end, llm.response, tool.invoke, tool.result]
Reference: pkg/eval/case/assertions.go:53-58.
tool_invocation_parity
Per-tool count parity (within count_tolerance) and, when arg_keys: true,
that each tool’s observed argument key set matches the golden’s. Arg-value
parity is intentionally not checked — values vary across model upgrades.
- kind: tool_invocation_parity
count_tolerance: 0
arg_keys: true
Reference: pkg/eval/case/assertions.go:64-67.
token_budget
Caps tokens read off llm.response events. per_turn: true enforces the
limits per agent turn; otherwise they cap the session total.
- kind: token_budget
max_input_tokens: 1000
max_output_tokens: 500
per_turn: false
Reference: pkg/eval/case/assertions.go:84-88.
latency
Caps p50/p95 turn latency, computed from event timestamp deltas (not
wall-clock). A turn = agent.turn.start … agent.turn.end.
- kind: latency
p50_ms: 500
p95_ms: 2000
Reference: pkg/eval/case/assertions.go:92-95.
Generating a case
For new cases, prefer nexus eval promote (alias nexus eval record) over
hand-crafting the directory. The promoter copies an existing session’s
journal verbatim, projects the journaled io.input events into
inputs.yaml, and synthesizes a starter assertions.yaml you can tighten
afterwards.
nexus eval promote \
--session <session-id-or-path> \
--case <new-case-id> \
[--no-edit] [--force] [--tags ...] [--description "..."]
See promotion.md for the full workflow, including
warnings (failed-session, non-replayable event types) and the
assertions.yaml editing checklist. The hand-crafted _record/main.go
recorder under tests/eval/cases/<id>/ remains supported for synthetic
mock-mode cases that have no source session, but real sessions should go
through promote.
Authoring tips
- Start broad, tighten over time. The first version of a case typically
uses
event_count_boundsplusevent_emittedfor the load-bearing events. Addevent_sequence_distanceonce you’ve seen one or two clean runs. - Filter ruthlessly.
event_sequence_*assertions over an unfiltered stream are fragile because tick events, status updates, and thinking steps slip in and out of runs. Filter to the events you actually care about. - Know what re-emits. Side-effecting events (
llm.response,tool.result) are stashed and re-emitted from the stash; live-emitted events (agent.turn.*,plan.*,tool.invoke,skill.discover) are generated fresh every replay. Boot-time emissions (e.g. capability registration,tool.register) fire live too. Anything that depends on a side-effect failure (provider.fallback, retry events) will not re-fire — assert on the success path or move that case to the live integration test suite. - Tags are a superset filter.
--tags react,mockmatches a case tagged[react, mock, planner]but skips[react]alone. Tag generously.
Reference cases
The five seed cases in-tree are the canonical examples. Read the bundles when authoring a new case — the patterns scale:
tests/eval/cases/build-error-fix/— minimal end-to-end: ReAct + shell tool + 2 turns.tests/eval/cases/react-planner-handoff/— dynamic planner handing tasks to the ReAct agent.tests/eval/cases/multi-subagent-fanout/— orchestrator with parallel workers.tests/eval/cases/provider-fallback/— boot/config validation only; the live error path stays intests/integration/fallback_test.go(replay short-circuit can’t reproduce side-effect failures).tests/eval/cases/skills-discovery/— agent invoking a skill loaded viascan_paths.
Each seed bundle keeps its _record/main.go recorder (build-tagged
//go:build evalrecord) alongside the journal so the trace can be
regenerated deterministically when the schema changes.
Promoting a Session to an Eval Case
nexus eval promote (alias nexus eval record) turns a live session
directory under ~/.nexus/sessions/<id>/ into a deterministic eval case
under tests/eval/cases/<case-id>/. The whole flow runs offline — the
recorded journal is the source of truth, and replay short-circuits every
side effect.
This page covers:
- The failure → case workflow and when to use it.
- Exactly what
promotewrites (and what it deliberately doesn’t). - How to edit the synthesized
assertions.yamlafterwards. - Warnings the tool surfaces and how to interpret them.
- The non-replayable caveat (provider fallbacks, retries, errors).
For YAML schema details see case-format.md. For the
CLI flag list, run nexus eval promote -h.
When to promote
The intended workflow is production failure → offline regression case:
- A real session goes wrong. The journal at
~/.nexus/sessions/<id>/journal/already captured the full event stream — everyllm.response, everytool.result, everyio.input. No additional logging needed. - You run
nexus eval promote --session <id> --case <new-id>. - The promoted case is checked in, replays deterministically in CI, and keeps the failure from regressing.
Promotion is also the easiest way to bootstrap a new case from a clean
session you already ran by hand — just point promote at it instead of
hand-crafting the journal under _record/.
What promote does
The implementation lives in pkg/eval/promote/. The pipeline:
- Validate the source session has a journal and metadata
(
promote.go:validateSessionDir). - Copy the journal byte-for-byte from
<session>/journal/into<case>/journal/— header, active segment, and any rotated segments. Replay reads the same on-disk shape, so a verbatim copy is the right contract. - Copy the config snapshot from
<session>/metadata/config-snapshot.yamlinto<case>/input/config.yaml. The case runs against the same engine config the recorded session used — no rewriting, no redaction (today). If the snapshot is missing (rare — only happens when a session crashed before the engine wrote metadata),promotewrites a placeholder and warns. - Reconstruct
inputs.yamlfrom journaledio.inputevents (inputs.go:ExtractInputs). The runner doesn’t read this file — it re-fires inputs from the journal directly — butinputs.yamlis the canonical record of the user side of the dialogue, kept for human review. - Synthesize a starter
assertions.yamlfrom a single journal pass (scaffold.go:SynthesizeAssertions):event_count_bounds— every distinct event type withmin=max=count.token_budget— observed input/output totals + 10% slack.latency— observed turn-pair p50/p95 + 50% slack.tool_invocation_parity— one block when at least one tool was used, withcount_tolerance: 0andarg_keys: true.semantic: []— empty, with a TODO comment for Phase 5 LLM-judge rubrics.
- Write
case.yamlwithname,description(synthesized when omitted),tags,owner(falling back to$USER, thenunknown),freshness_days: 30,model_baseline(extracted fromcore.models.default), andrecorded_at = now. - Optionally launch
$EDITORon<case>/assertions.yaml. The fallback chain is$EDITOR → $VISUAL → nano → vi. Pass--no-editto skip.
The CLI prints a one-line summary plus warnings to stderr and exits 0 on
success. On any error after the case directory was created, it removes the
partial directory so a retried promote is not blocked by stale shell.
Editing assertions.yaml
The synthesized YAML is intentionally strict — every event-type bound
is min=max=count, every tool count tolerance is 0. This is the right
default: a freshly-promoted case should reproduce the source session
exactly, byte-for-byte. As you fold the case into a broader scenario, you
loosen the parts that legitimately vary:
- Loosen
event_count_boundsfor noise types:core.tick,status.update,thinking.step. These vary across runs even when the load-bearing behaviour is identical. - Tighten
tool_invocation_parityby adding new tools as your case evolves — but keeparg_keys: trueto catch shape regressions. - Add an
event_sequence_distanceassertion (withthreshold: 0.15as a starting point) to gate trace drift across model upgrades. - Add an
event_emittedwithwherefor the load-bearing tool calls (e.g.tool.invoke where {name: read_file}withcount: {min: 1}). - Phase 5: add an
llm_judgerubric to thesemantic:block once the case is stable. The TODO comment in the scaffold reminds you.
The deterministic checks are the regression gate; the rubric is the quality gate. The two answer different questions and should both grow over time.
Warnings
PromoteResult.Warnings (printed to stderr in the CLI) flags conditions
that produced a valid case but might surprise you on replay:
session ended with status=<X>— the source session’smetadata/session.jsonhas a non-completed/non-active status (typicallyfailed). The case still promotes; this warning just tells you the authoring intent is “reproduce the failure”, not “lock in a green baseline”.config-snapshot.yaml not found— rare; only happens for sessions that crashed before the engine could write metadata.input/config.yamlis left as a stub with instructions; you must paste in the engine config manually.journal contains non-replayable event types …— see the next section.
Warnings never fail the command. The CLI prints them and exits 0; library
callers see them on PromoteResult.Warnings.
The non-replayable caveat
Replay short-circuits side effects via the journal stash. That means any event whose presence depended on a side effect failure will not re-fire under replay. Promoting such a session produces a case where the non-replayable branch is silently absent from the live observed stream.
The promoter recognises a small allow-list of these types
(scaffold.go:nonReplayableTypes):
provider.fallback.error/provider.fallback.advance/provider.fallback.exhausted— fired when a primary provider errored and the chain advanced. Replay pops the stashedllm.responsefrom the primary without raising the error, so the fallback never fires.llm.error/tool.error— same shape; replay never re-raises the failure.agent.iteration.exceeded— depends on the agent loop’s runtime behaviour. Re-derived during replay only if the same run-to-completion state holds.
When promote sees any of these in the source journal, it warns. The
case still lands on disk — the deterministic-replay half of the session
(the success path that did run) remains testable. But two follow-ups
are usually appropriate:
- Tighten the synthesized
event_count_boundsto exclude the non-replayable types — otherwise the case will fail withcount=0for those entries on every replay. - Move the failure-mode behaviour to a live integration test under
tests/integration/. Theprovider-fallbackseed case (tests/eval/cases/provider-fallback/case.yaml) shows the pattern: the eval case validates the boot/configuration path, while a live integration test exercises the actual fallback.
Round-trip example
# 1. You ran a session yesterday — find its ID under ~/.nexus/sessions/
ls ~/.nexus/sessions/
# 2. Promote it. --no-edit skips $EDITOR; --force overwrites existing.
nexus eval promote \
--session 20260501T143000Z-abc123 \
--case my-regression \
--tags reproduction,react \
--description "Repro of the missing-tool-result bug from Apr 30." \
--no-edit
# 3. Replay it deterministically. No API key needed.
nexus eval run --case my-regression --deterministic
# 4. Edit the assertions to taste, commit, and CI is gated against
# a future regression of the same bug.
$EDITOR tests/eval/cases/my-regression/assertions.yaml
git add tests/eval/cases/my-regression
The full pipeline runs in seconds for the typical 8–30-event session.
Larger sessions scale linearly with journal length — promote does one
journal pass during synthesis and copies bytes verbatim for the journal
clone, so disk I/O dominates.
Library API
For embedders the surface is promote.Promote(ctx, opts):
import "github.com/frankbardon/nexus/pkg/eval/promote"
res, err := promote.Promote(ctx, promote.PromoteOptions{
SessionDir: "/Users/me/.nexus/sessions/<id>",
CaseID: "my-regression",
CasesDir: "/repo/tests/eval/cases",
Owner: "me@example.com",
Tags: []string{"reproduction"},
Description: "Optional override; defaults to a synthesized stub.",
OpenEditor: false,
Force: false,
})
Reference: pkg/eval/promote/promote.go:PromoteOptions.
The Phase 4 sampler will reuse this entry point: a sampled failed session
becomes a Promote candidate the user can accept with one command.
Inspect-Mode Protocol
nexus eval --inspect-mode is the headless JSON-on-stdin/stdout protocol
that external eval harnesses (AISI Inspect AI, Braintrust, custom CI
tooling) use to drive Nexus. The binary reads one JSON object from stdin,
runs a single agent turn (or multi-turn run, capped by max_turns) under
the supplied config, and writes one JSON object to stdout.
This page is the durable wire-format reference. It is pinned by the
schema-stability snapshot test in
pkg/eval/protocol/schema_test.go.
PRs that change the wire format must update both that snapshot and the
documentation in this file in the same change.
Nexus does not ship a Python shim. A 15-line shim is sketched at the bottom of this page so the interop story is documented; the shim itself is out-of-tree by design (see plan.md, “No Python in this repo”).
Invoking
echo '{"schema":1, ...}' | nexus eval --inspect-mode
Flags:
--inspect-mode— required. Mutually exclusive with subcommands and positional args; combining them returns anINVALID_REQUESTerror.--timeout=<duration>— optional. Per-request deadline. When unset, the env varNEXUS_EVAL_INSPECT_TIMEOUTis honored. Default60s.
The deadline applies to the entire request (engine boot, agent run,
shutdown). Crossing it surfaces as a TIMEOUT error code.
Request
Exactly one JSON object on stdin, terminated by EOF:
{
"schema": 1,
"config_path": "configs/coding.yaml",
"config_inline": "<yaml string>",
"user_input": "explain the build error in main.go",
"max_turns": 8,
"metadata": { "case_id": "swe-bench-1234" }
}
| Field | Type | Required | Semantics |
|---|---|---|---|
schema | integer | yes | Wire-format version. Must be 1 today. |
config_path | string | one of | Path to a YAML config. ~ is expanded. Mutually exclusive with config_inline. |
config_inline | string | one of | YAML config body inline. Mutually exclusive with config_path. |
user_input | string | yes | The single prompt fed into the agent. |
max_turns | integer | no | Hard cap on agent.turn.end events observed. 0 (or omitted) = no protocol-level cap; the agent’s own iteration gate still bounds the run. |
metadata | object | no | Opaque pass-through. Round-tripped to the response. |
Strict parsing applies: unknown fields are rejected with INVALID_REQUEST.
This catches typos at the harness boundary instead of silently defaulting
fields.
The runner overlays your config to make the run hermetic:
core.sessions.rootis overridden to a temp directory underos.TempDir(). The directory is removed on exit unlessNEXUS_EVAL_INSPECT_KEEP_SESSIONS=1is set (useful for post-hoc journal forensics).nexus.io.testis added toplugins.activeif absent; visual transports (nexus.io.tui,nexus.io.browser,nexus.io.wails,nexus.io.oneshot) are stripped because the run must be headless.plugins.nexus.io.test.inputsis set to[user_input],approval_modetoapprove,timeoutto600s. Any other keys you set onnexus.io.test(notablymock_responses) are preserved.
Response
One JSON object on stdout, exit 0 on success:
{
"schema": 1,
"session_id": "01HK...",
"final_assistant_message": "the build error is in line 42",
"tool_calls": [
{
"tool": "shell",
"args": { "cmd": "go build ./..." },
"result_summary": "main.go:42: undefined: Foo",
"duration_ms": 412
}
],
"tokens": { "input": 6213, "output": 1102 },
"latency_ms": 18733,
"metadata": { "case_id": "swe-bench-1234" },
"error": null
}
| Field | Type | Semantics |
|---|---|---|
schema | integer | Mirrors the request’s wire-format version. |
session_id | string | Engine-assigned session UUID. Empty if the engine never reached session bootstrap. |
final_assistant_message | string | Text of the final assistant llm.response (terminal FinishReason). Empty when no terminal turn fired. |
tool_calls | array | Ordered tool.invoke → tool.result pairs in journal order. Always non-null (empty array when no tool calls fired). |
tool_calls[].tool | string | Tool name. |
tool_calls[].args | object | Parsed argument map from the agent’s invocation. |
tool_calls[].result_summary | string | Truncated stringification of the tool’s output (≤ 2 KB, UTF-8 safe; ellipsized when truncated). Includes any error string. |
tool_calls[].duration_ms | integer | tool.result.Ts − tool.invoke.Ts in milliseconds. |
tokens | object | Per-session token totals across every llm.response. |
latency_ms | integer | First-to-last journaled envelope wall time, in milliseconds. |
metadata | object | Round-tripped from the request unchanged. |
error | object | null | Populated on failure; null on success. |
Errors
When the request fails, the process exits non-zero and the response’s
error field is populated. This redundancy is deliberate: harnesses can
key off either signal.
{
"schema": 1,
"tool_calls": [],
"metadata": { "case_id": "..." },
"error": {
"code": "CONFIG_LOAD",
"message": "config load: read /missing.yaml: no such file or directory"
}
}
| Code | When | Typical recovery |
|---|---|---|
INVALID_REQUEST | Wire-format violation: missing/extra field, both config sources set, schema mismatch, unknown field, malformed JSON, or the mode was paired with a subcommand. | Fix the request envelope. |
CONFIG_LOAD | The named config_path could not be read, or the YAML failed to parse. | Verify the path / contents. |
ENGINE_BOOT | Plugin initialization or capability resolution failed. | Inspect engine logs; usually a missing required plugin or bad per-plugin config. |
RUN_FAILED | The engine booted and ran, but projecting the journal into the response shape failed (e.g. malformed events, unreachable journal). Mid-run agent errors generally surface via TIMEOUT when context expires or as a partial response. | Inspect the response’s tool_calls, the journal under NEXUS_EVAL_INSPECT_KEEP_SESSIONS=1, or rerun under nexus eval run with the same config. |
TIMEOUT | The request exceeded the deadline (flag, env, or default 60s) before reaching session-end. | Raise --timeout, lower max_turns, or simplify the case. |
INTERNAL | Unanticipated error. | Treat as a Nexus bug; file an issue. |
Schema versioning
The schema field is the durable contract. Bumping it is a deliberate
event:
- Increment
SchemaVersioninpkg/eval/protocol/protocol.go. - Update the snapshots in
pkg/eval/protocol/schema_test.go. - Add a migration note to this page describing what changed and how external harnesses should adapt.
The schema-stability snapshot test enforces this discipline: a drift in field names, types, or order fails CI until the snapshot is updated deliberately.
External harness integration
Nexus does not ship a Python shim. The protocol is the contract; any
language with subprocess and a JSON parser can drive it. Here is a
minimal Python sketch demonstrating the wire format; copy it out-of-tree
into your eval harness as needed.
# example: drive_nexus.py — NOT shipped with Nexus, copy out-of-tree.
import json
import subprocess
def run_nexus(config_path, user_input, *, max_turns=0, metadata=None,
timeout="60s"):
req = {
"schema": 1,
"config_path": config_path,
"user_input": user_input,
}
if max_turns:
req["max_turns"] = max_turns
if metadata:
req["metadata"] = metadata
proc = subprocess.run(
["nexus", "eval", "--inspect-mode", f"--timeout={timeout}"],
input=json.dumps(req).encode(),
capture_output=True,
check=False,
)
resp = json.loads(proc.stdout)
if resp.get("error"):
raise RuntimeError(f"{resp['error']['code']}: {resp['error']['message']}")
return resp
For Inspect AI specifically, write a Solver whose __call__ drives
this protocol and emits the final assistant message as the model output;
score on the round-tripped metadata.
See also
- Eval Harness Overview — the bigger picture.
- Case Format — the on-disk eval bundle that runs
through
nexus eval runrather than the inspect protocol. pkg/eval/protocol/— Go source of truth for the wire format.pkg/eval/protocol/schema_test.go— snapshot test pinning the format.
Writing Skills
Skills are reusable instruction sets that extend the agent’s behavior without writing Go code. A skill is a directory containing a SKILL.md file with YAML frontmatter and markdown instructions.
Skill Structure
skills/
my-skill/
SKILL.md # Required: frontmatter + instructions
resources/ # Optional: supporting files
template.txt
schema.json
SKILL.md Format
---
name: my-skill
description: >-
A concise description of what this skill does and when it should
be used. This appears in the skill catalog shown to the agent.
metadata:
author: your-name
version: "1.0"
---
# My Skill
## When to use
Describe the situations where this skill should be activated.
## Instructions
1. Step one...
2. Step two...
3. Step three...
Frontmatter Fields
| Field | Required | Description |
|---|---|---|
name | Yes | Unique skill identifier (used in activation) |
description | Yes | What the skill does — shown in the catalog. Write this so the agent knows when to use it. |
metadata.author | No | Who created this skill |
metadata.version | No | Version string |
output_schema | No | Inline JSON Schema for structured output (see Output Schema) |
output_schema_file | No | Path to a .json schema file, relative to the skill directory |
Body Content
The markdown body is loaded into the agent’s context when the skill is activated. Write it as instructions the agent should follow.
Skill Locations
Skills are discovered only from directories listed in the nexus.skills plugin’s scan_paths config — there are no implicit defaults. If scan_paths is empty, no skills are loaded.
Scope is inferred from each scan path:
| Path Pattern | Scope | Trust Level |
|---|---|---|
Under user’s home .nexus/ or .agents/ tree | User | Always trusted |
| Anywhere else | Project | Configurable (ask/always/never) via trust_project |
Configure scan paths explicitly. Tilde paths (~, ~/...) are expanded to the user’s home directory:
nexus.skills:
scan_paths:
- ./skills # project skills, relative to cwd
- ~/.agents/skills # user-scope skills (tilde is expanded)
Example: Code Review Skill
---
name: code-review
description: >-
Review code for quality, bugs, security issues, and style.
Use when the user asks for a code review or wants feedback
on their code changes.
metadata:
author: nexus
version: "1.0"
---
# Code Review
## When to use
Use this skill when the user asks you to review code, check a PR,
or provide feedback on code quality.
## Instructions
1. Read all changed files thoroughly before commenting
2. Check for these categories of issues:
- **Bugs**: Logic errors, off-by-one, null/nil handling, race conditions
- **Security**: Injection, XSS, hardcoded secrets, unsafe deserialization
- **Performance**: Unnecessary allocations, N+1 queries, missing indexes
- **Style**: Naming, formatting, idiomatic patterns for the language
- **Design**: SOLID violations, coupling, missing abstractions
3. Prioritize findings by severity (critical > major > minor > nit)
4. For each finding, explain the issue AND suggest a fix
5. Start with a high-level summary before detailed findings
6. Acknowledge what's done well
Resources
Skills can include resource files in a resources/ subdirectory. The agent can request these via the skill.resource.read event.
skills/
doc-analysis/
SKILL.md
resources/
analysis-template.md
output-format.json
Skill Catalog in System Prompt
When catalog_in_system_prompt: true is set on the skills plugin, discovered skills are listed in the system prompt as XML:
<skills>
<skill name="code-review" scope="project">Review code for quality, bugs, security issues, and style.</skill>
<skill name="doc-analysis" scope="project">Analyze documents and extract structured information.</skill>
</skills>
The agent can then decide to activate a skill based on the user’s request.
Output Schema
Skills can declare an output schema to enforce structured LLM output when the skill is active. The schema is registered with the Schema Registry on activation and deregistered on deactivation.
Inline Schema
For simple schemas, define output_schema directly in the frontmatter:
---
name: code-review
description: Review code for quality and bugs.
output_schema:
type: object
required: [summary, issues]
properties:
summary:
type: string
issues:
type: array
items:
type: object
required: [file, line, severity, message]
properties:
file: { type: string }
line: { type: integer }
severity: { type: string, enum: [critical, major, minor, nit] }
message: { type: string }
---
File-Referenced Schema
For complex schemas, reference a .json file:
---
name: code-review
description: Review code for quality and bugs.
output_schema_file: resources/review.schema.json
---
skills/
code-review/
SKILL.md
resources/
review.schema.json
Paths are resolved relative to the skill directory. Absolute paths are also accepted.
Precedence
If both output_schema and output_schema_file are present, output_schema (inline) wins.
Lifecycle
- Skill activates → schema loaded (inline or from file) →
schema.registeremitted - While skill is active, LLM requests are tagged with
_expects_schemametadata - Schema Registry attaches
ResponseFormatto tagged requests - Provider maps to native structured output or simulates it
- Skill deactivates →
schema.deregisteremitted → tagging stops
When to Use Inline vs File
- Inline: Simple schemas under ~10 fields. Easy to read alongside instructions.
- File: Complex schemas, schemas shared across skills, schemas generated or validated by external tooling.
Best Practices
- Write clear descriptions — The description is how the agent decides whether to activate the skill. Make it specific about the trigger conditions.
- Be prescriptive in instructions — Tell the agent exactly what to do, in what order, and what output to produce.
- Use numbered steps — Structured instructions are easier for the agent to follow.
- Specify output format — If you want structured output, describe the format explicitly.
- Keep skills focused — One skill should do one thing well. Compose multiple skills rather than creating one mega-skill.
- Test with different inputs — Verify the skill produces good results across various scenarios.
Structured Output
This guide covers how to get structured (schema-validated) output from LLM providers in Nexus. The system uses a three-layer design: schema declaration → request tagging → provider execution, with the existing json_schema gate as a safety net.
How It Works
- A schema is registered with the Schema Registry (via
schema.registerevent or direct API) - An LLM request is tagged with
_expects_schemametadata pointing to the schema name - The Schema Registry attaches a
ResponseFormatto the request - The provider maps
ResponseFormatto its native structured output mechanism (or simulates it) - The
json_schemagate optionally validates the response as a safety net
Scenarios
1. Skill with Inline Output Schema
The simplest path — declare the schema directly in your SKILL.md frontmatter.
SKILL.md:
---
name: extract-entities
description: Extract named entities from text.
output_schema:
type: object
required: [entities]
properties:
entities:
type: array
items:
type: object
required: [name, type]
properties:
name: { type: string }
type: { type: string, enum: [person, org, location, date] }
---
# Entity Extraction
Extract all named entities from the user's text. Return only the JSON output.
Config:
plugins:
active:
- nexus.skills
- nexus.llm.openai # or nexus.llm.anthropic
- nexus.agent.react
No additional config needed — the skills plugin handles registration automatically.
What happens:
- User triggers skill activation → skills plugin loads
output_schemafrom frontmatter - Skills plugin emits
schema.registerwith nameskill.extract-entities.output - On each LLM request while skill is active, skills plugin tags
_expects_schema = "skill.extract-entities.output"viabefore:llm.request - Schema Registry sees the tag, attaches
ResponseFormat{Type: "json_schema", Schema: ...}to the request - Provider sends structured output request to the API
- On deactivation, skills plugin emits
schema.deregister
2. Skill with File-Referenced Schema
For complex schemas, keep them in a separate JSON file.
Directory layout:
skills/
data-analysis/
SKILL.md
resources/
analysis.schema.json
SKILL.md:
---
name: data-analysis
description: Analyze datasets and produce structured findings.
output_schema_file: resources/analysis.schema.json
---
# Data Analysis
Analyze the provided dataset and return structured findings.
resources/analysis.schema.json:
{
"type": "object",
"required": ["summary", "findings", "recommendations"],
"properties": {
"summary": { "type": "string" },
"findings": {
"type": "array",
"items": {
"type": "object",
"required": ["metric", "value", "trend"],
"properties": {
"metric": { "type": "string" },
"value": { "type": "number" },
"trend": { "type": "string", "enum": ["up", "down", "stable"] }
}
}
},
"recommendations": {
"type": "array",
"items": { "type": "string" }
}
}
}
The path is resolved relative to the skill directory. The skills plugin loads and parses the file at activation time.
3. Embedder Requesting Structured Output
Embedders can set ResponseFormat directly on LLMRequest, bypassing the registry entirely.
// In your embedder code:
req := events.LLMRequest{
Messages: []events.Message{
{Role: "user", Content: "Analyze this resume..."},
},
ResponseFormat: &events.ResponseFormat{
Type: "json_schema",
Name: "candidate_score",
Schema: map[string]any{
"type": "object",
"required": []string{"name", "score", "reasoning"},
"properties": map[string]any{
"name": map[string]any{"type": "string"},
"score": map[string]any{"type": "integer", "minimum": 1, "maximum": 10},
"reasoning": map[string]any{"type": "string"},
},
},
Strict: true,
},
MaxTokens: 4096,
Stream: true,
}
_ = bus.Emit("llm.request", req)
Use direct ResponseFormat when:
- The schema is known at compile time and won’t change
- You don’t need the registry’s indirection
- You’re building a focused embedder app, not a plugin
4. Plugin Injecting Schema via before:llm.request
A custom plugin can dynamically select schemas based on conversation state.
func (p *MyPlugin) handleBeforeLLMRequest(event engine.Event[any]) {
vp, ok := event.Payload.(*engine.VetoablePayload)
if !ok {
return
}
req, ok := vp.Original.(*events.LLMRequest)
if !ok {
return
}
// Dynamic schema selection based on request context.
if p.shouldUseStructuredOutput(req) {
if req.Metadata == nil {
req.Metadata = make(map[string]any)
}
req.Metadata["_expects_schema"] = "my_plugin.output_schema"
}
}
This pattern works when:
- Schema varies based on conversation state or user input
- Plugin registers multiple schemas and picks one per request
- You want registry-based resolution but custom tagging logic
5. Belt-and-Suspenders with json_schema Gate
Enable both structured output and the json_schema gate for defense-in-depth.
plugins:
active:
- nexus.skills
- nexus.gate.json_schema
- nexus.llm.openai
- nexus.agent.react
nexus.gate.json_schema:
schema:
type: object
required: [entities]
properties:
entities:
type: array
max_retries: 2
How they interact:
- Schema Registry drives generation — provider sends structured output request
json_schemagate validates output — checks_structured_outputmetadata- When
_structured_outputistrue(provider enforced), the gate skips validation - When
_structured_outputis absent orfalse, the gate validates and retries as usual
This gives you native enforcement where available plus validation fallback everywhere else.
Provider Behavior
| Provider | json_object | json_schema | Metadata |
|---|---|---|---|
| OpenAI | Native response_format | Native response_format with strict mode | _structured_output: true |
| Anthropic | Not supported | Simulated via tool-use-as-schema | _structured_output: true |
| Unknown | Ignored | Ignored | No flag set |
Anthropic Simulation Details
Since Anthropic doesn’t support response_format, the provider simulates json_schema mode:
- Injects a synthetic tool
_structured_outputwith the schema as itsinput_schema - Forces
tool_choiceto{"type": "tool", "name": "_structured_output"} - Claude returns structured data as tool call arguments
- Provider unwraps tool arguments back into
LLMResponse.Content - During streaming, tool input deltas are emitted as content chunks
This overrides any existing ToolChoice on the request.
Web Search & Fetch
Nexus gives agents two tools for doing research on the live web:
web_search— returns a ranked list of URLs + titles + snippets for a query.web_fetch— downloads one URL and returns its main article text (or raw text for non-article pages).
They are intentionally split so the agent can triage first and only pay for full-page reads on the hits it actually cares about.
Architecture at a glance
LLM turn
│
▼
web_search tool ──► search.request event ──► search.provider plugin ──► HTTP API
│
bus fills SearchRequest.Results ◄──┘
▼
LLM picks a URL, calls web_fetch
│
▼
web_fetch tool ──► http.Client ──► go-readability / x/net/html ──► tool.result
The web tool plugin (nexus.tool.web) is the only plugin that registers tools with the catalog. Search providers are separate plugins that advertise the abstract search.provider capability. This mirrors how the LLM provider system works: one consumer, pluggable backends, resolved by capability name at boot.
Plugins
| Plugin ID | Role | Notes |
|---|---|---|
nexus.tool.web | Registers web_search and web_fetch. Emits search.request. | Requires a search.provider to be active. Holds the fetch cache. |
nexus.search.brave | search.provider via the Brave Search API. | Needs BRAVE_API_KEY. Free tier: 2k queries/month. |
nexus.search.anthropic_native | search.provider via Anthropic’s built-in web_search tool. | Needs ANTHROPIC_API_KEY. Bills to your Anthropic account at the native web-search rate. Works even when your LLM provider is OpenAI. |
nexus.search.openai_native | search.provider via OpenAI’s Responses API with the built-in web_search tool. | Needs OPENAI_API_KEY. Works even when your LLM provider is Anthropic. |
Adding a new adapter later (Tavily, Exa, Serper, Kagi, a private Searx instance) is a matter of writing a plugin that:
- Advertises
Capabilities() = [{Name: "search.provider"}] - Subscribes to
search.requestand fills the result in place
No changes to nexus.tool.web or to any agent are needed. See the existing adapters in plugins/search/ for reference.
Minimum config
plugins:
active:
- nexus.agent.react
- nexus.llm.anthropic
- nexus.tool.web
- nexus.search.brave # pick exactly one search provider
nexus.search.brave:
api_key_env: BRAVE_API_KEY
timeout: 15s
nexus.tool.web:
search:
count: 10
safe_search: moderate
fetch:
timeout: 20s
max_size: 5MB
extract_mode: readability
If more than one plugin advertising search.provider is active, the engine picks the first in plugins.active order and emits a WARN. Pin one explicitly with a top-level capabilities: block:
capabilities:
search.provider: nexus.search.anthropic_native
plugins:
active:
- nexus.search.brave # still active; pin overrides
- nexus.search.anthropic_native
Configuration reference
nexus.tool.web
| Key | Type | Default | Description |
|---|---|---|---|
search.count | int | 10 | Default max results when the LLM omits count. |
search.safe_search | string | moderate | Provider-dependent safety filter. off / moderate / strict. |
search.language | string | (empty) | BCP-47 language tag forwarded to the provider. |
fetch.timeout | duration | 20s | Per-fetch HTTP timeout. |
fetch.max_size | bytes | 5MB | Hard cap on response body. Excess truncates and errors. |
fetch.user_agent | string | Nexus/0.1 ... | User-Agent header. |
fetch.extract_mode | string | readability | readability or raw. Per-call override via the tool’s extract arg. |
fetch.allowed_domains | list | (empty) | When set, only these domains (and subdomains) are fetchable. |
fetch.blocked_domains | list | (empty) | Always denied, even if allowed_domains includes them. |
fetch.follow_redirects | bool | true | Follow 3xx redirects. |
fetch.max_redirects | int | 5 | Redirect chain limit. |
nexus.search.brave
| Key | Type | Default | Description |
|---|---|---|---|
api_key | string | — | Brave API key (direct literal). |
api_key_env | string | BRAVE_API_KEY | Env var name to read the key from when api_key is unset. |
timeout | duration | 15s | HTTP timeout. |
nexus.search.anthropic_native
| Key | Type | Default | Description |
|---|---|---|---|
api_key | string | — | Anthropic API key. |
api_key_env | string | ANTHROPIC_API_KEY | Fallback env var. |
model | string | claude-haiku-4-5-20251001 | Model used for the one-shot search call. Haiku keeps it cheap. |
timeout | duration | 30s | HTTP timeout (search requires a full LLM round trip). |
nexus.search.openai_native
| Key | Type | Default | Description |
|---|---|---|---|
api_key | string | — | OpenAI API key. |
api_key_env | string | OPENAI_API_KEY | Fallback env var. |
base_url | string | https://api.openai.com/v1/responses | Override for Azure/compatible endpoints. |
model | string | gpt-4o-mini | Model used for the Responses call. |
timeout | duration | 30s | HTTP timeout. |
Tool surface
web_search
| Argument | Type | Required | Notes |
|---|---|---|---|
query | string | yes | The search query. |
count | int | no | Max results. Defaults to search.count. |
freshness | string | no | day / week / month. Adapter best-effort. |
language | string | no | BCP-47 tag. Adapter best-effort. |
Output format: a numbered list of results followed by a JSON payload in the same string. The JSON carries the raw SearchResult structs for consumers that want to parse programmatically (e.g. a downstream tool chained via run_code).
web_fetch
| Argument | Type | Required | Notes |
|---|---|---|---|
url | string | yes | Absolute http or https URL. |
extract | string | no | readability (default) or raw. |
Output is text with a small header block (URL, Title, Byline, Summary, Extract). Readability failures return an error — the agent should retry with extract='raw' rather than silently degrade.
Session caching
Fetch results are memoized inside the plugin keyed on (extract-mode, final-URL) for the duration of the session. This matters because an agent often searches → fetches a URL → reasons → re-fetches the same URL later in the same turn when the conversation loops back to it. The cache clears on io.session.end, so recalling a session in a new engine boot starts fresh.
Search results are not cached (queries are high cardinality and cheap enough).
Gate interaction
Both tools emit through the normal vetoable before:tool.result hook, so every existing gate applies without extra wiring:
| Gate | Effect on web tools |
|---|---|
nexus.gate.content_safety | PII/secret redaction or blocking on fetched page content and on search snippets. |
nexus.gate.output_length | Truncates oversized page extractions with an LLM retry. |
nexus.gate.tool_filter | Allow- or block-list web_search / web_fetch per profile without removing the plugin. |
nexus.gate.prompt_injection | Runs on the LLM’s input, including anything the agent pastes back from a fetch result. |
No new gate is needed specifically for the web tools. If you want to constrain fetch targets to a fixed set of sites, use fetch.allowed_domains — policy at the fetch layer is cheaper and clearer than a custom gate.
Choosing an adapter
| You want… | Pick |
|---|---|
| Cheapest dedicated search API, free tier, LLM-agnostic | nexus.search.brave |
| Zero additional API keys, already paying Anthropic | nexus.search.anthropic_native |
| Zero additional API keys, already paying OpenAI | nexus.search.openai_native |
| Fanout / redundancy across multiple providers | pin one primary, layer a future fallback adapter |
The native adapters make an extra LLM round-trip to Claude/OpenAI to execute the search. This adds latency (roughly one LLM call on top of the downstream search) but means you do not need a second vendor. They are particularly handy during early prototyping, when shipping another API key is more friction than the latency cost.
Bus contract
Everything flows through one event pair. Knowing the shape is enough to write your own adapter.
// pkg/events/search.go
type SearchRequest struct {
Query string
Count int
SafeSearch string
Language string
Freshness string
// Filled by the provider:
Results []SearchResult
Provider string
Error string
}
type SearchResult struct {
Title string
URL string
Snippet string
PublishedAt time.Time
Source string
}
The request is emitted as a pointer payload on search.request. Handlers mutate it in place before Emit returns, so the tool plugin sees the result synchronously. This is the same pattern as tool.catalog.query and memory.history.query.
Adapter handlers must:
- Ignore the event if
req.Provider != ""(someone else already answered). - Set
req.Provider = pluginIDwhether the call succeeded or failed. - Set
req.Errororreq.Results, not both.
Writing a new adapter
package tavily
import (
"github.com/frankbardon/nexus/pkg/engine"
"github.com/frankbardon/nexus/pkg/events"
)
const pluginID = "nexus.search.tavily"
type Plugin struct{ /* … */ }
func (p *Plugin) Capabilities() []engine.Capability {
return []engine.Capability{{Name: "search.provider"}}
}
func (p *Plugin) Init(ctx engine.PluginContext) error {
// … wire config, HTTP client, API key …
p.bus = ctx.Bus
p.bus.Subscribe("search.request", p.handleSearch,
engine.WithPriority(50), engine.WithSource(pluginID))
return nil
}
func (p *Plugin) handleSearch(e engine.Event[any]) {
req, ok := e.Payload.(*events.SearchRequest)
if !ok || req.Provider != "" {
return
}
results, err := p.callTavily(req)
req.Provider = pluginID
if err != nil {
req.Error = err.Error()
return
}
req.Results = results
}
Register the factory in pkg/engine/allplugins/register.go and add a search section to your config. The web tool picks it up automatically via the capability system.
Troubleshooting
-
no search provider answered — check that a plugin advertising 'search.provider' is activeThe web tool dispatched a search but no adapter handled it. Activate one of the provided adapters or your own. -
readability extraction failed … (try extract='raw')The page is not an article (docs site, table, forum, dashboard). Re-call withextract: raw. -
host "example.com" is not allowed by policyYourfetch.allowed_domainsorfetch.blocked_domainsrejected the URL. Adjust the list or remove the restriction. -
HTTP 401/403 from the adapter Check the API key env var. Each adapter logs the variable it looked at during boot.
Retrieval-Augmented Generation (RAG)
Nexus has first-class RAG support built from two primitive capabilities (embeddings.provider, vector.store) and three consumer plugins (nexus.rag.ingest, nexus.tool.knowledge_search, nexus.memory.vector). Each layer is swappable via the standard capability/adapter system, so adding a new vector backend or embedding provider doesn’t ripple through any other code.
This guide walks through the common path: stand up a knowledge base, point an agent at it, and let the LLM cite sources. Reference docs for each plugin live under Plugin Reference — the goal here is to give you a working setup in ten minutes.
What you get
- Knowledge-base search:
knowledge_searchtool the LLM calls to pull supporting passages from configured namespaces. Returns top-k chunks with similarity scores and source paths for citation. - Per-agent semantic memory: automatic recall of relevant past content (compaction summaries, explicit stores) on every user turn, injected into the system prompt.
- Bulk-ingest CLI:
nexus ingestboots a minimal engine to chunk, embed, and store files without a running agent. - Watch-mode ingestion:
fsnotify-backed directory watchers re-ingest files on write and drop chunks on delete.
Architecture at a glance
flowchart TB
subgraph Caps["⚡ Capabilities"]
direction LR
EP["embeddings.provider<br/><sub>nexus.embeddings.openai · …</sub>"]
VS["vector.store<br/><sub>nexus.vectorstore.chromem · …</sub>"]
end
subgraph Consumers["🔌 Consumer plugins"]
direction LR
ING["nexus.rag.ingest<br/><sub>chunk + cache + fsnotify</sub>"]
KS["nexus.tool.knowledge_search<br/><sub>LLM-facing tool</sub>"]
MV["nexus.memory.vector<br/><sub>per-agent recall</sub>"]
end
ING -- embeddings.request --> EP
KS -- embeddings.request --> EP
MV -- embeddings.request --> EP
ING -- vector.upsert --> VS
KS -- vector.query --> VS
MV -- vector.query / upsert --> VS
classDef cap fill:#1e3a5f,stroke:#4a90e2,stroke-width:2px,color:#fff;
classDef consumer fill:#2d4a3e,stroke:#5fb878,stroke-width:1.5px,color:#fff;
class EP,VS cap;
class ING,KS,MV consumer;
Everything flows through the bus. No plugin imports another. Adding a new backend means writing one plugin that advertises a capability and subscribes to its events.
Quickstart
1. Provide an OpenAI API key
The default embedding adapter calls OpenAI’s text-embedding-3-small model. Set the key in env or .env:
export OPENAI_API_KEY=sk-...
You can also keep your existing Anthropic key for the LLM — embeddings and chat use different keys.
2. Ingest some content
Bulk-load a directory using the CLI subcommand:
bin/nexus ingest --namespace=kb --glob="*.md" ./docs
Output looks like:
OK docs/getting-started/installation.md (3 chunks, 0 cached)
OK docs/architecture/overview.md (5 chunks, 0 cached)
...
ingested 24 file(s), 0 failed; 87 chunks total (0 from cache)
Files persist to ~/.nexus/vectors/<namespace>/. Re-running the same command shows 87 chunks total (87 from cache) — the embedding cache shortcuts unchanged content.
3. Run an agent that knows about it
The configs/rag.yaml profile wires everything up:
bin/nexus -config configs/rag.yaml
You can ask the agent questions about the ingested content:
> What does the docs say about the plugin lifecycle?
[The agent calls knowledge_search → top hits from architecture/plugin-system.md]
The plugin lifecycle has three phases: Init, Ready, and Shutdown.
During Init, plugins receive a PluginContext with config, bus, logger,
and registry handles. Ready runs after every plugin's Init has
completed... (source: docs/architecture/plugin-system.md, chunk 2)
That’s the full happy path. The rest of this guide covers the moving parts.
Plugins
| Plugin ID | Role |
|---|---|
nexus.embeddings.openai | embeddings.provider — OpenAI embeddings API |
nexus.embeddings.mock | embeddings.provider — deterministic hash-based vectors for tests, no network |
nexus.vectorstore.chromem | vector.store — chromem-go backend, pure Go, JSON on-disk persistence |
nexus.rag.ingest | Ingestion: chunker + embedding cache + fsnotify watcher; backs nexus ingest |
nexus.tool.knowledge_search | LLM-facing search tool; mirrors web_search for configured knowledge bases |
nexus.memory.vector | Per-agent semantic recall; advertises memory.vector |
Common configurations
Knowledge base only
The agent has a knowledge_search tool but no automatic recall. Good when you want full LLM control over when retrieval happens.
plugins:
active:
- nexus.io.tui
- nexus.llm.anthropic
- nexus.agent.react
- nexus.embeddings.openai
- nexus.vectorstore.chromem
- nexus.rag.ingest # only needed if you ingest at runtime
- nexus.tool.knowledge_search
nexus.tool.knowledge_search:
namespaces: [kb]
default_namespaces: [kb]
top_k: 5
Per-agent semantic memory only
No tool — the agent just gets relevant past context auto-injected on every turn.
plugins:
active:
- nexus.io.tui
- nexus.llm.anthropic
- nexus.agent.react
- nexus.embeddings.openai
- nexus.vectorstore.chromem
- nexus.memory.vector
nexus.memory.vector:
top_k: 5
auto_store_compaction: true
The plugin auto-stores compaction summaries (so trimmed context stays recallable) and on every io.input queries the agent’s namespace and renders hits as a <recalled_memory> XML block in the next system prompt. Conservative by default: it does not auto-store every user message — auto_store_user_input is false until you opt in.
Watch-mode ingestion
For a docs site or knowledge wiki, point a watcher at a directory:
nexus.rag.ingest:
chunker:
size: 1000
overlap: 200
watch:
- path: ./docs
glob: "*.md"
namespace: project-docs
- path: ./knowledge-base
namespace: kb
fsnotify re-ingests on write (debounced 250ms to coalesce save bursts) and drops the file’s chunks on delete. Chunk IDs are deterministic (<sha256-prefix-of-abspath>-<chunk-idx>) so the upsert path is idempotent and deletes don’t need a preceding query.
Both: tool + memory + watcher
The configs/rag.yaml example activates everything. Tune namespaces and salience knobs to taste.
Embedding provider
OpenAI is the only built-in adapter today. To use it:
nexus.embeddings.openai:
api_key_env: OPENAI_API_KEY
# model: text-embedding-3-small # default; -large is 3x cost
# dimensions: 1536 # provider-default; smaller = cheaper, less accurate
# base_url: https://api.openai.com/v1/embeddings # override for Azure / proxies
For tests or offline development, swap in the mock:
plugins:
active:
- nexus.embeddings.mock
# ...
nexus.embeddings.mock:
dimensions: 128 # default; size doesn't matter for tests
nexus.embeddings.mock produces deterministic hash-based vectors. Same text in → same vector out, no network. Used by the integration test suite and useful for exercising the rest of the stack without burning OpenAI credits.
To add a new provider (Anthropic Voyage, Ollama, local models, etc.) you write one plugin: advertise embeddings.provider, subscribe to embeddings.request, fill req.Vectors / req.Provider / req.Error in place. See Bus contract below.
Vector store
chromem-go is the only built-in adapter. Pure Go, no CGO, in-memory with JSON on-disk persistence.
nexus.vectorstore.chromem:
path: ~/.nexus/vectors # default
compress: false # gzip-compress collections on disk
Namespaces map 1:1 to chromem collections. Each namespace becomes a subdirectory under path. Suitable up to low millions of chunks; swap for sqlite-vec / pgvector / Qdrant when you outgrow it. The vector.store event surface stays identical.
Choosing namespaces
Namespaces isolate logically distinct knowledge. Three common shapes:
| Use case | Example namespace | Notes |
|---|---|---|
| Project documentation | project-docs | One namespace per project; switch profiles to switch context. |
| Shared knowledge base | kb | Cross-project FAQ, runbooks, playbooks. |
| Per-agent semantic memory | memory-{InstanceID} | Auto-derived by nexus.memory.vector — sanitized InstanceID. |
nexus.tool.knowledge_search enforces an allow-list: the LLM-supplied namespaces arg is intersected with the configured namespaces list, so the tool can’t reach into stores it shouldn’t see. The memory.vector namespace is intentionally separate from any knowledge-base namespace — semantic memory is an internal detail you wouldn’t expose as a citable source.
Embedding cache
nexus.rag.ingest caches content-hash → vector on disk under ~/.nexus/vectors/_cache/ by default. Cache hits skip the embedding API call entirely.
The cache is keyed on content hash only — not per embedding model. If you switch embedding models, drop the cache directory. (Mixing vectors from two models in one namespace is incoherent anyway, so re-ingest is required either way.)
rm -rf ~/.nexus/vectors/_cache/ # after switching embedding model
nexus ingest --namespace=kb ./docs # rebuild
Knowledge-search tool
The tool registers as knowledge_search (mirroring web_search), described to the LLM as searching configured knowledge bases. Behavior on invoke:
- Embed the query.
- Fan out
vector.queryacross the requested-and-allowed namespaces. - Merge hits, sort by similarity, truncate to top-k.
- Return JSON with
rank,namespace,similarity,source,chunk_idx,content, and (optional)metadata— enough for the LLM to cite.
Configuration knobs:
nexus.tool.knowledge_search:
namespaces: [kb, project-docs] # required allow-list
default_namespaces: [kb] # used when LLM omits the arg
top_k: 5 # default; LLM may override per-call up to maxTopK=50
include_metadata: true # whether to return raw metadata map
The system-prompt example in configs/rag.yaml nudges the LLM toward calling knowledge_search first on factual questions — adjust this prompt for your domain so the tool gets used at the right times. Without a hint, the LLM may try to answer from training data on questions you wanted grounded in the KB.
Vector memory
nexus.memory.vector runs three behaviors:
- On
io.input(priority 10, before the agent’s handler at 50): embed the user message → query the agent’s namespace → stash hits → render them as a<recalled_memory>block via thePromptRegistryfor the nextllm.request. - On
memory.compacted: auto-store the compaction summary so past context stays recallable after the history buffer trims it. - On
memory.vector.store: explicit-store entry point for tools or plugins that want to record content deliberately.
nexus.memory.vector:
# namespace: memory-default # default: "memory-{InstanceID}"
top_k: 5
min_similarity: 0.3 # filter out weak hits from the prompt
auto_store_compaction: true # auto-write summaries on memory.compacted
auto_store_user_input: false # off by default — you usually don't want this
section_priority: 45 # prompt ordering vs. other sections
Vector memory vs long-term memory
Both plugins persist across sessions, but they solve different problems and don’t share storage:
nexus.memory.longterm | nexus.memory.vector | |
|---|---|---|
| Address by | key (LLM-managed) | embedding (semantic similarity) |
| Storage | one markdown file per entry, YAML frontmatter | vector store namespace |
| LLM tools | memory_read / memory_write / memory_list / memory_delete | none — automatic |
| Best for | structured notes, preferences, facts to remember exactly | fuzzy recall of past content, summaries, topics |
They coexist. Long-term is the agent’s deliberate filing cabinet; vector memory is the agent’s automatic associative recall. Activate one or both; nothing in either plugin references the other.
Tool surface
knowledge_search
| Argument | Type | Required | Notes |
|---|---|---|---|
query | string | yes | The semantic query. Phrase as you’d phrase a search. |
namespaces | array of string | no | Subset of allowed namespaces. Defaults to default_namespaces. |
k | int | no | Max results. Defaults to plugin’s top_k, capped at 50. |
Output is a JSON document; the LLM is expected to read it and quote source paths. Example:
{
"query": "what is the plugin lifecycle?",
"results": [
{
"rank": 1,
"namespace": "project-docs",
"similarity": 0.84,
"source": "docs/architecture/plugin-system.md",
"chunk_idx": "2",
"content": "Each plugin goes through three lifecycle phases...",
"metadata": { "source": "docs/architecture/plugin-system.md", "chunk_idx": "2", "chunk_size": "768" }
}
]
}
Bus contract
Five event types make the whole stack work. Knowing the shape is enough to write your own adapter.
// pkg/events/embeddings.go — primitive
type EmbeddingsRequest struct {
Texts []string
Model string
Dimensions int
Vectors [][]float32
Provider string
Usage EmbeddingsUsage
Error string
}
// pkg/events/vector.go — primitive
type VectorUpsert struct { Namespace string; Docs []VectorDoc; Provider string; Error string }
type VectorQuery struct { Namespace string; Vector []float32; K int; Filter map[string]string;
Matches []VectorMatch; Provider string; Error string }
type VectorDelete struct { Namespace string; IDs []string; Provider string; Error string }
type VectorNamespaceDrop struct { Namespace string; Provider string; Error string }
// pkg/events/rag.go — ingest
type RAGIngest struct { Path string; Namespace string; Metadata map[string]string;
Provider string; Chunks int; SkippedCached int; Error string }
type RAGIngestDelete struct { Path string; Namespace string;
Provider string; Deleted int; Error string }
// pkg/events/memory_vector.go — explicit store
type VectorMemoryStore struct { Content string; Source string; Metadata map[string]string;
Provider string; Error string }
Every payload is emitted as a pointer so providers fill results in place before Emit returns. This is the same sync pointer-fill pattern used by search.provider, tool.catalog.query, and memory.history.query.
Adapter handlers must:
- Ignore the event if
payload.Provider != ""(someone else already answered). - Set
payload.Provider = pluginIDwhether the call succeeded or failed. - Set either
payload.Erroror the result fields, not both.
Bulk-ingest CLI reference
nexus ingest --namespace=NAME [flags] PATH [PATH...]
| Flag | Default | Description |
|---|---|---|
--namespace | (required) | Target namespace in the vector store. |
--glob | (empty) | Filename glob — matched against the path-relative-to-root and the basename. |
--concurrency | 4 | Max files ingested in parallel. |
--chunk-size | 1000 | Chunker target size (chars). |
--chunk-overlap | 200 | Chunker overlap (chars). |
--vector-path | ~/.nexus/vectors | Vector store directory. |
--cache-path | ~/.nexus/vectors/_cache | Embedding cache directory. |
--model | text-embedding-3-small | Embedding model. |
The subcommand boots a minimal engine with only nexus.embeddings.openai, nexus.vectorstore.chromem, and nexus.rag.ingest active — no agent, no LLM, no IO. Useful for offline pre-loading before an agent starts.
Gate interaction
The retrieve tool emits through the normal vetoable before:tool.result hook, so existing gates apply unchanged:
| Gate | Effect on RAG |
|---|---|
nexus.gate.content_safety | PII/secret redaction or blocking on retrieved chunk content. |
nexus.gate.output_length | Truncates oversized search outputs with an LLM retry. |
nexus.gate.tool_filter | Allow-/block-list knowledge_search per profile. |
nexus.gate.prompt_injection | Runs on the LLM’s input, including anything pasted back from search results. |
Per-namespace access policy lives in nexus.tool.knowledge_search.namespaces, not a gate. Policy at the tool layer is cheaper and clearer than a custom gate.
Writing a new adapter
The pattern is identical for embedding providers and vector stores. Here’s a minimal embeddings.provider:
package myembed
import (
"github.com/frankbardon/nexus/pkg/engine"
"github.com/frankbardon/nexus/pkg/events"
)
const pluginID = "nexus.embeddings.myembed"
type Plugin struct{ /* ... */ }
func (p *Plugin) Capabilities() []engine.Capability {
return []engine.Capability{{Name: "embeddings.provider"}}
}
func (p *Plugin) Init(ctx engine.PluginContext) error {
p.bus = ctx.Bus
p.bus.Subscribe("embeddings.request", p.handle,
engine.WithPriority(50), engine.WithSource(pluginID))
return nil
}
func (p *Plugin) handle(e engine.Event[any]) {
req, ok := e.Payload.(*events.EmbeddingsRequest)
if !ok || req.Provider != "" {
return
}
vectors, err := p.callMyAPI(req.Texts, req.Model)
req.Provider = pluginID
if err != nil {
req.Error = err.Error()
return
}
req.Vectors = vectors
}
Register the factory in pkg/engine/allplugins/register.go and add a config block. Consumers (ingest, retrieve tool, vector memory) pick it up automatically via the capability system. No changes elsewhere.
A vector-store adapter follows the same shape but subscribes to all four vector.* events. See plugins/vectorstore/chromem/plugin.go for the reference implementation — about 240 lines including all four handlers.
Out of scope (for now)
These weren’t in the initial RAG pass and are tracked separately:
- Auto-retrieve gate on
before:llm.request(system injection without LLM tool call). Distinct UX questions around latency budget and provenance — theknowledge_searchtool path makes the retrieval visible to the LLM and the user, which is usually what you want. - Re-ranking (cross-encoder, LLM-based). Future
rag.rerankercapability. - Additional vector store backends (
sqlite-vec,pgvector, Qdrant). Thevector.storeevent surface is designed so they slot in without breaking changes. - Additional embedding providers (Anthropic Voyage, Ollama, local sentence-transformers).
Troubleshooting
-
no embeddings provider answered — check that a plugin advertising 'embeddings.provider' is activeActivatenexus.embeddings.openai(production) ornexus.embeddings.mock(testing). -
expected N vectors, got 0The provider returned an error. Look up at the previous log line for the underlying API failure (HTTP status, key issue, model name typo). -
ingest: read FILE: permission deniedThe plugin runs as the same user as the engine. Make sure files are readable and that watch-modepath:entries point at directories that don’t shift permissions. -
Cache hits never happen Cache lives at
~/.nexus/vectors/_cache/by default. Check thatcache_dir(if overridden) is writable and persistent. Also remember the cache is keyed on content hash, so even one trailing newline change invalidates an entry. -
Search returns empty matches when content was definitely ingested Check the namespace in your
knowledge_searchconfig matches the namespace you ingested into. Namespace mismatches don’t error — they just return zero results. -
Sliced docs site looks like noise after retrieval The default chunker is generic prose-tuned (1000/200). For code, mixed prose+code, or very short pages, try smaller chunks (
chunk-size: 600,chunk-overlap: 120). Per-content-type chunkers are a future enhancement.
Integration Testing
Nexus provides an integration test framework for automated validation of test configurations. Tests run real engines with real LLM calls, verifying end-to-end behavior.
Quick Start
# Run all integration tests
go test -tags integration ./tests/integration/ -v
# Run a specific test
go test -tags integration ./tests/integration/ -run TestMinimal -v
# With timeout (tests make real API calls)
go test -tags integration ./tests/integration/ -timeout 5m -v
Prerequisites: ANTHROPIC_API_KEY must be set in the environment.
Architecture
Three components work together:
nexus.io.test— IO plugin that replacesnexus.io.tuiin test configs. Feeds scripted inputs, collects all events, handles approvals.pkg/testharness— Go test helper that boots the engine, waits for completion, and provides assertion methods.- Semantic judge — Optional Haiku-based LLM judge for evaluating dynamic response content.
test config (YAML with nexus.io.test)
↓
Engine boots normally (real plugins, real LLM)
↓
Test IO plugin emits scripted io.input events
↓
Collects ALL bus events
↓
Go test assertions on collected events
↓
Optional: LLM-as-judge for semantic validation
Writing Tests
Basic Test
//go:build integration
package integration
import (
"testing"
"time"
"github.com/frankbardon/nexus/pkg/testharness"
)
func TestMyFeature(t *testing.T) {
h := testharness.New(t, "configs/test-my-feature.yaml",
testharness.WithTimeout(60*time.Second),
)
h.Run()
h.AssertEventEmitted("io.output")
h.AssertNoSystemOutput()
}
Test Config
Test configs use nexus.io.test instead of nexus.io.tui:
plugins:
active:
- nexus.io.test # replaces nexus.io.tui
- nexus.llm.anthropic
- nexus.agent.react
# ...
nexus.io.test:
inputs:
- "Hello, who are you?"
approval_mode: approve
timeout: 60s
Mock LLM Responses
Most gate and plugin tests don’t need a real LLM. Use mock_responses to inject synthetic responses — no API key, no cost, millisecond execution:
nexus.io.test:
inputs:
- "Include the word FORBIDDEN in your response."
mock_responses:
- content: "This should never be seen."
timeout: 15s
Gates fire before mock responses (priority 10 vs 20), so gate behavior is tested accurately. The mock just replaces the expensive LLM call.
Override Config Per Test
Use copyConfig to create a temp config with different inputs or settings:
func TestStopWords(t *testing.T) {
cfg := copyConfig(t, "configs/test-all-gates.yaml", map[string]any{
"nexus.io.test": map[string]any{
"inputs": []string{"Include FORBIDDEN_WORD in your response."},
"approval_mode": "approve",
"timeout": "30s",
},
})
h := testharness.New(t, cfg, testharness.WithTimeout(45*time.Second))
h.Run()
h.AssertSystemOutputContains("Content blocked")
}
Assertion Reference
Tier 1: Deterministic (free, fast, reliable)
| Method | What it checks |
|---|---|
AssertBooted(pluginIDs...) | Plugins were initialized |
AssertEventEmitted(type) | At least one event of this type |
AssertEventNotEmitted(type) | No events of this type |
AssertEventCount(type, min, max) | Event count within range |
AssertOutputContains(substring) | Assistant output contains text |
AssertOutputNotContains(substring) | Assistant output does not contain text |
AssertSystemOutputContains(substring) | System-role output (gate messages) contains text |
AssertNoSystemOutput() | No system-role outputs (no gate vetoes) |
AssertToolCalled(name) | Tool was invoked |
AssertToolNotCalled(name) | Tool was not invoked |
AssertSessionArtifact(relPath) | File exists in session directory |
Tier 2: Semantic (LLM judge, ~$0.001/assertion)
| Method | What it checks |
|---|---|
AssertOutputSemantic(criteria) | Haiku judges if output satisfies criteria |
Semantic assertions require ANTHROPIC_API_KEY. Tests are skipped (not failed) if no judge is configured.
h.AssertOutputSemantic("response recalls the user's earlier question about greetings")
Harness Options
| Option | Default | Purpose |
|---|---|---|
WithTimeout(duration) | 90s | Max time before harness gives up |
WithRetainSession() | off | Keep session dir on failure for debugging |
WithJudge(judge) | auto Haiku | Custom semantic judge implementation |
Raw Event Access
For assertions not covered by built-in methods:
for _, e := range h.Events() {
if e.Type == "llm.response" {
// inspect e.Payload
}
}
Build Tags
Integration tests use //go:build integration so they don’t run with go test ./.... They require API keys and make real LLM calls.
# Unit tests only (default)
go test ./...
# Integration tests only
go test -tags integration ./tests/integration/
# Both
go test -tags integration ./...
Testing UI Plugins (Future)
The test IO plugin validates the bus contract shared by all IO plugins (TUI, browser, wails). When integration tests pass, the bus-side behavior is validated.
Transport-specific rendering requires separate test suites per plugin:
| Plugin | Transport | Test Approach |
|---|---|---|
| TUI | BubbleTea | teatest package (headless terminal simulation) |
| Browser | HTTP/WS | httptest + WebSocket client |
| Wails | Runtime bindings | Mock runtime.Runtime interface |
These validate that the transport correctly bridges bus events to/from the UI. The test IO plugin serves as a reference for the event flow, ordering, and payload shapes.
Future work: extract an IOContract interface from common subscription/emission patterns so any IO plugin can run a shared contract test suite.
Plugin Contract Tests
Every Nexus plugin declares two event-contract methods on the Plugin interface:
Subscriptions() []EventSubscription
Emissions() []string
These declarations are used by the lifecycle manager for ordering and by observability tooling for plugin manifests. Without tests, nothing prevents a plugin from emitting an event type it never declared, or from declaring a subscription it never wires up. The contract harness in pkg/testharness/contract/ makes those assertions cheap to write.
It’s a separate, lighter wrapper than the integration harness in pkg/testharness/. The contract harness boots one plugin in isolation against a real engine.Bus plus a minimal PluginContext — temp data dirs, default host sandbox, optional session workspace. No engine Boot, no other plugins, no full session.
This guide is for unit-level contract assertions. For end-to-end agent-loop tests with multiple plugins active, see Integration Testing instead.
When to use it
- Every new plugin should land with a
contract_test.go(orplugin_test.go) that asserts its declaredSubscriptions()andEmissions(). - Use it for tests that drive the plugin’s handlers via scripted bus events and assert which events come back out.
- Don’t use it for tests of internal helpers that don’t touch the bus — those live in regular
_test.gofiles.
Quick start
package mygate
import (
"testing"
"github.com/frankbardon/nexus/pkg/events"
"github.com/frankbardon/nexus/pkg/testharness/contract"
)
func TestContract(t *testing.T) {
h := contract.NewContract(t, New)
// 1. Static contract — declared sub/emit set.
h.AssertSubscribesTo("before:io.output")
if got := h.Plugin().Emissions(); len(got) != 1 || got[0] != "io.output" {
t.Errorf("Emissions() = %v, want [io.output]", got)
}
// 2. Behavioral contract — drive the handler and assert.
h.InjectVetoable("before:io.output", &events.AgentOutput{
Role: "assistant",
Content: "this output is too long for the configured limit",
})
h.AssertEmitted("io.output") // gate emits its system warning
h.AssertNoUndeclaredEmissions() // nothing outside the declared set
}
Cleanup is registered with t.Cleanup automatically. The harness drains the bus and calls Shutdown for you.
API
func NewContract(t *testing.T, factory engine.PluginFactory, opts ...ContractOption) *ContractHarness
Constructs the harness, calls Init and Ready on the plugin, registers cleanup. Fails the test on any error from those steps.
Options
| Option | Effect |
|---|---|
WithPluginConfig(map[string]any) | YAML-derived config map the plugin would normally receive. |
WithPluginID(string) | Override the plugin ID (use for instance-suffixed IDs like nexus.agent.subagent/researcher). Defaults to plugin.ID(). |
WithSession() | Boot with a real SessionWorkspace rooted in a temp dir. Enables plugins that touch ctx.Session, ctx.DataDir, or ScopeSession storage. Off by default to keep tests fast. |
WithLogger(*slog.Logger) | Override the default discard logger. |
Driving events
| Method | Purpose |
|---|---|
Inject(eventType, payload) | Emit a normal event on the harness bus. The harness tags it as OriginInject so it’s filtered out of plugin-emission checks. |
InjectVetoable(eventType, payload) VetoResult | Emit a before:* event and return the resulting VetoResult. The vetoable wrapper protocol is handled for you. |
Assertions
| Method | Asserts |
|---|---|
AssertSubscribesTo(types ...string) | Plugin’s static Subscriptions() declaration includes every type. Doesn’t run the plugin — pair with Inject to verify the subscription actually fires. |
AssertEmitted(eventType) | At least one plugin-origin event of this type was captured. |
AssertNotEmitted(eventType) | No plugin-origin event of this type was captured. |
AssertEmittedInOrder(types ...string) | Types appeared in the captured stream in the given relative order. Other emissions between them are ignored. |
AssertNoUndeclaredEmissions() | Every plugin-origin event the harness saw is in the plugin’s declared Emissions() list. Use after Inject to catch contract drift. |
Captured() returns every event observed (including injects); PluginEmissions() filters down to plugin-origin only.
Patterns
Plugin that mutates a request payload (no emissions)
Many plugins (embeddings adapters, rerankers, metadata router) subscribe to a request event and mutate the payload pointer in place rather than emitting a result. Their Emissions() is empty by design.
func TestContract(t *testing.T) {
h := contract.NewContract(t, New)
h.AssertSubscribesTo("embeddings.request")
req := &events.EmbeddingsRequest{Texts: []string{"foo"}}
h.Inject("embeddings.request", req)
if req.Provider != "nexus.embeddings.mock" {
t.Errorf("provider not stamped: %q", req.Provider)
}
if got := h.Plugin().Emissions(); len(got) != 0 {
t.Errorf("expected empty Emissions(), got %v", got)
}
}
Plugin that needs a session workspace
Plugins that persist files (longterm memory, planners, fileio) require ctx.Session to be non-nil. Pass WithSession():
h := contract.NewContract(t, New,
contract.WithSession(),
contract.WithPluginConfig(map[string]any{
"scope": "global",
"auto_load": false,
}),
)
The session workspace is rooted in t.TempDir() and cleaned up automatically.
Plugin that needs an API key in config
Any plugin whose Init hard-rejects on missing credentials needs a stub key in test config:
h := contract.NewContract(t, New, contract.WithPluginConfig(map[string]any{
"api_key": "sk-mock-not-used",
}))
The harness never makes outbound HTTP calls during contract tests — the key just satisfies validation.
Plugin that fails Init for negative-path tests
NewContract calls t.Fatalf on Init errors. To assert that Init correctly rejects bad config, bypass the harness and call Init directly:
func TestContract_NoSteps_InitFails(t *testing.T) {
p := New().(*Plugin)
err := p.Init(engine.PluginContext{
Bus: engine.NewEventBus(),
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
Config: map[string]any{}, // no required steps
})
if err == nil {
t.Error("expected Init to fail when no steps configured")
}
}
Contract harness vs integration harness
| Aspect | contract harness | testharness (integration) |
|---|---|---|
| Scope | One plugin in isolation | Full engine boot |
| Build tag | None — runs in normal go test | //go:build integration |
| Plugins active | Just the one under test | Whatever the YAML config lists |
| Session | Optional via WithSession() | Always present |
| Mock LLM | Not applicable (plugin owns its bus) | Configured via mock_responses in YAML |
| Use for | Subscriptions/Emissions assertions, request-payload mutations | Multi-plugin agent loops, full event chains |
Where it lives
The harness deliberately lives in pkg/testharness/contract/, not in pkg/testharness/ itself. The integration harness imports pkg/engine/allplugins (which imports every plugin); putting contract code in the same package would create a cycle whenever a plugin imports the harness:
plugin → pkg/testharness → pkg/engine/allplugins → plugin
Splitting into a sub-package breaks the cycle.
Reference
- Source:
pkg/testharness/contract/contract.go - Self-tests:
pkg/testharness/contract/contract_test.go - Examples: every
contract_test.goandplugin_test.gounderplugins/.
Session Broker
The session broker (cmd/nexus-broker) is a standalone service that fronts
many OS-isolated nexus instances behind a single HTTP/WebSocket ingress.
Callers claim an instance, talk to it over a WebSocket, and release it when
done. Each instance is a separate nexus process, so tenant isolation is
process isolation.
It is a protocol-aware gateway, not a blind TCP proxy: it decodes every frame and routes by lease and signal, which is how it tracks readiness, idleness, and crashes.
The broker is not an engine plugin. It lives under
cmd/nexus-brokerand is built separately fromcmd/nexus. The only plugin involved isnexus.io.broker, which runs inside each spawned instance and dials back to the broker.
How it works
┌───────────────────────────────────────┐
│ nexus-broker │
client ──HTTP POST────▶│ /claim /release/{id} /leases │
◀──lease+ws_url─│ │
──WebSocket────▶│ /lease/{id} ◀──frames──▶ /instance │
└───────────────────────────────────────┘
│ exec() with env ▲
▼ │ dials back
┌───────────────────────────────────────┐ │
│ nexus instance (own process) │─┘
│ nexus.io.broker plugin │
└───────────────────────────────────────┘
- A caller
POST /claims with a full nexus config. - The broker acquires a capacity slot, mints a lease, writes the config to a
temp file, and cold-spawns a
nexussubprocess (nexus_binary_path), injecting the broker address and lease id as environment variables. - The instance’s
nexus.io.brokerplugin dials back to the broker’s/instanceendpoint, registers its lease, and signals ready. The broker is the only listening socket. POST /claimreturns the lease id and aws_url. The caller opens that WebSocket and IO frames flow client ↔ broker ↔ instance.- The instance is released on demand (
POST /release), on idle (idle_timeout), or on crash. The session persists on disk and is resumable.
Running the broker
The broker reads its own YAML config file (default broker.yaml, override with
-config <path>):
# broker.yaml
listen_addr: ":8080" # HTTP/WS gateway bind address
nexus_binary_path: "nexus" # path to the nexus binary the broker exec()s
max_concurrent: 8 # max live instances; <=0 = unlimited
idle_timeout: 5m # release a lease after this much inactivity; <=0 disables
queue_wait_timeout: 30s # how long an over-cap claim waits in the FIFO queue; <=0 = no waiting
release_grace: 10s # graceful-shutdown grace before force-kill
# build both binaries
go build -o bin/nexus ./cmd/nexus
go build -o bin/nexus-broker ./cmd/nexus-broker
# run the broker
bin/nexus-broker -config broker.yaml
Every config key, its type, and its default are listed in the authoritative Configuration Reference.
Health check
curl -s http://localhost:8080/healthz
# {"status":"ok"}
HTTP API
All control-plane calls are plain HTTP/JSON. There is no authentication in v1 (see caveats).
POST /claim — claim an instance
Body:
{
"config": "engine:\n name: example\n", // required: full nexus config (YAML text)
"session_id": "prior-session-id" // optional: resume a persisted session
}
Success (200):
{
"lease_id": "…", // handle for this instance
"ws_url": "ws://host:port/lease/<lease>", // client WebSocket endpoint
"session_id": "…" // engine session id (see new-vs-resume below)
}
curl -s -X POST http://localhost:8080/claim \
-H 'Content-Type: application/json' \
-d '{"config":"engine:\n name: example\n"}'
Error responses:
| Condition | Status | Body |
|---|---|---|
Missing/empty config | 400 | {"error":"claim requires a non-empty config"} |
| Over capacity, queue wait elapsed | 503 | {"error":"capacity wait timed out"} |
At capacity, queueing disabled (queue_wait_timeout <= 0) | 503 | {"error":"no capacity"} |
| Instance exited before ready (e.g. resume of a missing/invalid session) | 502 | {"error":"instance exited before signalling ready"} |
| Instance did not become ready within the boot window | 504 | {"error":"instance did not become ready in time"} |
New vs. resume
- New session — omit
session_id. The engine generates a fresh session id and the instance reports it back; the broker returns it in the response. Capture that id if you want to resume the session later. - Resume — set
session_idto a previously returned id. The broker spawns the instance with-recall <id>, so the engine reloads that session from~/.nexus/sessions/<id>/and replays its history. The response echoes the requested id.
Resuming a session id that does not exist on disk makes the engine fail to boot;
the instance never signals ready and the claim returns 502 rather than
silently starting a new session.
POST /release/{lease_id} — release an instance
Gracefully tears a live instance down: the broker sends a shutdown frame, the
instance’s nexus.io.broker plugin emits io.session.end, and the engine
performs a clean Stop that flushes and persists the session before exit.
The broker waits up to release_grace and force-kills the process if that
window elapses (orphan prevention). The session directory under
~/.nexus/sessions/<id>/ is left intact and remains resumable via -recall.
curl -s -X POST http://localhost:8080/release/lease-abc123
# {"status":"released","lease_id":"lease-abc123"}
| Outcome | Status | Body |
|---|---|---|
| Released (graceful or killed) | 200 | {"status":"released","lease_id":"…"} |
| Unknown / already-released lease | 404 | {"error":"unknown lease"} |
| Missing lease id in path | 400 | {"error":"release requires a lease id"} |
Release is idempotent: releasing an already-gone lease returns 404 rather
than erroring, and concurrent releases of the same lease collapse to one
teardown.
GET /leases — list live instances
A read-only introspection surface. Returns the capacity/queue aggregates plus
every live lease, sorted by created_at then lease_id.
curl -s http://localhost:8080/leases
{
"max_concurrent": 8, // configured cap (0 = unlimited)
"slots_in_use": 2, // live instances currently holding a slot
"queue_depth": 0, // claims parked in the FIFO capacity wait queue
"leases": [
{
"lease_id": "lease-abc123",
"session_id": "…",
"pid": 41234,
"state": "active", // "spawning" | "active" | "draining"
"reason": "", // teardown reason once draining (e.g. "manual release", "idle")
"last_activity": "2026-06-25T12:00:00Z",
"created_at": "2026-06-25T11:59:30Z"
}
]
}
Lease states:
| State | Meaning |
|---|---|
spawning | The lease exists but its instance has not yet dialed back and registered — the claim is still booting an engine. |
active | The instance has registered; frames can flow. |
draining | A teardown (manual release, idle, or crash) has latched; the lease is on its way out. |
Connecting over WebSocket
After a successful claim, open the returned ws_url and exchange IO frames.
A minimal sketch:
const { lease_id, ws_url } = await (await fetch("http://localhost:8080/claim", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ config: "engine:\n name: example\n" }),
})).json();
const ws = new WebSocket(ws_url);
ws.onmessage = (e) => console.log("frame:", e.data);
ws.onopen = () => {
// send a user input message into the instance
ws.send(JSON.stringify({ type: "input", content: "hello" }));
};
// later: release the instance (the session persists on disk)
await fetch(`http://localhost:8080/release/${lease_id}`, { method: "POST" });
The IO message shapes carried inside broker frames (output, stream.delta,
input, approval.response, …) are documented on the
nexus.io.broker plugin page.
Capacity and queueing
max_concurrent caps live instances. Each claim acquires a slot before
spawning, so the live count can never exceed the cap. When the cap is full a
claim does not fail immediately — it parks in a FIFO wait queue bounded by
queue_wait_timeout. The moment a slot frees (release, idle, or crash) it is
handed to the oldest waiter. Set queue_wait_timeout to 0 to disable waiting
(at-capacity claims are rejected immediately with 503 no capacity); set
max_concurrent to 0 for unlimited instances.
Idle reaping
If an instance receives no real client input for idle_timeout, the broker
releases it through the same teardown path as POST /release (so the session is
persisted). Only inbound io input frames (client → instance) reset the idle
timer — output, pings, and control frames do not. Set idle_timeout to 0 to
disable idle reaping.
v1 caveats
The session broker is a v1. Understand these boundaries before deploying it:
- No auth / no access control. The broker trusts every caller. Tenant isolation is process isolation, not access control — any client that can reach the broker can claim, connect to, and release any instance. Front it with your own authenticating reverse proxy; treat the broker’s listen address as a trusted-caller boundary only.
- Single broker, single host. No clustering or HA. A broker restart
orphans running instances and loses all lease tracking — orphaned
nexusprocesses must be cleaned up manually. - Cold-spawn per claim. There is no pre-warm pool, so each claim pays full engine boot latency before the instance signals ready.
- No OS-level per-tenant sandboxing. Instances are separate processes but are not otherwise sandboxed from each other or the host beyond what the OS user provides.
See also
nexus.io.brokerplugin — the dial-back transport inside each instance.- Configuration Reference — authoritative broker + plugin config keys.
- Sessions — on-disk session layout and
-recall.
Creating a Custom Plugin
This guide walks through creating a new Nexus plugin from scratch.
Plugin Template
Create a new package under plugins/:
plugins/
mycat/
mycat.go # Main plugin file
mycat_test.go # Tests
Minimal Plugin
package mycat
import (
"context"
"log/slog"
"github.com/frankbardon/nexus/pkg/engine"
)
const pluginID = "nexus.mycat"
type Plugin struct {
bus engine.EventBus
logger *slog.Logger
}
func New() engine.Plugin {
return &Plugin{}
}
func (p *Plugin) ID() string { return pluginID }
func (p *Plugin) Name() string { return "My Category Plugin" }
func (p *Plugin) Version() string { return "0.1.0" }
func (p *Plugin) Dependencies() []string { return nil }
func (p *Plugin) Init(ctx engine.PluginContext) error {
p.bus = ctx.Bus
p.logger = ctx.Logger
// Read config from ctx.Config
// Set up subscriptions
return nil
}
func (p *Plugin) Ready() error { return nil }
func (p *Plugin) Shutdown(ctx context.Context) error { return nil }
func (p *Plugin) Subscriptions() []engine.EventSubscription {
return []engine.EventSubscription{
{EventType: "io.input", Priority: 50},
}
}
func (p *Plugin) Emissions() []string {
return []string{"io.output"}
}
Reading Configuration
Plugin config comes as map[string]any from the YAML:
func (p *Plugin) Init(ctx engine.PluginContext) error {
// Read a string config value
if v, ok := ctx.Config["my_setting"].(string); ok {
p.mySetting = v
}
// Read an int (YAML numbers may come as float64)
if v, ok := ctx.Config["max_items"].(int); ok {
p.maxItems = v
} else if v, ok := ctx.Config["max_items"].(float64); ok {
p.maxItems = int(v)
}
// Read a bool with default
p.enabled = true
if v, ok := ctx.Config["enabled"].(bool); ok {
p.enabled = v
}
return nil
}
Subscribing to Events
Via Subscriptions() (preferred for static subscriptions)
func (p *Plugin) Subscriptions() []engine.EventSubscription {
return []engine.EventSubscription{
{EventType: "io.input", Priority: 50},
{EventType: "tool.result", Priority: 50},
}
}
The lifecycle manager will wire these up automatically and call the handler. You’ll need to implement event routing in your handler.
Via Bus.Subscribe() (for dynamic subscriptions)
func (p *Plugin) Init(ctx engine.PluginContext) error {
ctx.Bus.Subscribe("custom.event", p.handleCustomEvent,
engine.WithPriority(50),
engine.WithSource(pluginID),
)
return nil
}
func (p *Plugin) handleCustomEvent(event engine.Event[any]) {
// Handle the event
}
Emitting Events
// Simple emit
p.bus.Emit("my.event", MyPayload{
Field: "value",
})
// Vetoable emit (for before:* events)
result, err := p.bus.EmitVetoable("before:my.action", &engine.VetoResult{})
if result.Vetoed {
p.logger.Info("action vetoed", "reason", result.Reason)
return
}
Creating a Tool Plugin
Tool plugins register themselves and handle invocations:
func (p *Plugin) Init(ctx engine.PluginContext) error {
p.bus = ctx.Bus
p.logger = ctx.Logger
// Subscribe to tool invocations
ctx.Bus.Subscribe("tool.invoke", p.handleInvoke, engine.WithPriority(50))
return nil
}
func (p *Plugin) Ready() error {
// Register the tool
p.bus.Emit("tool.register", events.ToolDef{
Name: "my_tool",
Description: "Does something useful",
Parameters: `{"type":"object","properties":{"input":{"type":"string","description":"The input"}},"required":["input"]}`,
})
return nil
}
func (p *Plugin) handleInvoke(event engine.Event[any]) {
call, ok := event.Payload.(events.ToolCall)
if !ok || call.Name != "my_tool" {
return
}
input, _ := call.Arguments["input"].(string)
// Do something with input
result := processInput(input)
p.bus.Emit("tool.result", events.ToolResult{
ID: call.ID,
Name: call.Name,
Output: result,
TurnID: call.TurnID,
})
}
Using the Session Workspace
func (p *Plugin) Init(ctx engine.PluginContext) error {
// Get plugin-specific data directory
dataDir := ctx.DataDir // ~/.nexus/sessions/<id>/plugins/<plugin-id>/
// Or use session directly
ctx.Session.WriteFile("plugins/"+pluginID+"/state.json", data)
return nil
}
Using the Prompt Registry
func (p *Plugin) Init(ctx engine.PluginContext) error {
ctx.Prompts.Register("my-context", 50, func() string {
if p.hasContext {
return "## My Context\n" + p.contextData
}
return "" // Empty string = section omitted
})
return nil
}
Using the Model Registry
func (p *Plugin) Init(ctx engine.PluginContext) error {
// Resolve a model role
cfg, found := ctx.Models.Resolve("reasoning")
if found {
p.logger.Info("using model", "model", cfg.Model, "provider", cfg.Provider)
}
return nil
}
Registering the Plugin
Add your plugin to cmd/nexus/main.go:
import "github.com/frankbardon/nexus/plugins/mycat"
// In main():
eng.Registry.Register("nexus.mycat", mycat.New)
Then activate it in your config:
plugins:
active:
- nexus.mycat
nexus.mycat:
my_setting: "value"
max_items: 10
Testing
Use the standard Go testing framework. You can create a test EventBus for unit tests:
func TestPlugin(t *testing.T) {
bus := engine.NewEventBus()
p := New()
err := p.Init(engine.PluginContext{
Config: map[string]any{"my_setting": "test"},
Bus: bus,
Logger: slog.Default(),
})
if err != nil {
t.Fatal(err)
}
}
Conventions
- Plugin ID:
nexus.<category>.<name>(e.g.,nexus.tool.mytool) - Logging: Use the provided
slog.Logger, notfmt.Println - Error wrapping: Use
fmt.Errorf("context: %w", err) - No direct plugin-to-plugin calls: Always communicate through events
- Declare all emissions: List every event type in
Emissions()