docs

Composability

Most agent harnesses hard-code the decisions that make an agent an agent: which tools the model sees, when to compact, when to ask for approval, how a turn loops. In dex, those decisions are slots on a harness graph — each with a Rust default — and you can select, wrap, or replace any of them at runtime, from config or Lua, without recompiling dex.

The kernel stays fixed: workspace confinement, credential handling, sessions, cancellation. The harness — the components that make per-turn decisions — is the composable part.

The slots

Every turn resolves one immutable harness snapshot; the turn loop asks the snapshot and never reads global mutable state, so a turn can never see a half-applied composition.

SlotDecidesDefault
catalogWhich tool schemas the model sees (native + MCP + extensions)composed catalog
triggerWhether a turn needs compactionconfig budget
summarizerThe compaction summarydeterministic checkpoint
overflowContext-overflow detectionthreshold detector
conflictWhether a tool batch has conflicting callsconflict detector
scorerWhich stale tool outputs to pruneheuristic scorer
approvalTool approval policybuilt-in permission policy
executorHow tool calls executeworkspace-confined dispatcher
transcriptWhere transcript lines gosession store
usageToken/cost accountingtoken ledger
eventsEvent sinktranscript events
recoveryError recovery policybuilt-in recovery

Plus one Lua-only slot, agent_loop, which owns the whole turn loop.

Select, then inspect

The floor is config — pick a named implementation per slot:

harness:
  slots:
    catalog: native-only   # native tools only — no MCP/extension tail
    trigger: never         # never auto-compact

Whatever you select, dex runtime graph prints every slot's resolved id and origin — the same resolver the turn loop calls, so the graph cannot drift from what a turn actually runs:

dex runtime graph
slots:
  agent_loop   run_turn                  rust, builtin
  catalog      native-only               config harness.slots
  trigger      never                     profile 'lean'
  summarizer   default                   built-in default
  ...

A bad selection never fails a turn: unknown slots or ids warn once and keep the default.

Implement slots in Lua

A harness-capability extension can implement slots as Lua components. Here a router decides which model each turn serves:

# manifest.yaml
dex: ">=0.15"
capabilities: [harness]
components:
  model_selector: router.lua

# router.lua — pick the model one turn serves
return function(dex)
  dex.use("model_selector", {
    id = "cost.router",
    interface = "model_selector.v1",
    run = function(input)
      return { model = "opencode/grok-code" }
    end,
  })
end

dex.use selects, dex.wrap adds middleware around an existing implementation, dex.fallback installs a backup. Interfaces are versioned (model_selector.v1) and checked at load — a component written against a different envelope fails loudly instead of misreading payloads.

The ladder goes to the top

Depth is a spectrum, and the deepest rung replaces the turn loop itself:

  1. Config — harness.slots: selects named implementations.
  2. Profiles — a named slot map (harness_profiles:) applied transactionally; a Lua extension can switch them at runtime with dex.activate_profile(name). A profile never half-applies.
  3. Components — dex.use/dex.wrap/dex.fallback per slot.
  4. The loop — dex.replace("agent_loop", …):
dex.replace("agent_loop", { id = "my.loop", interface = "agent_loop.v1",
  run = function(ctx)
    while true do
      local r = ctx.model.call()
      if r.tool_calls and #r.tool_calls > 0 then
        ctx.tools.execute()
      else
        local s = ctx.finish(r.content or "")
        if not s.steered then return r.content or "" end
      end
    end
  end })

Your Lua owns iteration; Rust keeps every invariant — persistence, streaming, cancellation, budgets, the history ledger. The step surface (ctx.model.call(), ctx.tools.execute(), ctx.finish()) is the whole API, and the default loop is the same six lines.

What you can't break

All host operations pass through Rust primitives; Lua policy sits above the runtime policy, and the kernel below both is authoritative:

Lua policy → Runtime policy → Kernel enforcement
  • Capabilities are enforced at registration — a plain tools extension can never see or decide an approval.
  • Hooks may deny or escalate, never clear a host denial.
  • A broken component fails open to the Rust default; it never crashes the process.

Everything else you'd expect from a composable harness is here too — skills, MCP tools, extensions, and a pipe-friendly one-shot CLI all enter through the same schema and transcript. But that's the surface: the harness graph underneath is what makes "change anything" literal. The full reference lives in extensions and the repository's docs/runtime.md.