docs

Lua extensions

Lua extensions are harness extensions that run in a sandboxed VM inside the daemon. Drop a directory containing manifest.yaml + extension.lua into a discovery directory and it can register tools, shadow built-ins, register slash commands, subscribe to lifecycle hooks, and contribute to the system prompt.

Extension code runs in a stripped Lua VM — no io, os, no require — and every effect flows through the same permission gates as a model-issued call. A failed extension is skipped whole: its tools never enter the schema.

Discovery & consent

Extension directories are walked in this order:

  • Project scope — <cwd>/.dex/extensions and <cwd>/.agents/extensions. These load only after dex extensions enable <id>, the trust consent. A skipped project extension prints the exact enabling command.
  • User scope — $XDG_CONFIG_HOME/dex/extensions, config extensions.paths:, and --extensions-dir flags (plus DEX_EXTENSIONS_PATHS, which wins over the file). These load unless dex extensions disable <id>.

Manage them from the CLI:

dex extensions list              # discovery walk + consent state + loaded summary
dex extensions install <dir>     # copy into the user dir
dex extensions remove <id>
dex extensions enable <id>       # consent marker (project scope)
dex extensions disable <id>

In the TUI, /extensions shows what is loaded and /extensions reload rescans. dex doctor lists every discovered extension with its consent state.

The manifest

manifest.yaml is parsed and validated, never executed. It declares identity, capabilities (the engine withholds dex.* subtrees that are not declared), and tool schemas. Unknown capabilities are rejected at load — a typo fails loudly instead of silently granting nothing.

manifest_version: 1
id: my-tools          # [a-z0-9_-]+, max 64 chars
version: 0.1.0
capabilities:
  - tools             # register tools
  # - tools.override  # shadow a built-in tool
  # - workspace.read  # dex.workspace.read/exists
  # - model           # dex.model.current/auth (current model + credentials)
  # - net             # dex.net.fetch (needs model: confined to its endpoint)
strict: false         # true: a tool.before error denies the call (fail-closed)
tools:
  - name: lint_file
    description: Lint one file and return a short report.
    timeout: 30       # seconds; default 30, hard cap 120
    parameters:
      type: object
      properties:
        path: { type: string }
      required: [path]

Tool names become lua__<ext_id>__<tool> in the schema (dex.tools.call and dex run accept the short name; dispatch lives under the lua__ prefix, the same seam MCP tools use). Extension tools share the per-request schema budget with MCP, capped at DEX_MAX_EXTENSION_TOOLS (default 64). Per-tool timeout defaults to 30 s and caps at 120 s.

The dex.* API

The extension chunk must return function(dex) ... end; the host calls it once with the dex table:

APIWhat it does
dex.tools.register({name, execute, override?})Register a tool; override: true shadows a built-in (needs tools.override, plain built-in names only, not your own tools).
dex.tools.call(name, args)Full host-mediated tool invocation — cancellation, policy, and filters are inherited from the calling context. Depth is capped (default 4) against unbounded recursion.
dex.tools.call_original(ctx, args)Inside a shadow: re-dispatch the built-in the shadow replaced.
dex.tools.list()Extension tool names (the set_active domain).
dex.tools.set_active(list)Restrict the extension schema slice (short own-tool names or full lua__ names — the host resolves).
dex.model.current()Current model as {provider, model, id, api, base_url} (needs model). The served per-turn snapshot wins; file+env is the fallback.
dex.model.auth()Current model's key + endpoint + merged extra headers (needs model). Never logged — Lua memory only.
dex.net.fetch({url, method?, headers?, body?, timeout_ms?})One HTTP request confined to the model's own endpoint — scheme+host+port must match (needs net, which requires model). Non-2xx is a {status, headers, body} value; redirects are never followed (a 3xx surfaces as a value instead of escaping the endpoint check). timeout_ms defaults to 30 s, caps at 120 s.
dex.json.encode(value) / .decode(text)Table to JSON string and back — the fetch body/parse primitive. No capability gate.
dex.events.on(event, fn)Subscribe to a lifecycle event (unknown names fail legibly at load).
dex.commands.register({name, description, execute})Add a slash command (name is the slash word, [a-z0-9-]).
dex.prompt.append(text)Load-time text appended to the base system prompt.
dex.prompt.get_system_prompt()Read the composed prompt (no skills — those are session-scoped).
dex.state.get/set(key, value)Per-extension JSON key/value store, write-through to disk. Deletion is not supported.
dex.workspace.read(path) / .exists(path)Confined file reads (needs workspace.read).
dex.log.debug/info/warn/error(msg)Daemon log + journal, prefixed lua[<ext>].

Events & hooks

dex.events.on accepts these events:

  • tool.before / tool.after
  • turn.start / turn.end
  • agent.start / agent.end
  • before_agent_start
  • model_select
  • session.before_compact

Handlers have the signature function(ctx, ev): read the context from ctx, and read/rewrite the envelope ev in place (or return a directive table). The host reads the resulting envelope strictly:

EventEnvelope & directives
tool.before ev.tool and ev.args (mutable). Mutate ev.args in place to rewrite the call; return { deny = true, reason = "…" } to deny the call before any permission gate runs. A non-object args is an error.
tool.after ev.tool, ev.args, ev.content, ev.is_error (all mutable). Rewrite ev.content and/or set ev.is_error = true. A hook may escalate a result to an error, never clear one — a host-reported failure can't be rewritten into a success.
session.before_compact Return { cancel = true } to abort compaction, { instructions = "…" } to ride guidance onto the summary, or { summary = "…" } for the host to use verbatim instead of generating one.
turn.start / turn.end
(and the other lifecycle events)
Fire-and-forget: subscribers run in load order, failures are logged, directives are ignored. turn.end's envelope carries { ok, error? }.

Denials and rewrites are audited with the extension id that produced them. By default a hook error is skipped (fail-open); set strict: true in the manifest so an error denies instead — enforcement posture is install-time consent, not runtime surprise.

Model-aware extensions

A model-aware extension declares model (read the served model and reuse its credentials instead of configuring its own) and net (HTTP confined to that model's own endpoint). net requires model — confinement needs the endpoint — and unknown capabilities are rejected at load. The reference is examples/extensions/web in the dex repo: provider-native web search (Gemini / openai-responses / anthropic) plus URL fetch (Gemini only), reusing the current model's key. Copy it to $XDG_CONFIG_HOME/dex/extensions/web (or dex extensions install <dir>) to use it.

The host fires model_select on the first turn and whenever the served provider/model changes (fail-open, like other lifecycle events), so extensions can hide tools the model cannot serve via dex.tools.set_active — the served model is scoped to the turn, so concurrent sessions and nested subagent turns each see their own — the web example serves both tools on Gemini and search only elsewhere, and enforces the same gate at call time so a stale slice fails loudly. Budget the timeouts together: per-call timeout_ms must sit under the tool's manifest timeout: — provider-side search rounds run ~40 s non-streamed, so the reference uses timeout: 120 with timeout_ms = 100000. The host re-attaches the turn's routing-affinity headers under Lua-explicit ones, so calls to a gateway endpoint route like dex's own requests.

Example

-- extension.lua
return function(dex)
  dex.tools.register({
    name = "echo",
    description = "Echo a message back.",
    execute = function(ctx, args)
      return "echo: " .. tostring(args.msg)
    end,
  })

  dex.events.on("tool.before", function(ctx, ev)
    -- deny every bash call the model tries to make
    if ev.tool == "bash" then
      return { deny = true, reason = "bash is disabled by my-tools" }
    end
    -- pass-through needs no return: ev mutations are directives too
  end)

  dex.commands.register({
    name = "greet",
    description = "say hi",
    execute = function(ctx)
      return "hi from my-tools"
    end,
  })

  dex.prompt.append("Use the echo tool before saying anything final.")
end

Slash commands run fire-and-forget under a read-only policy — /name [args] dispatches the handler with the raw argument string, and the output lands in the daemon log, not the transcript. Denial and rewrite decisions from tool.before/tool.after are audited with the extension id that produced them.

Loading model

Like MCP servers, extensions are loaded once at daemon bootstrap with cached tool schemas — not per turn. /extensions reload in the TUI rescans discovery dirs. Extra extension dirs come from --extensions-dir (repeatable), config extensions.paths:, or DEX_EXTENSIONS_PATHS (env wins).