Driving the Agent SDK: permissions, hooks and caps
A stage is one query() call. The five options that define what an autonomous agent is allowed to do, the four hooks that make an unattended run watchable, and an honest account of what bypassPermissions actually means.
Everything Dex does eventually reduces to one
function call. A stage — specify, plan, implement, verify — is a single query()
against the Claude Agent SDK, and the orchestrator’s entire relationship with the
model is about five options and four hooks.
Those options are not configuration. Each one is a design decision about what an agent running for eight hours without supervision is allowed to be, and it is worth going through them individually, including the one that should make you uncomfortable.
for await (const msg of query({
prompt,
options: {
model: effectiveModel,
cwd: effectiveCwd,
maxTurns: config.maxTurns,
permissionMode: "bypassPermissions",
settingSources: ["project"],
abortController: abortController ?? undefined,
...(outputFormat ? { outputFormat } : {}),
...(profile?.allowedTools ? { allowedTools: profile.allowedTools } : {}),
canUseTool: async (toolName, toolInput) => { /* … */ },
hooks: { /* … */ },
},
})) { /* … */ }
permissionMode: "bypassPermissions"
Start with the alarming one. Every tool call this agent makes is approved automatically. It edits, it writes, it runs shell commands, and nobody is asked.
There is no way to build an unattended loop without something equivalent — a prompt-for-approval mode is a contradiction with “walk away”, not a configuration of it. So the honest framing is not “is this safe” but what is carrying the safety instead, and the answer has to be structural, because it is certainly not supervision.
Four things carry it:
| Control | What it bounds |
|---|---|
A dedicated dex/<date>-<id> branch |
Blast radius — main is never the working branch |
| A commit after every completed stage | Recoverability — any stage is a ref you can return to |
maxTurns (default 200) and maxBudgetUsd |
Runaway — the loop stops when either ceiling is hit |
| A verify stage with fixed acceptance criteria | Correctness — “done” was defined before the code was written |
Note what that list does not claim. It does not claim the agent cannot do
something destructive; it can, and Bash is in its toolbox. It claims that the
damage is confined to a branch, that every intermediate state is recoverable, and
that it cannot run forever or spend without limit. That is the actual security
posture, and anyone running this on a repository they care about should decide
whether that is enough for them rather than taking the checkbox as reassurance.
settingSources: ["project"]
The single option I would most want other people to copy.
It makes the SDK load the target project’s own configuration — its CLAUDE.md,
its rules, its skills. The consequence: the project governs the agent, not the
tool. A Rust repository with strict clippy conventions gets an agent that has
read those conventions; a repo with a house style for tests gets an agent that
follows it. Dex ships no opinion about how your code should look and has no
mechanism to impose one.
This also happens to be the mechanism by which Ralph’s self-improvement idea lands. The learnings a run accumulates are written to the project, so subsequent stages read them as part of the project’s own instructions rather than through anything Dex has to remember or re-inject.
abortController, and why Stop is a promise
Stop is documented as pause, not kill. The AbortController is the SDK half of
that: it interrupts an in-flight stage rather than waiting for it to finish.
The orchestrator half is harder and took a dedicated spec. Aborting mid-stage means the run must record which stage was last completed, so that resuming re-enters the middle of a cycle rather than restarting it. Get that wrong and the spec directory a stage just wrote gets orphaned — never planned against, never implemented, a folder you paid four dollars for and can never use.
A pause button that loses work is worse than having no pause button. No pause button is a limitation. A pause button that eats a stage is a trap — it teaches people that the safe-looking control is the dangerous one, and after that they stop trusting the parts that work.
canUseTool, or: letting the agent ask
This one surprised me by how much it changed the product.
canUseTool is an interception point for individual tool calls. Dex uses it for
exactly one thing — catching AskUserQuestion and routing it into the desktop
UI:
canUseTool: async (toolName: string, toolInput: Record<string, unknown>) => {
if (toolName === "AskUserQuestion") {
rlog.agentRun("INFO", "canUseTool: AskUserQuestion intercepted");
// Parse SDK question format into our typed format
const rawQuestions = (toolInput.questions ?? []) as Array<Record<string, unknown>>;
// …surfaced as a real dialog; the agent blocks until answered
So “autonomous” is not quite the binary I had been treating it as. The agent can decide it needs a human, and instead of guessing or failing, it escalates — a question appears in the app and the stage waits. During clarification, before any code is written, this is the entire interaction model.
The design point generalises: an autonomous system needs a way to be uncertain. If the only options are proceed-confidently and fail, it will proceed confidently, because that is what models do. A channel for “I need a decision” converts a category of silent wrong-building into a category of waiting, and waiting is recoverable.
Hooks: making eight unattended hours watchable
Four hooks, and between them they produce everything the trace UI shows:
| Hook | Emits |
|---|---|
PreToolUse |
A tool-call step — or a skill_invoke step when the tool is Skill |
PostToolUse |
The matching result step, or skill_result |
SubagentStart |
A subagent-started event, recorded against the run |
SubagentStop |
Subagent completion, and a subagent_result step |
Skills get their own step types rather than appearing as a generic tool call,
because a spec-kit skill invocation is a stage-level event — /speckit-plan
running is the most informative single line in the log, and burying it among
forty Read calls would waste it.
Subagent tracking matters for a related reason. A stage that spawns eight
subagents looks, without the hooks, like one agent that went quiet for ten
minutes. With them it is a tree, and MCP tool calls get attributed back to the
server that served them by parsing the mcp__<server>__<tool> naming convention
into a per-server map.
This is where I would push back on the instinct to treat observability as polish. For an unattended system it is the primary interface — the run itself is not interactive, so the trace is the only surface on which the product exists while it is working. Every hook above pays for itself the first time a run does something surprising at 2am.
Costs accumulate the same way and land in a file you can read without the app:
jq '.totalCostUsd, (.phases | length)' .dex/runs/<runId>.json
Agent profiles, and the runners that are not Claude
A profile is a folder — no registry, no database, no UI-owned config format:
<projectDir>/.dex/agents/<name>/
├── dex.json # agentRunner, model, systemPromptAppend, allowedTools
└── .claude/ # optional runner-native overlay: skills, subagents, MCP servers, CLAUDE.md
export type AgentRunnerKind = "claude-sdk" | "codex" | "copilot";
export type AgentProfile = ClaudeProfile | CodexProfile | CopilotProfile;
Two things there deserve attention. First, the folder name is the profile name
and is not stored inside dex.json — the filesystem is the registry, which means
creating a profile is mkdir and there is no way for the name and the record to
disagree. Second, and more consequential: the runner is a property of the
profile. Dex models Claude, Codex and Copilot as peer backends. The query()
call above is the Claude implementation of an interface, not the architecture.
The optional runner-native overlay is the part I would defend hardest. A profile
can carry its own .claude/ directory — skills, subagents, MCP servers, a
CLAUDE.md — which gets overlaid at spawn time. So a profile is not just “which
model”; it is a whole working environment, expressed in the runner’s own native
config format rather than translated into a Dex-specific one.
What this costs
The SDK is a moving dependency and the adapter is the seam that absorbs it.
ClaudeAgentRunner.ts is one of only two files permanently exempt from the
project’s 600-line limit, with
the comment perpetual — TBD SDK-adapter spec. That is an honest admission that
the file is large because it is doing translation work nobody has yet designed
properly, and every SDK version bump lands there.
Hooks are a firehose. Every tool call in an eight-hour run becomes an event, an IPC message, and a row in a trace the UI has to render without falling over. The observability that makes the system trustworthy is also, by volume, most of what the system produces.
Cost figures are estimates. estimateCost(model, inputTokens, outputTokens)
is arithmetic over a local price table, not a billing API. It is the right number
for “should this loop stop”, and it is not the number on your invoice.
Profile overlays are a second configuration system. settingSources: ["project"] says the project governs the agent; a profile overlay says the
profile can override that. Both are useful and they are in tension, and the
resolution rules live in code rather than anywhere a user would look.
And multi-runner is a claim with one mature implementation. Codex and Copilot are modelled in the type system and in the profile format. Claude is the one that has actually built things unattended. A union type is a design commitment, not a support matrix, and I would rather say so than let the enum imply parity.
Next: I built a time machine, then deleted half of it — four timeline verbs, parallel variants in git worktrees, a record mode, and the specs that removed almost all of it.