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

When files are written to the session, events are emitted automatically:

EventWhen
session.file.createdA new file is written
session.file.updatedAn existing file is overwritten

These events carry the file path, session ID, and file size. The TUI plugin subscribes to these to show file creation notifications.

Session Lifecycle

Creating a Session

When Engine.Run() starts, it calls NewSessionWorkspace() which:

  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

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.

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

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.

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

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

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

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.

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.

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

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

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.

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

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

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 — this MUST be the first frame so the gateway can bind the socket to the lease.
  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.

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

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 and force-kills it if the graceful path overruns (release_grace).

Security

There is no auth in the plugin itself. The broker gateway owns lease validation and any transport-level authentication. The session broker ships with no auth in v1 — it is a trusted-caller boundary only. See the session broker guide for the full list of v1 limitations.

Example configuration

You rarely write this by hand — the broker injects both values as environment variables at spawn. When you do set them explicitly:

nexus.io.broker:
  broker_addr: "ws://127.0.0.1:8080/instance"
  lease_id: "lease-abc123"

Omit both keys (or leave the env vars unset) and the plugin stays dormant, so a config that activates the plugin outside a broker still boots without error.

See also

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>.
  • 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.
bearer_token_envstring(empty)Env var name to read the bearer token from (used only when bearer_token is empty).
cors_originslist<string>(empty)Allowed CORS origins. * echoes any Origin; a list echoes only matches; empty means same-origin only. Also accepts a comma-separated string.
emit_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.

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"

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.

Sampling

MCP sampling (server-asks-host-to-call-an-LLM) is deferred to Phase 2. Tracked in issue #98.

Testing

The integration tests in tests/integration/mcp_client_test.go build the fake MCP server at tests/integration/mcp_fake/ and exercise the plugin end-to-end over stdio. Run with:

go test -tags integration ./tests/integration/ -run TestMCPClient -v

No LLM provider key is required — the tests drive the bus directly and observe the plugin’s catalog, resource, and prompt projections.

Event Types Reference

Complete reference for all event types in Nexus, organized by domain.

Core Events

Event 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.createdSessionFileNew file in session
session.file.updatedSessionFileFile updated in session

SessionFile

FieldTypeDescription
PathstringFile path within session
Actionstring"created" or "updated"
SizeintFile size in bytes

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 top-level keys (core, engine, capabilities, plugins.active, journal) are always validated against the engine schema.

Schema authoring

Plugins expose their schema by implementing the optional engine.ConfigSchemaProvider interface (defined in pkg/engine/config_validation.go). Conventions:

  • The schema lives at plugins/<id>/schema.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.
modelsmap(empty)Model role registry — see core.models below.

core.models

Maps role names → model configurations. Roles can be:

  • single model — map with provider, model, max_tokens,
  • fallback chain — list of single-model maps (tried in order on non-retryable error or exhausted retries; coordinated by nexus.provider.fallback),
  • fanout role — map with fanout: 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.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.


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.
bearer_token_envstring(empty)Name of an environment variable to read the bearer token from. Used only when bearer_token is empty.
cors_originslist(empty)Allowed CORS origins for browser clients. A single * echoes any request Origin; an explicit list echoes only matching origins. Empty means no CORS header (same-origin only), the safe default for a loopback listener. Also accepts a comma-separated string.
emit_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.

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.

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.

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

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(none)Optional alias map: short slash command → <server>.<prompt>. Aliases use the configured command_prefix chain; e.g. review: gh.review_pr makes /review rewrite to /mcp.gh.review_pr.

defaults

KeyTypeDefaultDescription
lifecyclestringengineWhen servers connect/disconnect. engine = connect on engine boot, disconnect on shutdown. session = connect on io.session.start, disconnect on io.session.end.
timeoutduration30sPer-RPC timeout used for tools/call, resources/read, prompts/get, etc.
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.
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_maxint50If a server returns more static resources than this, the static auto-registration is skipped and only the generic list_resources/read_resource tools are exposed.
resources.subscribe_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_-]*).
transportstringstdiostdio (subprocess via the SDK) or http (streamable HTTP).
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(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(none)HTTP headers attached to every request. ${VAR} references expand from the host environment.
lifecyclestringinherited from defaultsengine or session.
timeoutdurationinherited from defaultsOverrides defaults per server.
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.

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

Session broker (nexus-broker)

The nexus-broker binary (cmd/nexus-broker) is a standalone service, not an engine plugin. It reads its own YAML config file (default path broker.yaml, override with -config <path>) and fronts OS-isolated Nexus instances behind an HTTP/WebSocket gateway.

# broker.yaml
listen_addr: ":8080"
nexus_binary_path: "nexus"
max_concurrent: 8
idle_timeout: 5m
queue_wait_timeout: 30s
release_grace: 10s
KeyTypeDefaultDescription
listen_addrstring:8080host:port the broker’s HTTP/WS gateway binds to. GET /healthz returns {"status":"ok"}.
nexus_binary_pathstringnexusPath to the nexus binary the broker exec()s to spawn instances. Funneled through ExpandPath (supports ~).
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).
idle_timeoutduration5mHow long an instance may sit with no real client input before the broker releases it. “Activity” is only an inbound io frame flowing client → instance (user input); instance → client output, pings, and control frames do not reset the timer. The release reuses the POST /release teardown path (shutdown frame → release_grace → force-kill → reap), so the session is persisted and the client WS closes with the going-away status. A background sweeper polls at min(idle_timeout/4, 15s) (floored at 50ms). Set idle_timeout to 0 (or any non-positive value) to disable idle reaping entirely.
queue_wait_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).
release_graceduration10sHow long a release (manual POST /release, and later idle/crash teardown) waits for an instance to shut its engine down cleanly before the broker force-kills it. The graceful path always persists the session; the kill is the orphan-prevention backstop.

The spawned-instance side is configured by the nexus.io.broker plugin (broker_addr, lease_id) — see nexus.io.broker in the I/O section. Both keys fall back to the NEXUS_BROKER_ADDR / NEXUS_BROKER_LEASE_ID environment variables the broker injects at spawn (defined as brokerframe.EnvBrokerAddr / brokerframe.EnvLeaseID).

POST /claim (HTTP API, not YAML)

POST /claim mints a lease, spawns an instance with the supplied config, waits for it to dial back and signal ready, and returns the lease coordinates. The request is a small JSON envelope; session_id is optional.

// request body
{
  "config": "engine:\n  name: example\n",  // required: full nexus config (YAML text)
  "session_id": "prior-session-id"          // optional: resume a persisted session
}

When session_id is set the broker spawns the instance with -recall <id> so the engine reloads that session and replays its history; when omitted it starts a fresh session. An unknown/invalid session_id makes the engine fail to boot, so the instance never signals ready and the claim returns 502 (“instance exited before signalling ready”) rather than silently starting a new session.

// success response (200)
{
  "lease_id": "…",                          // lease handle for this instance
  "ws_url": "ws://host:port/lease/<lease>",  // client WebSocket endpoint
  "session_id": "…"                          // engine session id: the generated id for a
                                             // new session (capture it to -recall later),
                                             // or the requested id echoed back on resume
}

POST /release/{lease_id} (HTTP API, not YAML)

POST /release/{lease_id} tears a live instance down gracefully. The broker sends a shutdown frame to the instance, whose nexus.io.broker plugin emits io.session.end so the engine performs a clean Stop — flushing and persisting the session before exit. The broker then waits up to release_grace for the process to exit and force-kills it if that window elapses (no orphan). The lease is removed and its slot freed. The session directory under ~/.nexus/sessions/<id>/ is left intact and remains resumable via -recall.

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 a single teardown.

GET /leases (HTTP API, not YAML)

GET /leases is a read-only introspection surface: it reports the capacity and queue aggregates plus a snapshot of every live lease, sorted by created_at then lease_id. It performs no mutation.

// response (200)
{
  "max_concurrent": 8,     // configured cap (0 = unlimited)
  "slots_in_use": 2,       // live instances currently holding a slot
  "queue_depth": 0,        // claims parked in the FIFO capacity wait queue
  "leases": [
    {
      "lease_id": "…",
      "session_id": "…",                       // omitted until reported by the instance
      "pid": 41234,
      "state": "active",                        // "spawning" | "active" | "draining"
      "reason": "manual release",               // teardown reason once draining; omitted otherwise
      "last_activity": "2026-06-25T12:00:00Z",  // RFC3339
      "created_at": "2026-06-25T11:59:30Z"      // RFC3339
    }
  ]
}

Surface states: spawning (lease exists, instance not yet registered), active (registered, frames can flow), draining (a teardown has latched).

Full narrative, the new-vs-resume flow, a WebSocket connect sketch, and the v1 deployment caveats live in the Session Broker guide.


Cross-references

  • Plugin System — plugin lifecycle, Requires() 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/.

Session Broker

The session broker (cmd/nexus-broker) is a standalone service that fronts many OS-isolated nexus instances behind a single HTTP/WebSocket ingress. Callers claim an instance, talk to it over a WebSocket, and release it when done. Each instance is a separate nexus process, so tenant isolation is process isolation.

It is a protocol-aware gateway, not a blind TCP proxy: it decodes every frame and routes by lease and signal, which is how it tracks readiness, idleness, and crashes.

The broker is not an engine plugin. It lives under cmd/nexus-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}  /leases       │
          ◀──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.
  2. The broker acquires a capacity slot, mints a lease, writes the config to a temp file, and cold-spawns a nexus subprocess (nexus_binary_path), 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.

Running the broker

The broker reads its own YAML config file (default broker.yaml, override with -config <path>):

# broker.yaml
listen_addr: ":8080"          # HTTP/WS gateway bind address
nexus_binary_path: "nexus"    # path to the nexus binary the broker exec()s
max_concurrent: 8             # max live instances; <=0 = unlimited
idle_timeout: 5m              # release a lease after this much inactivity; <=0 disables
queue_wait_timeout: 30s       # how long an over-cap claim waits in the FIFO queue; <=0 = no waiting
release_grace: 10s            # graceful-shutdown grace before force-kill
# build both binaries
go build -o bin/nexus ./cmd/nexus
go build -o bin/nexus-broker ./cmd/nexus-broker

# run the broker
bin/nexus-broker -config broker.yaml

Every config key, its type, and its default are listed in the authoritative Configuration Reference.

Health check

curl -s http://localhost:8080/healthz
# {"status":"ok"}

HTTP API

All control-plane calls are plain HTTP/JSON. There is no authentication in v1 (see caveats).

POST /claim — claim an instance

Body:

{
  "config": "engine:\n  name: example\n",  // required: full nexus config (YAML text)
  "session_id": "prior-session-id"          // optional: resume a persisted session
}

Success (200):

{
  "lease_id": "…",                          // handle for this instance
  "ws_url": "ws://host:port/lease/<lease>",  // client WebSocket endpoint
  "session_id": "…"                          // engine session id (see new-vs-resume below)
}
curl -s -X POST http://localhost:8080/claim \
  -H 'Content-Type: application/json' \
  -d '{"config":"engine:\n  name: example\n"}'

Error responses:

ConditionStatusBody
Missing/empty config400{"error":"claim requires a non-empty config"}
Over capacity, queue wait elapsed503{"error":"capacity wait timed out"}
At capacity, queueing disabled (queue_wait_timeout <= 0)503{"error":"no capacity"}
Instance exited before ready (e.g. resume of a missing/invalid session)502{"error":"instance exited before signalling ready"}
Instance did not become ready within the boot window504{"error":"instance did not become ready in time"}

New vs. resume

  • New session — omit session_id. The engine generates a fresh session id and the instance reports it back; the broker returns it in the response. Capture that id if you want to resume the session later.
  • Resume — set session_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.

POST /release/{lease_id} — release an instance

Gracefully tears a live instance down: the broker sends a shutdown frame, the instance’s nexus.io.broker plugin emits io.session.end, and the engine performs a clean Stop that flushes and persists the session before exit. The broker waits up to release_grace and force-kills the process if that window elapses (orphan prevention). The session directory under ~/.nexus/sessions/<id>/ is left intact and remains resumable via -recall.

curl -s -X POST http://localhost:8080/release/lease-abc123
# {"status":"released","lease_id":"lease-abc123"}
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.

GET /leases — list live instances

A read-only introspection surface. Returns the capacity/queue aggregates plus every live lease, sorted by created_at then lease_id.

curl -s http://localhost:8080/leases
{
  "max_concurrent": 8,     // configured cap (0 = unlimited)
  "slots_in_use": 2,       // live instances currently holding a slot
  "queue_depth": 0,        // claims parked in the FIFO capacity wait queue
  "leases": [
    {
      "lease_id": "lease-abc123",
      "session_id": "…",
      "pid": 41234,
      "state": "active",           // "spawning" | "active" | "draining"
      "reason": "",                 // teardown reason once draining (e.g. "manual release", "idle")
      "last_activity": "2026-06-25T12:00:00Z",
      "created_at": "2026-06-25T11:59:30Z"
    }
  ]
}

Lease states:

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.

Connecting over WebSocket

After a successful claim, open the returned ws_url and exchange IO frames. A minimal sketch:

const { lease_id, ws_url } = await (await fetch("http://localhost:8080/claim", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ config: "engine:\n  name: example\n" }),
})).json();

const ws = new WebSocket(ws_url);
ws.onmessage = (e) => console.log("frame:", e.data);
ws.onopen = () => {
  // send a user input message into the instance
  ws.send(JSON.stringify({ type: "input", content: "hello" }));
};

// later: release the instance (the session persists on disk)
await fetch(`http://localhost:8080/release/${lease_id}`, { method: "POST" });

The IO message shapes carried inside broker frames (output, stream.delta, input, approval.response, …) are documented on the nexus.io.broker plugin page.

Capacity and queueing

max_concurrent caps live instances. Each claim acquires a slot before spawning, so the live count can never exceed the cap. When the cap is full a claim does not fail immediately — it parks in a FIFO wait queue bounded by queue_wait_timeout. The moment a slot frees (release, idle, or crash) it is handed to the oldest waiter. Set queue_wait_timeout to 0 to disable waiting (at-capacity claims are rejected immediately with 503 no capacity); set max_concurrent to 0 for unlimited instances.

Idle reaping

If an instance receives no real client input for idle_timeout, the broker releases it through the same teardown path as POST /release (so the session is persisted). Only inbound io input frames (client → instance) reset the idle timer — output, pings, and control frames do not. Set idle_timeout to 0 to disable idle reaping.

v1 caveats

The session broker is a v1. Understand these boundaries before deploying it:

  • No auth / no access control. The broker trusts every caller. Tenant isolation is process isolation, not access control — any client that can reach the broker can claim, connect to, and release any instance. Front it with your own authenticating reverse proxy; treat the broker’s listen address as a trusted-caller boundary only.
  • Single broker, single host. No clustering or HA. A broker restart orphans running instances and loses all lease tracking — orphaned nexus processes must be cleaned up manually.
  • Cold-spawn per claim. There is no pre-warm pool, so each claim pays full engine boot latency before the instance signals ready.
  • No OS-level per-tenant sandboxing. Instances are separate processes but are not otherwise sandboxed from each other or the host beyond what the OS user provides.

See also

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