dax

package module
v0.0.14 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 11, 2026 License: MIT Imports: 58 Imported by: 0

README

dax

A coding agent for the terminal. It reads, searches, edits and writes files in your project and runs commands, asking you before it does anything it should not do alone, and records every session so you can list, resume, verify and export it. It runs against a local Ollama with no setup, OpenAI, Anthropic or Gemini with a key, or Claude and Gemini on Google Vertex AI with your gcloud login.

It is a thin product over a set of small Go libraries (openresponses, agentturn, agentkit, agentpolicy, agentsession and friends); docs/design.md says who owns what.

dax is a minimal core meant to be extended. The session (the model, the store, the policy, the workspace, AGENTS.md, MCP servers, compaction) knows no tool. Everything the model can do comes from an extension, and dax's own are extensions like any other: dax-coding (the file tools and bash), dax-agents (the sub-agents), dax-skills and dax-memory. A Go program runs dax with extensions of its own on the same terms, or leaves one of dax's out; see Building on dax.

Where the tools act is a value too. dax is meant to feel the same whether its tools act on this machine or elsewhere: every tool, and every check the policy makes of a call, goes through one workspace interface, and this machine's directory is one implementation of it; see Where the tools act.

Install

go install github.com/ChristopherDavenport/dax/cmd/dax@latest
# or, in a checkout
make install

Go 1.26 or later. With no flags dax talks to Ollama on localhost:11434 and runs qwen3.5:9b.

dax                          # REPL in the current directory; Ctrl-C aborts a run in flight
dax -p "what is in go.mod"   # one prompt, then exit
dax -provider anthropic      # a hosted model

Providers and keys

-provider default model key (environment or api_key_command) -base-url
ollama (default) qwen3.5:9b none default http://localhost:11434/v1; another Ollama host
openai gpt-5 OPENAI_API_KEY not supported
openrouter deepseek/deepseek-v4-pro-0813; sub-agents deepseek/deepseek-v4.1-flash OPENROUTER_API_KEY not supported
openresponses none; -model is required the variable -api-key-env names, or none required
anthropic claude-sonnet-5-5 ANTHROPIC_API_KEY not supported
gemini gemini-2.5-pro GEMINI_API_KEY not supported
vertex claude-sonnet-5-5 none; Google Application Default Credentials not supported

A provider is a vendor: openai is OpenAI, and openrouter is OpenRouter, whose models are named vendor/model. Any other server that speaks the Open Responses (OpenAI Responses) API, such as vLLM, LM Studio or a proxy, is openresponses, with its -base-url, a -model, and -api-key-env naming the variable its key is in, if it takes one:

LITELLM_KEY=... dax -provider openresponses -base-url https://llm.internal/v1 -model qwen3-coder -api-key-env LITELLM_KEY

-model names another model. -subagent-model names the one the sub-agents run; without it a sub-agent runs the provider's default for sub-agents, where it has one, else the main model. A missing key is an error before any request is made: dax: openai: no API key: set OPENAI_API_KEY in the environment. dax reads keys from the environment and nowhere else (not from a config file, not from a flag, so they stay out of ps and out of a repository), and never prints one. The variable a key was read from is removed from the environment of bash commands and MCP servers, whatever it is called, unless pass_env names it.

For a key that expires, such as a short-lived access token, set api_key_command in your config (or -api-key-command) to a program that prints the key, in place of the variable:

{
  "provider": "openresponses",
  "base_url": "https://llm.internal/v1",
  "model": "qwen3-coder",
  "api_key_command": ["gcloud", "auth", "print-access-token"]
}

dax runs it without a shell when it starts, again once the key is five minutes old, and again when the server answers 401 or 403, then sends the refused request once more with the new key, so a long session outlives any one key. Its output must be a single token of printable ASCII, at most 16 KiB. It may take up to five minutes, long enough to wait on a sign-in in a browser. Concurrent requests share one run. The key is held in memory only: it is never written down or printed, and never put in any environment, so bash commands and MCP servers cannot see it. It is for the providers that take a key (not ollama or vertex), and is one source or the other with api_key_env. -api-key-command "" turns off a command your config sets.

When the command fails, or the server refuses even a fresh key, the turn ends with an error that starts authentication failed and carries the last line the command wrote to standard error. A failure is not retried and the command is not run again for a few seconds, so a helper that cannot sign in is not run over and over; send the prompt again once you have signed in. The REPL and -p show the command's standard error as it runs; the terminal client keeps it for the error, so it does not draw over the screen. To have the error say how to sign in, set api_key_login to a URL or a command:

  "api_key_command": ["my-token-helper", "print"],
  "api_key_login": "my-token-helper login"

gives authentication failed; sign in with my-token-helper login, then try again (...). dax only shows it; it does not open or run it.

A server may group a client's calls by session, or key its prompt cache on the session, and record which client called. session_header names the header each model call carries its session's ID in, and client_header the header that carries dax/ and dax's version:

  "session_header": "X-Session-Id",
  "client_header": "X-Client"

The ID is that of the session the call is recorded in: a sub-agent's calls carry the sub-agent's own session, and after /clear the new session's. A request made outside a run, such as asking the vendor what a model supports, carries none. Each must be a header name of letters, digits and hyphens, not one the client sets itself (Authorization, X-Api-Key, Content-Type and the like), and the two must differ. They are not for vertex.

Ollama, OpenAI, OpenRouter and openresponses use the openresponses client; Anthropic and Gemini use its provider adapters.

vertex is Google Vertex AI, which serves Claude and Gemini behind one project and login. Each request goes to the Anthropic adapter for a claude- model and to the Gemini adapter for a gemini- model, so the main agent and the sub-agents can run different families and /model can move between them; any other model name is refused. It takes no key: it authenticates with Application Default Credentials (gcloud auth application-default login, or GOOGLE_APPLICATION_CREDENTIALS). It reads Google Cloud's usual settings from the environment: the project from GOOGLE_CLOUD_PROJECT, falling back to the credentials' own project, and the location from GOOGLE_CLOUD_LOCATION (or GOOGLE_CLOUD_REGION), such as global or us-east5.

gcloud auth application-default login
export GOOGLE_CLOUD_PROJECT=my-project GOOGLE_CLOUD_LOCATION=global
dax -provider vertex -model claude-opus-5-5 -subagent-model gemini-3.5-flash

Vertex publishes no model capabilities, so what dax knows is read off the model ID's generation, from what Vertex accepted from each model:

models efforts so
Claude before 4.6 (claude-haiku-4-5, claude-opus-4-5) none reasoning is off; the adapter sends an effort as adaptive thinking, which they do not take
Claude 4.6 none to high xhigh asks for high
Claude 4.7 to 5 not described sent as configured
Claude 5.5 and later minimal to xhigh -think=false asks for minimal: they refuse the way the adapter turns thinking off
Gemini low to high none and minimal ask for low, xhigh for high
What the model takes

At start dax asks the vendor what the model supports and shows it under the banner, model: reasoning low–max, always on · default high · 1M context · 128k out (openrouter). Anthropic and Gemini are asked through their model endpoints, OpenRouter through its public catalogue (fetched once, without the key) and Ollama through /api/show; OpenAI and an openresponses server publish nothing, and nothing is shown.

The reasoning effort dax asks for is then fitted to the answer: an effort the model does not take becomes the nearest one it does, and dax says so once, [qwen3-coder:30b: reasoning effort low is not accepted; asking for none (ollama)]. So -think on a model that cannot reason turns reasoning off instead of failing, and -think=false on one that always reasons asks for its least effort. The fitting is done where each configuration is made (the main agent's, the sub-agents', a fold's summary, /think and /model), never to a request on its way out, so the session records the effort that was sent. When the vendor cannot be asked, or does not know the model, the effort is asked for as configured. -think (default on) asks for the -effort (minimal, low by default, medium, high or xhigh) with summaries and shows the reasoning; -think=false turns reasoning off. The effort is fitted like any other, so medium on a model that takes only low and high asks for low.

Sub-agents

The main agent can start sub-agents, each a run of its own on the sub-agent model, recorded as a child session:

  • explore investigates without changing anything (read, glob, grep, ls and bash) and returns a written answer.

  • task carries out a coding task with the file tools and bash, then reports what it changed, what it ran and what is left. Its instructions are dax's, your instructions_file and the AGENTS.md chain, without skills or memory. The main agent chooses, per call:

    • context: fresh (the default) sees only the brief, so the brief must be complete; fork also sees the conversation so far, for a task that depends on what was said or decided in it.
    • model: subagent (the default) runs the sub-agent model; main runs the main model, for a task that needs it.

    Each call reads /think and /model as they stand: main is the model /model last chose, and with no subagent_model configured the sub-agents run it too.

    A fork's conversation is recorded in the child session as its own opening items, so the child verifies like any session. On OpenRouter the defaults were the cheapest in a trial: a fresh Flash sub-agent cost about a twentieth of a Pro one at the same pass rate, and a Pro fork about twice a fresh Pro task.

Several calls in one turn run at once, so the main agent can hand out independent pieces of work in parallel. Writes and edits take a workspace lock, so two sub-agents editing one file cannot lose an edit, though they can still make changes that do not fit together; the sub-agent is told to change only the files its task is about.

Both are offered by default; -agents=false or "agents": false turns them off. They are the dax-agents extension, which ships an allow rule for starting one, since what a sub-agent then does is decided call by call by the same policy.

Config

Settings come from three layers, each overriding the one before:

  1. ~/.config/dax/config.json ($XDG_CONFIG_HOME/dax/config.json; -config path names another)
  2. .dax/config.json in the working directory, read through the workspace, which can only tighten the policy
  3. flags
{
  "provider": "anthropic",
  "model": "claude-sonnet-5-5",
  "base_url": "",
  "think": true,
  "effort": "high",
  "instructions_file": "~/.config/dax/instructions.md",
  "skills_dirs": ["~/skills"],
  "memory_dir": "~/.dax/memory",
  "pricing_file": "~/.config/dax/prices.json",
  "mcp_servers": {
    "fs": { "command": "mcp-server-filesystem /home/me/notes" }
  },
  "policy": {
    "builtin": true,
    "fallback": "ask",
    "allow": ["bash(make:*)", "write(docs/**)"],
    "ask": ["bash(go test -race:*)"],
    "deny": ["bash(git push --force:*)"]
  }
}

model, subagent_model, base_url, api_key_env, api_key_command, api_key_login, session_header and client_header belong to the provider in force where they are set. A later layer that switches the provider leaves them behind, so with "model": "qwen3-coder:30b" in your config, dax -provider openrouter runs OpenRouter's default model rather than asking OpenRouter for an Ollama one; -model beside -provider names another.

Every field is optional. The files are strict: an unknown field, a bad provider, a malformed URL or a rule that does not parse is an error that names the file and the field.

field meaning
provider, model, subagent_model, base_url, think, effort as above; base_url is for ollama and openresponses
agents offer the explore and task sub-agents; default true
api_key_env the variable holding the openresponses provider's key (the name, never the key)
api_key_command a program and its arguments, ["program", "arg", ...], that prints the provider's key; run again as the key ages or is refused (see above)
api_key_login how to sign in again, a URL or a command, shown when api_key_command fails or its key is refused; one line, at most 500 bytes
session_header the header each model call carries its session's ID in, for a server that groups calls by session (see above)
client_header the header each model call carries dax/<version> in, for a server that records which client called
instructions_file your own instructions, added to the system prompt after dax's; a relative path is relative to the file that names it
skills_dirs more skill directories, after .dax/skills and ~/.dax/skills; one that does not exist is an error
agents_md_global your own instruction files for every session, whatever the repository holds, such as an AGENTS.md in a directory above your checkouts: read on this machine after ~/.dax/AGENTS.md and before the repository's AGENTS.md chain, in order; a missing one is skipped; a relative path is relative to the file that names it
memory_dir where the model's memory lives; "" turns memory off. Default ~/.dax/memory
pricing_file a JSON file of model prices ("model": {"input","cached","output"} in USD per million tokens), used by the terminal client's status line and session pane to show cost; without it the client shows token usage but no cost
mcp_servers stdio MCP servers by name; the name prefixes their tools, mcp__<name>__<tool>; command is split as executor's string is
max_read_bytes the most bytes of a file read scans per call and edit will rewrite; default 2 MiB (use grep to find a line later in a bigger file)
pass_env credential-looking variables bash commands and MCP servers may inherit, by name (default none)
executor {"command": "docker exec -i box dax execute -root /work"}: run the tools, and the MCP servers, in dax execute started by that command (see below). command is a command line, split as a shell splits words ('…', "…", \) with nothing expanded (no $VAR, ~ or globs), or an array, ["docker", "exec", "-i", "box", "dax", "execute", "-root", "/my work"], used exactly; no shell runs either
executor.pass_env variables, credentials included, that only the command starting the executor is given, by name: SSH_AUTH_SOCK for ssh's agent, the token a kubectl credential plugin reads. bash and MCP servers never get them, here or in the sandbox; the model's key may not be one. It may be set without command, for a -executor given each time
policy see below

A project's .dax/config.json comes from a repository, not from you, so it can only tighten. It may add ask and deny rules to policy (no allow, and no ! carve-out of any rule), set "builtin": false to drop the shipped allow rules (their asks and denies stay), and set "fallback" to ask or deny when that is stricter than yours. It cannot bring back what you dropped or loosen what you set, and its rules rank below yours so they cannot cancel one of yours. It may not set provider, model, subagent_model, base_url, api_key_env, api_key_command, api_key_login, session_header, client_header, think, effort, agents, instructions_file, skills_dirs, agents_md_global, memory_dir, pricing_file, mcp_servers or executor: dax refuses the file with an error naming the field and saying to put it in your own config. (Where the model runs, what it is told and remembers, and what programs start are decisions that send your code, your files and your keys somewhere; a repository does not get to make them.)

flag
-provider, -model, -subagent-model, -base-url, -api-key-env, -think, -effort override the config
-api-key-command 'program arg ...' overrides api_key_command, split as a shell splits words with nothing expanded; "" turns it off, and api_key_login with it
-api-key-login hint overrides api_key_login
-session-header name, -client-header name override session_header and client_header; "" turns one off
-config path the user config file
-memory dir memory directory; off or empty disables it
-executor 'command ...' overrides executor: the command line that starts dax execute where the tools act, split as a shell splits words (-root "/my work") with nothing expanded; "" clears it
-executor-pass-env NAME,NAME overrides executor.pass_env; "" clears it
-pricing-file path JSON file of model prices for the terminal client's session cost
-no-policy run every tool call without asking; ignores the config's policy
-front tui|repl the front end; default tui when standard input and output are both terminals and a session is recorded, repl otherwise (so -sessions "" gives the REPL); -front tui without a terminal or a session store is refused; -p always prints
-v the terminal client prints its start lines before it takes the screen and what dax noted during the run after it exits; without it, only the executor's line and warnings before, and warnings that came while it had the screen and the resume command after
-agents-md-global 'a.md:b.md' overrides agents_md_global, split on : as PATH is, so a path may hold a space; "" clears it
-agents-md, -skills, -trust-skills the AGENTS.md files (~/.dax/AGENTS.md, agents_md_global and the repository's chain), skills, and a skill's allowed-tools running unasked until the next message, for skills in ~/.dax/skills and skills_dirs only, never the repository's
-compact N, -compact-server fold the transcript above N estimated tokens, locally or through the server; without -compact, N is three quarters of the model's context window when the vendor reports the window, and compaction is off when it does not; -compact 0 turns it off
-mcp "cmd" one more stdio MCP server, as mcp__cli__<tool>; split as -executor is
-agents offer the explore and task sub-agents (default on; -agents=false turns them off)
-sessions dir, -sync append|response|never the session store (-sessions "" disables recording) and when appends are durable

Tools

tool what it does
read numbered lines of a file, with offset and limit
write create or replace a file, making parent directories
edit replace one exact occurrence of a string
glob paths matching a doublestar pattern (**/*.go, cmd/*/main.go, *.{md,txt}), sorted
grep a regular expression over files: path, include (a glob), ignore_case, max_results; prints path:line:text
ls one directory, sorted, directories with /, files with their size
bash a command in the working directory with a timeout; runs one at a time, its output reported as progress while it runs

The file tools are confined to the working directory. A path that is absolute outside it, climbs out with .., or goes out through a symbolic link is refused. bash is not confined (a shell reaches what you reach); the policy is what stands in front of it. glob and grep skip .git, node_modules, vendor, .venv, __pycache__ and similar trees by name; there is no .gitignore support. Search a skipped directory by naming it as path.

Environment of commands and servers

Commands the bash tool runs and the MCP servers dax starts do not get your credentials. A variable is removed from their environment if its name ends in _KEY, _KEY_ID, _PAT, _PWD, _JWT, _CREDENTIALS, _AUTH, _TOKEN, _SECRET, _PASSWORD or _API_KEY, contains PASSWORD or SECRET, is one of PASSWORD, TOKEN, API_KEY, SECRET_KEY, DATABASE_URL, SSH_AUTH_SOCK, PGPASSWORD, MYSQL_PWD or a provider or CI token by name (OPENAI_API_KEY, GITHUB_TOKEN, ...), or holds a URL with user:password@ in it. So a test, a build script or a server cannot read them, and cannot use your ssh agent.

Variables that hold the path of a credential file pass through, since a program that needs its file needs them: GOOGLE_APPLICATION_CREDENTIALS, KUBECONFIG, DOCKER_CONFIG, NETRC, AWS_SHARED_CREDENTIALS_FILE, AWS_CONFIG_FILE, CLOUDSDK_CONFIG, PGPASSFILE. The files are as readable to a command as they are to you. dax has no setting to withhold them; unset one before starting dax to keep it from commands.

A command that needs a scrubbed variable (gh, a private module proxy) gets it by name from your config: "pass_env": ["GITHUB_TOKEN"]. Only your own config can say that.

The tools in a sandbox: dax execute

dax execute serves dax's tools over MCP on its standard input and output, from inside the place they should act: a container, a VM, another host. It runs no model, no policy and no session. A session elsewhere decides each call under its own policy and sends it there to run: start that session with -executor (or "executor" in your config) set to the command that starts dax execute.

dax -executor 'docker exec -i box dax execute -root /work -kind container -ref box'
docker exec -i box dax execute -root /work -kind container -ref box
ssh build-host dax execute -root /home/me/src/app
kubectl exec -i pod/agent -- dax execute -root /work -kind container -ref pod/agent

docs/sandbox.md has recipes for daily use: an image with dax in it, each launcher, the config, what to check and what does not work yet.

The pipe is the credential: whoever started the process is its one client. There is no listener, no port and no token. Since no policy runs in it, anyone who can start it can run whatever its tools run, which is no more than anyone who can already docker exec or ssh there.

Flag Default
-root the current directory where the tools act
-max-read-bytes 2 MiB as max_read_bytes
-pass-env none comma-separated variables to pass to commands though they look like credentials, as pass_env
-kind remote the workspace's kind as the session records it: local, container or remote
-ref this host's name the workspace's ref as the session records it: an image, a host, a pod

It reads no config file: the sandbox holds the files it would read, and the policy is the session's. Commands get its environment with credentials removed, as under a session; the model's key is never sent to it.

It serves the tools of every extension that has them: dax-coding's, and a program built on dax.Main serves its own as <name> execute, less those it leaves out with WithoutExtension. Sub-agents, skills and memory are the session's and stay where it runs. The tools served are the tools themselves, so what a call would touch is read there, in the sandbox's files, and the stamp that holds an allowed call to what was decided is made and checked there, under that process's own key: a stamp made anywhere else is refused, and a file tool's path that became a link since the decision is caught where the file is. What MCP has no field for crosses beside the tools: agenttool's _meta (facts and replay claims, sequential, resource), its execution/facts method, and the experimental capability io.github.christopherdavenport.dax/executor with the descriptor, the extensions, each tool in order with its extension, read-only, facts and strict, and where its files are read. A running command's output arrives as progress, and a tool's question reaches the session.

It also serves the workspace's files, read-only, so the session reads the project where the project is: MCP resources under the template dax-workspace:///{op}{?path}, where op is stat, lstat, readdir, readlink or read and path a name relative to the root (. for the root itself), percent-encoded. Every name goes through the workspace's own confinement: one that is absolute, climbs out with .. or leads out through a link is refused, and nothing is written. A read gives at most 1 MiB of a regular file (the bound on a project's config and a skill's file) and refuses a larger one, a directory or a FIFO; a listing gives at most 10,000 entries. The reply is one JSON document, with a refusal's kind (notexist, outside, invalid, permission or other) in it, so the session sees the same errors a read of its own directory would give.

It can start a process for the session, a process that lives longer than one call with pipes to it, as the session's own machine starts an MCP server: dax-specific JSON-RPC methods, dax/process.start, .write, .read, .closeStdin, .signal, .wait and .close, named in the capability's start field. They are not tools, so another harness connected to dax execute is offered no tool that runs a command, and nothing in the sandbox (its files, a project's config, a tool) makes it start one: it starts only what its one client asks for and keeps no list of its own. A process starts at the root (or a directory under it) with the environment commands get; each session has its own processes, at most 32 open at once, and an id another session started is refused; a write is at most 1 MiB and a read's reply at most 64 KiB; standard error it holds unread is bounded, and past the bound dropped. When the session's connection closes, or a signal stops dax execute, every process it started for the session ends with it. A session started with -executor runs its MCP servers this way.

With -executor, the session starts the command with this machine's environment less its credentials, but those pass_env and executor.pass_env name, and never the model's key's variable, so the key stays here. What the command and dax execute write to standard error is cleaned; while the terminal client has the screen it is held with dax's other warnings and printed when the client exits. It refuses a program that is not a dax executor (no facts method, no capability, a capability of another version, or tools the capability does not name), and checks the tools against what it would have built: every extension with tools must be one the executor runs. Each call is decided here, on what the executor says it would touch; when the executor cannot say (its claim fails, a reading takes longer than 30 seconds, the executor is gone) the call is blocked, never allowed. The banner and the record name the executor's workspace, and the model is told its root.

The MCP servers run in the sandbox too: every one in your config's mcp_servers, -mcp and /mcp add starts in dax execute, at its root and with the environment its commands get, never on this machine. Which servers start is yours alone to say: the executor keeps no list and reads no config, and a project's .dax/config.json may not name one. Their tools are mcp__<name>__<tool> as anywhere, decided here by name under your rules (they ask by default; an MCP server's tools make no claim of what they would touch), and recorded here. Their standard error comes back cleaned, and /mcp remove, the session's end or the executor's end stops them. An executor too old to start a process (dax v0.0.8 or earlier) fails a session that has an MCP server, and /mcp add, with "update dax execute where it runs"; the server is not started here in its place.

The project's files are the executor's, read through it and not from this directory: the root's AGENTS.md, .dax/skills and .dax/config.json. They are screened as a local project's are: an AGENTS.md that is a link out of the workspace, and a skills directory with a link out of it, are left out with an omitted: line. Nothing above the executor's root is read, from either machine, even when it says it is a local directory. The project's config is read once the executor is connected (your config and the flags say which executor; a project's file cannot) and, as anywhere, can only tighten the policy; one that cannot be read (a link out, over 1 MiB, the executor gone or answering none of it within 30 seconds) stops the start, since leaving it out would drop rules that only tighten. An executor whose files cannot be read at all when the session starts stops it too; a single AGENTS.md or skills directory that cannot be read is left out and reported, as on this machine. Your own ~/.dax/AGENTS.md, agents_md_global and skills are read here as usual. An executor from dax v0.0.6, which does not serve its files, is refused: update dax where it runs.

What -executor does not do yet:

  • Only a command: an http:, https: or unix: address is refused.

Standard output carries MCP and nothing else. While it serves, os.Stdout is standard error, so a stray print from a tool cannot corrupt the stream; commands get no standard input and their output is captured. Logs and errors go to standard error.

Policy

Each tool call is decided by rules: deny, then ask, then allow, then the fallback (ask). A rule is a tool name, or a name with a specifier: read, bash(go test:*), write(docs/**). Bash, Read and Edit are accepted as the reference's names. For bash the specifier is matched against the command; go test:* means go test and anything after it at a word boundary, so it matches go test ./... and not go testing.

dax ships this default, as the rules of its extensions (a verdict names the one whose rule decided it: extension:dax-coding, say):

  • runs without asking: read, glob, grep, ls and the read-only bash commands listed below (dax-coding's), explore and task (dax-agents'), skill (dax-skills') and memory_search (dax-memory's);
  • asks: write, edit, every other command, memory writes, and every MCP tool. go test, go build, go vet and go list ask: they run the repository's code (a TestMain, cgo, a vet tool), so a hostile repository would run code as you on the model's say-so.

What runs without asking is a safe subset, not a blacklist. A bash call is auto-allowed only when it parses in a strict subset of the syntax and every part of it is a command dax knows to be read-only, with arguments that are.

The syntax: words of letters, digits and _ . / : @ % + , = - (and ~ or ^ inside a word, as in HEAD~1), single-quoted strings, and double-quoted strings with no $, backtick or backslash; the operators && and |; at the end of a command 2>&1, 2>/dev/null or >/dev/null as a separate word; and * or ? in the arguments of ls. Anything else, ;, &, ||, <, any other >, #, $, backtick, backslash, parentheses, braces, [, a newline, = in the command word, a non-ASCII byte, asks. It is never denied for that.

The commands (&& joins any of them; after a | only head, tail, wc, sort, uniq, cut and grep, with flags from a short list and no file arguments):

  • git status, diff, log, show, branch (listing forms only, a name only with --list), rev-parse, ls-files, remote (-v only), blame, stash list, tag (listing forms only), describe, shortlog and config --get of one key that holds no secret (user.name, user.email, core.autocrlf, branch.*.remote, remote.*.url, ...; not --list, --get-regexp, --global, --system, http.* or credential.*), with read-only flags from an allowlist, including combined short flags (-sb) and space-separated values (-n 5, --author x, -S foo). Not --output, -o, --ext-diff, --textconv, -c, -C, --git-dir, --no-index, or %G in a format.
  • ls (listing flags; a glob is expanded and checked), pwd, go version, go env NAME.
  • cat, head, tail, wc and grep (not recursive) on named files. Each file must be a regular file inside the working directory no larger than max_read_bytes (default 2 MiB), checked when the call is decided, and what bash returns is capped at 50 KiB however big it is; grep -r asks, use the grep tool.
  • cd <directory inside the workspace> && ...: the directory is checked, and what follows is checked relative to it.

The arguments: every path stays inside the working directory with links resolved and no .. component at all, and no git revision or pathspec contains a : (HEAD:file and :/file name what is in the repository, which may be above the working directory).

git: before a git command runs unasked, dax asks git for the config it would use (git config --list --show-scope). If the repository's own config, or anything it includes, names a program (filter.*.clean, smudge or process, diff.*.textconv or command, core.askPass, editor, gitProxy, attributesFile, remote.*.uploadpack or receivepack, credential.helper, merge.*.driver, pager.*, protocol.*.allow = always, ...), moves the work tree (core.worktree), is bare, sets an extension, or runs a submodule update command, or if a submodule's own config names a program, the call asks and the question names the key. It also asks when .git is a file or a link, or when git's own answer for the repository (rev-parse) is not the workspace's ancestor chain: a hand-built .git can point git at another repository or at your home directory. Your own global and system config are trusted. The auto-allowed run switches off the rest: no fsmonitor, pager, ssh command or hooks, the gpg programs are /bin/false, --no-ext-diff --no-textconv are added to diff, log and show, and go runs with GOTOOLCHAIN=local. What an auto-allowed command prints has the user:token@ of any URL replaced by ***@. A command you approve runs as you would run it, hooks and GIT_CONFIG_* included.

What was decided is what runs: the policy stamps an auto-allowed call with the plan it approved and the files it was decided on, and the bash tool runs a stamped call only if the line still analyses to that plan on those files. If a file, where a path leads (notes.txt is now a link to .env) or the repository's config changed in between, the call fails with "the command changed since it was allowed; ask again", and the original line is never run instead. A line outside the subset that writes through a redirect (echo x > notes) is never auto-allowed, but once you approve it (or a rule allows it) it is stamped with the files its redirects write, and it runs as typed only while they are still those files: a target that became a link to .env after your yes is refused the same way. Only dax can stamp a call; one the model stamps is refused. ls with a glob runs with the expansion after a --, so a file called -n is a name.

Secret-looking files ask (any case, and under any name: a link to one is decided under its target as well): reading .env*, *.pem, *.key, id_*, *.p12, *.pfx, .npmrc, .netrc, .pgpass, .git-credentials, credentials*, *secret*, .aws/**, .ssh/**, .kube/config or .docker/config.json (at the root or in any directory) asks, whether by read, grep, glob or ls or by cat, head, tail, wc, grep and git show, log, diff or blame of the path: the stage is decided as a read of it. Name the path to open one, in your own config: "allow": ["read(.env)"] (or Read(config/*.pem)) allows it for every tool, cat .env included, and only that. A bare "allow": ["read"] does not open them: an ask beats an allow. This is a guard on naming a file, not a boundary: a search of the whole tree (grep -r, the grep tool with no path) can still read one, and so can anything you approve.

A line with git in it asks if the repository names a program, whatever else is on it: when a line is not auto-allowed (a stage your own rule allowed, say echo, beside git status) it runs as typed, without the auto-allow environment, so the repository's core.fsmonitor, hooksPath, sshCommand, pager and gpg program count as well, and the question names the key.

A command outside the subset is still cut into its parts, so a deny or ask rule for rm reaches git status; rm x, a redirect to a file is decided as a write of the file it opens, and the question names the part it is asking about. The target is read as a file tool's path is: normalised from the directory the line is in (after a plain cd DIR, and from the root as well), and through its links, so "deny": ["write(.env)"] refuses echo x > notes when notes is a link to .env, and cd sub && echo x > ../.env. A target whose links the workspace cannot read, or that leads out of it, adds a subject no rule names; one bash expands ($HOME/x, ~/x, a glob) is decided as its text. The cut is for the question and for deny and ask rules. Nothing is allowed because of it: a command outside the subset is allowed only by a bare bash allow rule or "fallback": "allow". Inside the subset a command the list above does not govern (make, rm) is for your rules, one stage at a time.

Trusting go test for your own repositories. Put the rule in your user config, ~/.config/dax/config.json, never a repository's (a project file's allow rules are ignored):

{ "policy": { "allow": ["bash(go test:*)", "bash(go vet:*)", "bash(go build:*)"] } }

It applies to every repository you open, so only add what you would run by hand in any of them. Each call is still one simple command: go test ./... && rm -rf x asks, as does go test ./... > out, and an arbitrary -exec, -toolexec or -vettool is yours to decide: tighten with "ask": ["bash(go test -exec:*)", "bash(go build -toolexec:*)", "bash(go vet -vettool:*)"] (the -o and -coverprofile flags write files and are worth asking about too).

Your rules in policy add to the default. Because deny beats ask beats allow, allow what the default asks about ("allow": ["write(docs/**)"]) and ask or deny what it allows. An ask the extensions ship, such as the one for a secret-looking path, holds against a plain allow of yours: lift it with a carve-out ("ask": ["deploy(!staging)"]) or, for the file tools, an allow that names the path ("allow": ["read(.env)"]). "builtin": false drops the shipped allow rules, so only your rules allow anything; the shipped asks and denies stay. "fallback": "allow" or "deny" changes what a call no rule names does.

Path rules (read(.env), write(docs/**), Read(secrets/**)) are written relative to the working directory and match the path after cleaning, so ./.env, a/../.env and the absolute path all meet the rule for .env, and docs/../.git/x is .git/x. Links are not followed for matching; the tools' own confinement still refuses one that leaves. glob, grep and ls are matched on the directory they search (default .), not on their pattern, and a rule for a directory should name dir and dir/**. A path rule is not a read ACL for a search that includes the directory from above.

An asked call prints ? allow bash {"command":"..."} (reason) [y/N]. Your answer is recorded in the session as a person's.

Security model

dax gives a model the ability to read your files, change them and run commands, so what it does and does not stand between the model and your machine matters.

What dax does

  • Asks by default. Every call that is not on a short allow list asks you first: writes, edits, every command that is not one simple read-only command, memory writes and every MCP tool. Your answer is recorded.
  • Auto-allows a small, checked set. The read-only tools (read, glob, grep, ls, confined to the working directory) and bash lines that parse in a safe subset of the syntax (simple commands joined by && and |, with a few trailing redirects) and whose every part is a read-only git, ls, cat/head/tail/wc/grep on named files, pwd, go version/go env NAME or cd into the workspace, with arguments that pass a per-command check and paths that stay inside the working directory (no .., links resolved). It is an allow-list of what is safe, not a list of what is dangerous: anything dax does not recognise asks. git is asked what its config would run first. go test, go build and go vet run the repository's code and are not on it.
  • Confines the file tools. Paths outside the working directory, .., and symbolic links that lead out are refused, by the operating system's rooted open rather than by string checks.
  • Treats the repository as untrusted. Its .dax/config.json can only tighten the policy; it cannot choose the provider, model or endpoint, add instructions, skills, memory or MCP servers, or allow anything. Its "builtin": false drops only allow rules, never an ask or a deny. Its AGENTS.md, .dax/skills and .dax/config.json are read through the workspace, so one that is a symbolic link out of it is refused as a tool's read would be: an AGENTS.md or .dax/skills is left out and reported, and a .dax/config.json is an error. A .dax/skills holding a link that leads outside it, even into the workspace, is left out too, since the skill tool reads without asking. A read of a file of the repository's skills, the instructions included, is held to read's asks and denies on the file's name in the workspace and where its links lead, so a .dax/skills that is itself a link elsewhere in the workspace (-> .., -> ../config) does not read a .env without read(.env)'s question, and your deny read(config/**) refuses it. An allow of read(...) never allows such a read; the skill tool's own rule does. The skills you installed are read unasked. Path rules match the path after normalisation, so docs/../.git/x is not under docs/**.
  • Keeps credentials away from what it starts. Bash commands and MCP servers get your environment without *_API_KEY, *_TOKEN, *_SECRET and the like unless your config names a variable. Keys are read from the environment or from your own api_key_command, and never printed.
  • Bounds resource use. read scans at most 2 MiB a call, grep skips big and non-regular files, search patterns cannot run away, and git runs with the repository's fsmonitor, pager and diff programs switched off.
  • Keeps its records private. The session store and memory are created 0700; terminal control sequences in tool output and model text are stripped.

What dax does not do

  • There is no sandbox. A command you approve runs with all your privileges, and a command that is auto-allowed is only as safe as the checks above. bash reaches files outside the working directory (approved commands are not confined, and the read-only allow list checks paths but cannot see what a program does with them). If you need a boundary, run dax in a container or VM.
  • A prompt injection can still ask. A file, a web page, an MCP tool's output or an AGENTS.md can tell the model what to do. dax makes the dangerous steps ask; it cannot make you read the question. Read what you approve, especially a compound command, a write to .git/hooks, .dax/, AGENTS.md or .github/, and anything that sends data out.
  • Your own allow rules are yours. "allow": ["write"] lets a model write .git/hooks/pre-commit; bash(go test:*) runs a hostile repository's tests with your privileges. Prefer narrow rules, and put deny or ask rules for the sensitive paths beside them.
  • Committed secrets are not caught. The secret-path asks fire when a file is named. git log -p, git show and git diff without a path, grep over the tree and git show HEAD~5 print what a repository holds, and a .env or key that was ever committed is in it. Keep secrets out of history (and rotate one that got in); dax cannot tell which lines of a diff are keys.
  • -trust-skills trusts the skills in directories you named (~/.dax/skills and your config's skills_dirs) and never the repository's: a skill in .dax/skills is text from the repository, and its allowed-tools are withheld, so it cannot run anything unasked.
  • The provider sees what the model reads. Files and command output go to the model's provider (OpenAI, OpenRouter and whichever upstream it routes to, Anthropic, Google, your Ollama host, or the openresponses server you named). Choose the provider with that in mind; a path rule is not a read ACL for a search that includes the directory from above.
  • The sub-agents are governed like the parent: every call explore or task makes is decided by the same rules (your denies, the secret-path asks, path rules, the auto-allow list), and one that asks is put to you, from inside the sub-agent's run. A task can write, so with "fallback": "allow" it writes unasked, as the main agent would.
  • Not covered: programs the user's own git config names (it is trusted), a race between dax checking a path and the command using it, credential-file path variables such as KUBECONFIG (they pass through to commands), and denial of service by a model that loops (use Ctrl-C).

The terminal client

On a terminal, dax opens the terminal client (agentconsole) over the same session: the conversation rendered from the session's record, with the run's live deltas on top. When it exits it leaves one line on the terminal, the command that resumes the session:

To resume this session: dax -resume 01a112e5-b4df-7081-bd11-baac746b29cc

Any warning, such as a store whose permissions were fixed, is printed before the client takes the screen, and dax waits for Enter, since the client uses the alternate screen. A warning that comes while the client has the screen, such as what the executor's launcher writes to standard error, is held and printed when it exits, above the resume command. With -executor, the executor's line (its name and version, kind and ref, and root) is printed before the client either way, and stays on the terminal when it exits. With -v dax also prints its start lines (provider and model, the session, the policy in force, the tools, any omitted: line) and waits for Enter on an omission too, and when the client exits it prints what it noted during the run (skill grants, compactions, denied calls) above the resume command.

Each of dax's tool calls is drawn as what it does, on one line, with a short body under it:

tool the line collapsed Ctrl-O
edit the path, the lines it adds and removes (+3 −1) the change as a diff, up to 8 lines the whole diff
write the path and the content's lines the content
bash $ and the command's first line, its timeout if set how many lines it printed; a failure's exit status and last 3 lines the whole output and the exit status
read the path and the lines asked for (a.go:120-199) how many lines it read the output
grep, glob, ls the pattern or directory, where and how the matches and files, the files or the entries counted the output
explore, task the brief's first line, fork and main model when set the report's first 2 lines the report

A call that failed shows its error in place of the body. A call waiting on a permission shows what it would do: an edit its diff, a command of several lines the rest of it. Any other tool (skill, memory, an MCP server's) is drawn as the client draws any call, its name and arguments.

key
Enter send a prompt, or steer the run in flight
Ctrl-C copy the selection when one is drawn; otherwise abort the run, quit when idle (a second one quits at once)
y / n approve / refuse the permission asked; n then takes an optional reason and Enter
y / a / n on a question: allow, allow with a note, refuse with an optional reason; the note or the reason is typed, then Enter
PgUp, PgDn, Ctrl-Up/Down, Ctrl-Home/End, mouse wheel scroll the conversation
Ctrl-R / Ctrl-O show reasoning / tool arguments and output in full: the selected row's, or with no row selected every row's
Ctrl-T the tree of the session's branches; Enter views one, c continues from it
Ctrl-P, Ctrl-N, Ctrl-B, Tab move over the rows, continue from a row, open its detail (policy decision, who decided, verification)
click select the row under the pointer; a click on the selected row expands or collapses it
drag select text anywhere on the screen; it stays drawn until the next key or press
Ctrl-/ or F1 list every key; Esc, q or Ctrl-/ goes back

A call the policy asks about is a permission: the panel shows the call and the reason, which is the policy's with what it is asking about added: the rule that fired, the secret-looking path, the git config key that names a program, the part of a command line. A call the policy only held beside the one it asks about is not shown: your answer to the batch decides it. A call that was started before it was held says it may have run. A session that stopped with a call held for approval opens with it on the panel, and a prompt waits until it is answered. Everything that can ask is answered on screen; nothing reads standard input once the client has the terminal. Two things cannot be a permission, which a run that ended answers, since the call that needs the answer is still running; the client asks them as questions while the run goes, ahead of any permission:

  • A call a sub-agent (explore or task) makes that the policy asks about: y allows it, a allows it with a note, n refuses it with an optional reason, and Esc goes back. The sub-agent sees the note with the call's result, and the reason in place of it.
  • A question a tool asks mid-call (MCP elicitation) that is yes or no. One that is a form or a page to visit has no screen in the client, and is cancelled.

A call stamped as auto-allowed that changed before it ran fails with "the command changed since it was allowed ... ask again", which shows in the transcript.

Commands

A line that starts with a slash is a command to dax, in the terminal client and the REPL alike; the model never sees it. Start a line with // to send the model a line that begins with a slash (//etc/hosts sends /etc/hosts). In the terminal client a command's output shows over the input until the next line, and an error gives the line back to edit.

  • /model name, /think on|off: the model and reasoning from the next request.
  • /mcp add <name> <command> (tools are mcp__<name>__<tool>; a name has letters, digits, - and _, and no __), /mcp remove <label>.
  • /tools, /session, /help.
  • /follow text queues a follow-up that runs once the model would have stopped; /abort aborts the run; /quit leaves.

Any other line typed while a run is in flight steers it and lands before the next model call. /model, /think and /mcp wait for the run to end; the rest work during one. An unknown command is an error, not a prompt. /compact does not exist (use -compact N).

Serving a session: dax serve and dax attach

dax serve runs a session with no front of its own and serves it, so a terminal elsewhere can drive it with dax attach. It takes the same flags as dax (the provider, the model, -resume, -executor and the rest) and -listen, the address (default 127.0.0.1:0, a free port):

dax serve -resume 01a112e5-b4df-7081-bd11-baac746b29cc
# serving at http://127.0.0.1:41753
# token: /home/you/.dax/serve/01a112e5-....token
# attach: dax attach -token-file /home/you/.dax/serve/01a112e5-....token http://127.0.0.1:41753

dax attach -token-file ~/.dax/serve/01a112e5-....token http://127.0.0.1:41753
  • Loopback, unless it has a certificate. Without TLS, dax serve refuses an address that is not 127.0.0.1, ::1 or localhost: the session runs tools and answers what its policy asks, so it is not offered to the network in the clear. The simplest way in from another machine is still a tunnel: ssh -L 7878:127.0.0.1:7878 host, then attach to http://127.0.0.1:7878.

  • HTTPS with a certificate. Given a certificate and its key (PEM), dax serve serves HTTPS and may listen on any address:

    dax serve -listen 0.0.0.0:7878 -tls-cert host.pem -tls-key host-key.pem
    dax attach -token-file host.token https://host.example:7878
    

    dax attach verifies the certificate against the system's roots, or against the CA you give it with -tls-ca ca.pem for a certificate of your own; nothing skips the check. It refuses an http:// URL to a host that is not loopback, since the token would cross the network in the clear. The certificate must name the host you attach to.

  • One token. Every request needs it. It is $DAX_SERVE_TOKEN if set; otherwise dax serve makes one and writes it to a file under ~/.dax/serve that only you can read, removed when it stops. The token is never printed; attach reads it from -token-file or $DAX_SERVE_TOKEN.

  • One conversation. Every view attached to a session drives the same conversation: what one asks, the others show, and a permission or a question is answered from whichever view answers first. Questions and held calls wait for whoever attaches. For independent work, fork the session instead.

  • Leaving is not stopping. /quit in an attached view leaves the session's run going; ctrl+c in a view aborts it. dax serve stops on ctrl+c or SIGTERM, aborting a run still going, and prints the command that resumes the session.

  • Commands. The slash commands run against the served session: /model, /think, /mcp and /session act there, through the server's commands.

  • The tree. Continuing from an entry (Ctrl-B on a row, c in the tree) moves the served session's head, as it does locally: the next prompt, from any view, continues from there. A move the session refuses, while a run goes or from a call whose output is not on the path, is shown in the view that asked.

In the REPL

dax -front repl (the default when not on a terminal) is a line REPL with the same commands; a permission or a question is answered with y or n on the next line, and what follows the first word is what you say with the answer, which the model is told: y keep it short allows with a note, n not that file refuses with a reason. Any other line refuses, the whole line its reason. -p reads its answers the same way.

Sessions

Every run is recorded in one content-addressed store, ~/.dax/sessions, for every project. Admin commands:

dax -list                          # sessions recorded for this directory
dax -resume <id>                   # continue one, answering any call an abort cut off
dax -verify <id>                   # rebuild every request and check its hash
dax -project <id> -out dir         # write one as a JSONL file (RFC 0001)
dax -import old.jsonl              # bring a JSONL session into the store
dax -gc pack                       # pack loose objects; -gc sweep also drops what no session needs
dax -repair <id>                   # rewrite a damaged log from what still reads

-list, -verify and -project open the store read-only, so they work beside a running dax. -sessions "" disables recording.

Other state lives in ~/.dax: AGENTS.md (read before the project's), skills/, memory/. A project's .dax/skills and its AGENTS.md files are read too, through the workspace. The AGENTS.md chain follows the convention (https://agents.md): from the repository's root, the nearest directory at or above the start that holds a .git, down to where dax starts, the nearest file last; a directory above the repository is not read. With no repository only the start directory's file is read. A file you want in every session whatever the repository, such as one in a directory above your checkouts, goes in your config's agents_md_global.

Building on dax

dax is a Go module as well as a command. cmd/dax is one line, dax.Main with no options; a program of your own is the same line with an extension:

func main() {
	os.Exit(dax.Main(context.Background(), os.Args[1:],
		dax.WithName("acme", ""),
		dax.WithExtension(extension.Extension{
			Name:         "acme-deploy",
			Tools:        deployTools, // func(extension.ToolEnv) []agenttool.Tool
			Matchers:     extension.FixedMatchers(map[string]agentpolicy.ToolMatcher{"deploy": {Match: agentpolicy.GlobMatcher("env")}}),
			Policy:       policy.Rules{Allow: []string{"deploy(staging)"}},
			Instructions: "Never deploy to prod unless the user asks.",
			Renderers:    func(string) toolview.Renderers { return toolview.Renderers{"deploy": deployRenderer{}} },
		})))
}

dax's own extensions are built the same way, from the same fields:

Extension Package What it offers
dax-coding ext/coding read, write, edit, glob, grep, ls and bash; the allow list, the secret-path asks, the matchers and the aliases (Bash, Read, Edit, Write) for them; the stamp that holds an auto-allowed bash call to the plan and the facts the policy approved
dax-agents ext/agents the explore and task sub-agents, given every extension's tools (the read-only ones for explore)
dax-skills ext/skills the skill tool and catalogue; grants under -trust-skills
dax-memory ext/memory the memory tools and block

dax-coding is always on; the others follow agents, skills and memory in the settings. WithoutExtension leaves one of the four out whatever the settings say (any other name is an error), and WithExtension adds the program's after them.

extension.Extension field What it adds
Name the extension's name; its rules are recorded as extension:<Name>
Tools tools (agenttool.Tool), in order, for the main agent and for task
ReadOnly the names of its tools that only look, which explore gets too; one annotated destructive is refused
Owns names of tools it adds through Kit (skill, memory_save), so its rules may name them
Matchers how a rule's specifier reads a call of one of its tools: deploy(staging) above. Built over the session's ToolEnv, so one that looks at files looks at the session's workspace; extension.FixedMatchers wraps ones that read only the arguments
Aliases names a rule may use for several of its tools: Read for read, grep, glob and ls
Policy the allow, ask and deny rules it ships for its tools
Lifts its tools whose asks a user's allow rule with a specifier lifts: read(.env)
HeldTo its tools whose calls are held to another extension's tools' ask and deny rules (never their allows), by what the tool's facts claim names: dax-skills holds skill to read
BeforeToolCall a hook, built over the session's ToolEnv, that decides or rewrites a call, folded with the policy, for the main agent and the sub-agents; extension.FixedHook wraps one that needs nothing of the session
Instructions text in the main agent's system prompt, in extension order, before your instructions_file
Kit agentkit options for anything else (skills, memory, child agents, guards), given every extension's tools
Renderers how the terminal client draws its tools' calls (toolview.Renderer)

A session builds its extensions in two phases: every extension's tools, then each one's Kit options, so a sub-agent built in the second phase gets a tool an extension listed after it adds. Names are checked across the whole session, without regard to case: two extensions may not offer a tool, an alias or an owned name that differ only in case, mcp__ belongs to MCP servers, and an alias may differ from its own tool only in case (Bash and bash). The session's own kit options (the name, the model, the instructions, the policy, the session) go after the extensions', so where an option replaces another the session's is the one in force; a Kit that sets a policy on a session with the policy off is an error. The session closes the tools it built. One tool value is shared by every agent that has it, the main agent, explore and task, and may be called by them at once, so a tool that keeps state guards it.

An extension's tools are under the same policy as dax's. A call no rule allows asks first. Each extension's rules are a source of their own, extension:<Name>, which every verdict one of them decides names in the session's record, and the start line counts them. A rule may name only the extension's own tools and aliases, not a pattern and not a carve-out, so one extension cannot loosen another's tools or cancel its rules. Nor can it borrow them: a call of another extension's tool that its tool's facts claim (agenttool.Factual) or its matcher's own subjects name is decided as a tool no rule names, so it asks, unless HeldTo holds the claiming tool to that tool, whose asks and denies then apply to the call and whose allows never do. Deny beats ask beats allow whatever the source: an extension's ask or deny holds against a plain allow in your config, and a carve-out in your config ("ask": ["deploy(!staging)"]) or, for a tool in Lifts, an allow with a specifier lifts it. A project's config can add asks and denies, and "builtin": false drops the extensions' allow rules but keeps their asks and denies.

An extension's tools get an extension.ToolEnv: the session's Workspace, Files over it, and MaxReadBytes. A tool that takes a path from the model reads and writes through Files (ReadFile, WriteFile, Update, Stat, ReadDir, Rel), which turns the path the model wrote, absolute in the workspace's root or relative to it, into the workspace's name and refuses one that leaves, as dax's file tools do. Update is a read-modify-write under the lock dax's write and edit take, so a tool's change and an edit beside it cannot lose each other's; a FIFO or a device is refused rather than waited on. A tool that runs a process uses Workspace.Exec, whose processes start with Workspace.Env(), the environment with credentials removed (a copy each call); the workspace types are the agentworkspace module's (github.com/ChristopherDavenport/agentworkspace, which dax imports as workspace). Written that way, a tool runs unchanged wherever the session's workspace is. A tool is built over ToolEnv rather than agentkit's kit because what it needs is dax's, the workspace and its confinement, which the kit does not hold.

A renderer reads only the record. It declines (returns ok false) a call whose arguments or schema it was not written for, such as one an older version recorded, and the client draws that call as raw text. Two extensions that draw one tool are an error.

WithName puts the program's name in the banner, the resume command, the client header, the session header and the prompt; the config files and the store are still dax's (~/.config/dax, .dax, ~/.dax). The program keeps the flags, the config files, the providers, the session store, MCP, and the three fronts.

For a front of your own, build the session with agent.New and Options.Extensions, and drive it as agentturn's control contract (*agent.Session is an agentturn.Control): prompt, queue a steer or a follow-up, answer the permissions a run stops on (Session.Permissions, Resume), reply to the questions asked while a call runs (the agentturn.Question events, Reply, after Session.Answering), and subscribe to the agent's events. Session is also agent.Controls (model, reasoning, MCP servers, Info); Session.Record is the store to follow for a view, and Session.Recorder what agentconsole's client.RecordOf takes. agent.Drive is a controller that answers by rule, for running without a person. The terminal client is agentconsole; hand it extension.Renderers(dir, exts). The public packages are dax, agent, extension, policy, tool, toolrender and the four under ext/, over the agentworkspace module's Workspace; the configuration, providers, model metadata, prompt frame and renderer for the REPL stay internal. Everything is pre-1.0: the exported API may change in a minor version, and the changelog says when.

Where the tools act

A session acts in one workspace.Workspace, from the agentworkspace module (github.com/ChristopherDavenport/agentworkspace): a root, a file system that never blocks on a FIFO or a device, writes and removes, an environment, one-shot execution with its output streamed as it arrives, and a descriptor the session records; a workspace that can also run a long-lived process with pipes is a workspace.Starter. workspace.Local is this machine's directory, opened as an os.Root so no name leaves it, with each command in its own process group. A container or a remote runtime is another implementation of the same interface, and the rest of dax cannot tell them apart:

  • dax-coding's tools act through it: read, write, edit, glob, grep and ls through tool.Files, and bash through Workspace.Exec, with the same output wherever it runs.
  • The policy's checks of a call read the same workspace: a path rule follows links through it, and the bash analyzer stats files, follows links, finds .git and reads git's configuration there (git is run through Exec, with paths passed as arguments). A workspace whose file system cannot tell a link from its target leaves those checks unable to follow links, and they ask.
  • The project's instructions are read through it: the AGENTS.md chain (agentsmd's Options.FS), .dax/skills (an agentskill.Source over its file system) and .dax/config.json, each screened there, so a container gives its own and nothing comes from the directory dax started in. Only when the workspace is that directory on this machine and dax started below its repository's root are the AGENTS.md files between the two read from this machine, screened the same way.
  • MCP stdio servers run in it when it is a Starter (Local is, and so is an executor's), at its root and with its environment, and end with the session; in a workspace that cannot start a process they run on this machine, with its environment scrubbed.
  • The session records the workspace: the header's and the env entry's cwd are its root, and the env entry names its kind (local, container, remote) and ref. The model is told the root as the working directory.

agent.Options.Workspace takes one; without it the session opens a workspace.Local over Dir and closes it. agent.Options.Executor takes an executor in its place (agent.DialExecutor, which starts dax execute as -executor does), whose tools act where it runs; the caller closes it. agent.Options.Store takes the session store the same way: an agentsession.Store the caller opened and closes, or, without it, the content-addressed store at Root. A remote store client (agentsession's RFC 0003) is meant to fit there.

What is not there yet:

  • dax uses only workspace.Local; a container or remote workspace is a program's to provide until agentworkspace ships one. dax execute serves the tools from inside a sandbox and -executor (agent.Options.Executor, agent.DialExecutor) runs a session's tools and MCP servers there and reads the project's files through it.
  • RFC 0003's client is not a drop-in Store: opening takes a lease, an append returns a different result, and a lost lease needs handling. Options.Store takes today's interface; the lease handling waits for agentsession's remote client. Session.Path is empty for a store that is not local.
  • A front over a wire (the terminal client driving a session elsewhere, ACP) is not built. The contract it carries is agentturn's, which the session implements, served by agentturn/front/control; what waits is the record over the wire (agentsession's RFC 0003 read and follow) for the view.

Develop

make check    # gofmt, go mod tidy -diff, go vet, staticcheck, govulncheck, go test -race
make build    # ./dax

In this workspace the module is built outside the go.work: GOWORK=off GOFLAGS=-mod=readonly make check. See AGENTS.md for the layout and release process, and CHANGELOG.md.

License

MIT. See LICENSE.

Documentation

Overview

Package dax is a coding agent: a terminal client, a REPL, or one prompt with -p, over an Open Responses model, under a policy, recording every session. Command dax (./cmd/dax) is Main with no options.

dax is a minimal core meant to be extended. The session (the model, the store, the policy, the workspace, AGENTS.md, MCP servers, compaction) knows no tool. Everything the model can do comes from an extension.Extension, and dax's own are extensions like any other: dax-coding (package ext/coding) has the file tools and bash, dax-agents the explore and task sub-agents, dax-skills skills, dax-memory memory. A program built on dax adds its own with WithExtension, on the same terms, and may leave one of dax's out with WithoutExtension; it keeps the flags, the config files, the providers, the session store and the fronts.

func main() {
	os.Exit(dax.Main(context.Background(), os.Args[1:],
		dax.WithName("acme", ""),
		dax.WithExtension(extension.Extension{
			Name:      "acme-deploy",
			Tools:     func(e extension.ToolEnv) []agenttool.Tool { return []agenttool.Tool{deploy.New(e.Workspace)} },
			Matchers:  extension.FixedMatchers(map[string]agentpolicy.ToolMatcher{"deploy": {Match: agentpolicy.GlobMatcher("env")}}),
			Policy:    policy.Rules{Allow: []string{"deploy(staging)"}},
			Renderers: func(string) toolview.Renderers { return toolview.Renderers{"deploy": deploy.Renderer{}} },
		})))
}

A program that wants a front of its own builds the session with agent.New and the same extensions.

dax is pre-1.0: the exported API of this package, agent, extension, policy, tool, toolrender and the ext packages may change in a minor version, and the CHANGELOG says when.

Example

A program built on dax: dax's flags, config, providers, session store, fronts and extensions, with one extension of its own holding two tools. changelog only looks, so the explore sub-agent has it too, and it runs unasked; deploy asks before every call, and the user's config can say otherwise with a rule such as deploy(staging), which the env matcher reads.

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"os"

	"github.com/ChristopherDavenport/agentconsole/toolview"
	"github.com/ChristopherDavenport/agentconsole/view"
	"github.com/ChristopherDavenport/agentpolicy"
	"github.com/ChristopherDavenport/agenttool"
	workspace "github.com/ChristopherDavenport/agentworkspace"

	"github.com/ChristopherDavenport/dax"
	"github.com/ChristopherDavenport/dax/extension"
	"github.com/ChristopherDavenport/dax/policy"
)

// changelogTool reads the project's CHANGELOG.md through the session's
// workspace, so it reads the same file whether the workspace is this
// machine, a container or a remote runtime, and reaches nothing outside.
func changelogTool(env extension.ToolEnv) []agenttool.Tool {
	return []agenttool.Tool{agenttool.NewFunc("changelog", "Read the project's changelog.", nil,
		func(context.Context, agenttool.Call) (agenttool.Result, error) {
			data, err := env.Files.ReadFile("CHANGELOG.md", env.MaxReadBytes)
			if err != nil {
				return agenttool.Result{}, err
			}
			return agenttool.Text(string(data)), nil
		})}
}

// deployTool is a tool that changes something outside the project.
func deployTool(env extension.ToolEnv) []agenttool.Tool {
	return []agenttool.Tool{agenttool.NewFunc("deploy", "Deploy the project to an environment.",
		json.RawMessage(`{"type":"object","properties":{"env":{"type":"string"}},"required":["env"]}`),
		func(ctx context.Context, c agenttool.Call) (agenttool.Result, error) {
			// The deploy runs where the session's tools act.
			out, err := env.Workspace.Exec(ctx, workspace.Command{Args: []string{"make", "deploy"}})
			if err != nil {
				return agenttool.Result{}, err
			}
			return agenttool.Text(fmt.Sprintf("deployed (exit %d)", out.ExitCode)), nil
		})}
}

// deployRenderer draws a deploy call in the terminal client.
type deployRenderer struct{}

func (deployRenderer) Head(c view.Call) (toolview.Line, bool) {
	var a struct{ Env string }
	if json.Unmarshal([]byte(c.Args), &a) != nil || a.Env == "" {
		return nil, false
	}
	return toolview.Line{toolview.S(toolview.Dim, "to "+a.Env)}, true
}

func (deployRenderer) Body(view.Call, bool) ([]toolview.Line, bool) { return nil, false }

// A program built on dax: dax's flags, config, providers, session store,
// fronts and extensions, with one extension of its own holding two tools. changelog only looks, so the
// explore sub-agent has it too, and it runs unasked; deploy asks before
// every call, and the user's config can say otherwise with a rule such
// as deploy(staging), which the env matcher reads.
func main() {
	os.Exit(dax.Main(context.Background(), os.Args[1:],
		dax.WithName("acme", ""),
		dax.WithExtension(extension.Extension{
			Name: "acme-release",
			Tools: func(e extension.ToolEnv) []agenttool.Tool {
				return append(changelogTool(e), deployTool(e)...)
			},
			ReadOnly:     []string{"changelog"},
			Matchers:     extension.FixedMatchers(map[string]agentpolicy.ToolMatcher{"deploy": {Match: agentpolicy.GlobMatcher("env")}}),
			Instructions: "Read the changelog before you describe a release. Never deploy to prod unless the user asks.",
			Policy:       policy.Rules{Allow: []string{"changelog"}},
			Renderers:    func(string) toolview.Renderers { return toolview.Renderers{"deploy": deployRenderer{}} },
		})))
}

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func Main

func Main(ctx context.Context, args []string, opts ...Option) int

Main runs dax with args, the command line after the program's name, and returns the process's exit status: 0, or 1 after printing the error.

Types

type Option

type Option func(*program)

Option configures Main.

func WithExtension

func WithExtension(e extension.Extension) Option

WithExtension adds e after dax's extensions, in the order given. Two extensions that claim one name, a tool's, an alias or their own, are an error at start.

func WithName

func WithName(name, version string) Option

WithName names the program, for the usage line, the banner, the resume command, the client header and the session header, and for the agent in the system prompt. An empty version is the main module's, as `go install` stamps it, or devel for a build that has none; never dax's, which would put dax's version under another name. The config files and the session store are still dax's: ~/.config/dax, .dax and ~/.dax.

func WithoutExtension

func WithoutExtension(name string) Option

WithoutExtension leaves out the dax extension called name (dax-coding, dax-agents, dax-skills or dax-memory), whatever the settings say. A name that is none of them is an error at start.

Directories

Path Synopsis
Package agent assembles one dax session with agentkit: the model, the prompt frame, the AGENTS.md chain, the confirmation policy, MCP servers and compaction, recorded into an agentsession content-addressed store (RFC 0002), and the extensions that offer everything else (Options.Extensions; see package extension).
Package agent assembles one dax session with agentkit: the model, the prompt frame, the AGENTS.md chain, the confirmation policy, MCP servers and compaction, recorded into an agentsession content-addressed store (RFC 0002), and the extensions that offer everything else (Options.Extensions; see package extension).
cmd
dax command
Command dax is a coding agent: a terminal client, a REPL, or one prompt with -p, over an Open Responses model, with read, write, edit, glob, grep, ls and bash tools under a policy, recording every session.
Command dax is a coding agent: a terminal client, a REPL, or one prompt with -p, over an Open Responses model, with read, write, edit, glob, grep, ls and bash tools under a policy, recording every session.
ext
agents
Package agents is dax-agents, the extension that offers the main agent two sub-agents as tools: explore, a read-only investigator, and task, a coding agent of its own for a piece of work.
Package agents is dax-agents, the extension that offers the main agent two sub-agents as tools: explore, a read-only investigator, and task, a coding agent of its own for a piece of work.
coding
Package coding is dax-coding, the extension that makes dax a coding agent: the read, write, edit, glob, grep, ls and bash tools, the rules dax ships for them, the matchers and aliases those rules are written with, the stamp that holds an auto-allowed bash call to the plan the policy approved, the prompt's guidance on using them, and their renderers in the terminal client.
Package coding is dax-coding, the extension that makes dax a coding agent: the read, write, edit, glob, grep, ls and bash tools, the rules dax ships for them, the matchers and aliases those rules are written with, the stamp that holds an auto-allowed bash call to the plan the policy approved, the prompt's guidance on using them, and their renderers in the terminal client.
memory
Package memory is dax-memory, the extension that gives the model a memory between sessions: a store with the user's scope and one for each project directory, the memory tools, and the memory block in the system prompt.
Package memory is dax-memory, the extension that gives the model a memory between sessions: a store with the user's scope and one for each project directory, the memory tools, and the memory block in the system prompt.
skills
Package skills is dax-skills, the extension that offers Agent Skills: the project's .dax/skills, the user's ~/.dax/skills and the config's skills_dirs, through the skill tool, with the catalogue in the system prompt.
Package skills is dax-skills, the extension that offers Agent Skills: the project's .dax/skills, the user's ~/.dax/skills and the config's skills_dirs, through the skill tool, with the catalogue in the system prompt.
Package extension is what dax is made of.
Package extension is what dax is made of.
facts
factspolicy
Package factspolicy is the policy's side of agenttool's facts claim (agenttool.Factual): the subjects agentpolicy decides a call on, taken from what the tool says the call would touch, and the rewrite a tool asks for, applied as a hook folded under the policy's verdict.
Package factspolicy is the policy's side of agenttool's facts claim (agenttool.Factual): the subjects agentpolicy decides a call on, taken from what the tool says the call would touch, and the rewrite a tool asks for, applied as a hook folded under the policy's verdict.
internal
cmdline
Package cmdline splits a command line into a program and its arguments the way a POSIX shell splits words, and does nothing else a shell does.
Package cmdline splits a command line into a program and its arguments the way a POSIX shell splits words, and does nothing else a shell does.
config
Package config reads dax's settings.
Package config reads dax's settings.
executor
Package executor is the session's execution plane for the extensions' tools: what runs a call, says what a call would touch, and says whether one may run again.
Package executor is the session's execution plane for the extensions' tools: what runs a call, says what a call would touch, and says whether one may run again.
modelinfo
Package modelinfo asks a provider what a model supports, ahead of the first request, and fits the reasoning effort a configuration asks for to it.
Package modelinfo asks a provider what a model supports, ahead of the first request, and fits the reasoning effort a configuration asks for to it.
private
Package private keeps dax's own directories private and holds the writer dax's warnings go to, which a front about to take the screen swaps for a buffer (agent.CaptureWarnings).
Package private keeps dax's own directories private and holds the writer dax's warnings go to, which a front about to take the screen swaps for a buffer (agent.CaptureWarnings).
prompt
Package prompt builds the session's part of the system prompt: the role line, the extensions' instructions and the user's, and the working directory.
Package prompt builds the session's part of the system prompt: the role line, the extensions' instructions and the user's, and the working directory.
provider
Package provider turns the provider setting into a model: the openresponses.Streamer the agent loop calls.
Package provider turns the provider setting into a model: the openresponses.Streamer the agent loop calls.
render
Package render is the print front: an agentturn subscriber that streams a run to a writer as it happens.
Package render is the print front: an agentturn subscriber that streams a run to a writer as it happens.
Package policy merges the agentpolicy rules a dax session runs under: the rules each extension ships for its own tools, each under a source of its own, the user's config, and the project's, which is not trusted.
Package policy merges the agentpolicy rules a dax session runs under: the rules each extension ships for its own tools, each under a source of its own, the user's config, and the project's, which is not trusted.
Package tool holds dax-coding's tools: read, write, edit, glob, grep, ls and bash.
Package tool holds dax-coding's tools: read, write, edit, glob, grep, ls and bash.
Package toolrender draws dax's tool calls in the terminal client: a toolview.Renderer for each of dax-coding's read, write, edit, glob, grep, ls and bash (Renderers), and for dax-agents' task and explore (SubAgents).
Package toolrender draws dax's tool calls in the terminal client: a toolview.Renderer for each of dax-coding's read, write, edit, glob, grep, ls and bash (Renderers), and for dax-agents' task and explore (SubAgents).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL