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 — noio,os, norequire— 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/extensionsand<cwd>/.agents/extensions. These load only afterdex extensions enable <id>, the trust consent. A skipped project extension prints the exact enabling command. - User scope —
$XDG_CONFIG_HOME/dex/extensions, configextensions.paths:, and--extensions-dirflags (plusDEX_EXTENSIONS_PATHS, which wins over the file). These load unlessdex 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:
| API | What 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.afterturn.start/turn.endagent.start/agent.endbefore_agent_startmodel_selectsession.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:
| Event | Envelope & 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).