A very lightweight, minimal CLI coding agent interface written in Go.
The project is deliberately opinionated. It trims the UI to maximize working space and makes firm choices about things like sub-agents and tool barriers. Those opinions are an ongoing exploration of CLI agent ergonomics, so expect them to shift as the project evolves.
Feedback is welcome. Please open issues if you have any suggestions or comments.
go install github.com/jentfoo/ajent@latestFor those that would prefer to test out ajent rather than read the docs, you can onboard quickly:
- Assuming go and your
~/go/binPATHare setup:go install github.com/jentfoo/ajent@latest - Then run
ajent. On first start it walks you through wiring up your first provider (alternatively refer to the configuration below to define~/.ajent/models.json). - Try your first prompt to learn about features and how to use
ajent:/help Provide a concise overview of the user facing features and key combinations I should be aware of as a new user.
If you need help, just ask in a session:
- Bare
/helplists commands and keybindings. - Ask with
/help how do I mark tools as read-onlyfor answers from ajent's own documentation.
You may like ajent if:
- You care about copy and paste out of your terminal more than a pretty TUI.
- You like permission choices that are basically "read anything", "write anything" or "do anything".
- You use open models, which benefit from minimized context usage, flexible tool handling, and a variety of providers.
One of the hardest things working with coding agents is needing to copy content out of the terminal. The output is either damaged, or padded and suffixed with spaces.
Our agent simplifies the TUI in order to provide a terminal native output. Everything above the prompt bar is permanent and will never be changed. It is only the lower section of the agent window which is dynamic, resizing for prompts and showing the activity of sub-agents or other tool calls.
Copying things out is easy: /copy puts the last agent reply on your clipboard, and ctrl+x copies whatever message you're highlighting in the rewind picker. It works with the copy tool built into most operating systems, and over SSH or Mosh it uses a standard terminal trick instead. If nothing's available, ajent tells you exactly what to install.
Tab support is available for file path completions, but our TUI favors a minimal form that is more conducive to power users. No notice of files available until you hit tab twice failing to complete a path.
Our CLI agent attempts to balance autonomy and safety. This is done through a variety of permission modes:
- allow-read (default) - Will automatically allow any read only tools or MCP tools which are marked as read only in their configuration. Any write or questionable bash operations will require user approval first.
- auto - Still attempts to provide a read only experience by default, but will delegate to the agent to make decisions on bash commands and MCP/extension tool calls which are not automatically allowed.
- auto+write - Auto-approves
write,editand bash operations that stay safely inside the working directory or temp directory. Anything touching files elsewhere, system changes, bulk deletion or network access still requires approval. - allow-all - All operations allowed without human approval.
- block-all - Nothing runs without explicit approval, reads included.
Shift+Tab cycles the live mode for the session only (Shift+→ does the same, Shift+← steps back), so you can reach an adjacent mode without wrapping all the way around.
When a write operation does need approval, you're presented with a dialog that lets you steer, allow once, or allow for the session (or until the barrier mode is changed).
Sub-agents only function in a read only form. They exist only to keep the main context free concise, offloading the exploratory research to come back with a targeted summary for the main context history. They are enabled by default, and also gain access to any readOnly MCP services configured.
AGENTS.md in the working directory (and ~/.ajent/AGENTS.md for habits you want everywhere) is read at startup and injected into every turn's system prompt. Editing it applies on the next start, or at a /reload between turns.
/init writes that file for you. It reads the project's README* itself, then fans the build and the codebase out to read-only sub-agents in parallel. One surveying job covers the Makefile, CI config and CONTRIBUTING.md, and one to four split the tree between them, scaled to its size and balanced by where the code actually is. Their summaries come back as ordinary tool results, and a single turn distills them into AGENTS.md. The write goes through the normal permission barrier, so you see the diff and approve it like any other. Run /init again later and it corrects the existing file against a fresh survey rather than replacing it. Esc cancels a survey in progress.
MCP and other tools are loaded on the first message (using /tools). Once a tool is loaded it can't be unloaded, however we do allow adding tools later at the cost of a cache miss.
On a vision-capable model you can bring pictures into the conversation three ways: paste one with Ctrl+V (a [image #N] token is inserted into your prompt), reference one with @file.png, or have the model read one with the read tool. Oversized images are downscaled to at most 2000×2000 pixels and ~4.5 MB, with a note telling the model how to map coordinates back to the original. BMP, TIFF and other foreign formats are converted to PNG automatically.
Images render in the terminal when a supported protocol is detected (kitty graphics on kitty, Ghostty or Konsole, or iTerm2 inline on iTerm2, WezTerm and mintty). Everywhere else, multiplexers like tmux and screen included, an honest placeholder naming the file, dimensions and size is shown instead. ui.images in config.json can force a protocol or turn the feature off.
Providers, and the models they serve, are configured in ~/.ajent/models.json, not in config.json. A provider is one endpoint speaking one wire dialect. The two dialects are anthropic-messages and the OpenAI family (openai-responses or openai-completions). A gateway serving both at once is simply two providers.
Per provider you can set:
baseUrl- the endpoint. Omitted means the flavor's own default, so a bare"anthropic"or"zai"entry needs nothing else.flavor- selects discovery and quirk defaults. The built-in flavors areanthropic,openai,zai,zai-coding-cn,openrouter,deepseek,together,xai,groq,mistral,moonshotai,moonshotai-cn,kimi,google,cerebras,nvidia,huggingface,minimax,minimax-cn,baseten,ant-ling,fireworks,qwen-token-plan,qwen-token-plan-cn,xiaomi,xiaomi-token-plan-cn,xiaomi-token-plan-sgp,xiaomi-token-plan-ams,lmstudio,llamacppandgeneric. Defaults to the provider key, so an OpenAI-compatible proxy in front of a known server can say{"flavor": "lmstudio"}and still get the right behavior.apiKeyEnv/apiKey- where to read the secret. Resolution order is the configured env var, then a literal key, then the dialect's conventional variable (ANTHROPIC_API_KEY,OPENAI_API_KEY, etc.).headers- extra request headerstimeouts- connect / TLS / header / idle / total bounds as Go durations, and an explicit"0s"disables a bound. Split these because one client timeout would clamp the body read, which is exactly what must be allowed to take minutes.retry- attempts and backoff (base,max,jitter), honoringRetry-Afterbut capping it at 60s.discover- whether this provider's models are fetched from the server. On by default for every flavor with a known model list (anthropic, openai, zai, openrouter, deepseek, together, xai, groq, mistral, moonshotai, google, cerebras, nvidia, huggingface, baseten, lm-studio, llama.cpp and the token-plan flavors), and any other OpenAI-compatible endpoint can opt in with"discover": true, which asks its/v1/models.models- your declared list for this provider. It is always visible and discovery fills gaps in it, but never adds to it unless you setdiscoverAdd.discoverAdd- lets a declared list also surface entries the server returned:"loaded"adds only what the endpoint reports resident right now (llama.cpp's/props, LM Studio's load state, a router's status), and"all"adds everything it listed. Useful for lazy-loading proxies like llama-swap, where an entry that is not loaded yet still serves a turn.
Models come from exactly two places, both under the provider they belong to:
- hand-written declarations - your per-provider
modelslist. - provider discovery - asking the provider for its model list. It fills any gaps in what you declared, and with
discoverAddcan also add entries you did not declare. A llama.cpp multi-model router (which reports nothing useful on/props) falls back to its OpenAI-compatible/v1/models, and any other server speaking chat-completions can be discovered the same way with"discover": true.
The minimal entry (if not using discovery) is just an id. Everything else has a sane default, so you typically only add one to pin something discovery got wrong (a name or context window).
{
"providers": {
"lmstudio": {
"models": [
{
"id": "deepseek-v4-flash-0731",
"name": "DeepSeek V4 Flash",
"reasoning": true,
"input": ["text"],
"contextWindow": 400000,
"maxTokens": 20000
}
]
}
}
}Per model you can set:
id(required) - the identifier, also how overrides address it.name/aliases- display name and extra names the registry resolves.reasoning- a boolean:trueenables reasoning with the model's resolved thinking format. There is no style-name form.input- accepted modalities (text,image), defaulting to text plus image when capabilities allow it.contextWindow/maxTokens- input window and output cap in tokens.compat- the capability overrides for this model: thinking format, tokenizer, cache-control encoding, parallel tool support, temperature, images, and so on. Every field is a pointer internally, so an override turns one quirk on without restating the others.thinkingLevelMap/thinkingBudgets- how our seven reasoning levels (off,minimal,low,medium,high,xhigh,max) map to this provider's effort values and token budgets, and anullentry omits the parameter for that level.samplingParams/headers/api/baseUrl- opaque additions folded into the request body, per-request headers, or a dialect/endpoint override so one gateway can serve two dialects.
Reasoning retention is configured globally rather than per model (reasoning.retain, below) and applied only to what is sent, so the transcript always keeps everything.
The JSON loader is deliberately lenient: // comments and trailing commas are accepted, duplicate keys warn instead of silently keeping the last one, and an unrecognized key or reasoning level warns rather than locking you out. An unrecognized api disables its provider (a wrong dialect is a wrong protocol), and an unknown flavor just degrades to generic.
The fastest start is no declaration at all: llama.cpp discovers whatever model you have loaded, so pointing it at a server serving qwen3-27b needs only the endpoint:
{
"providers": {
"llamacpp": { "baseUrl": "http://127.0.0.1:8080" }
}
}The host defaults to localhost:8080, and discovery fills in the model's name and context window from what the server reports. Declare an entry only when you want to pin a window or override something discovery got wrong:
{
"providers": {
"llamacpp": {
"baseUrl": "http://127.0.0.1:8080",
"models": [
{ "id": "qwen3.8-27b", "contextWindow": 128000 }
]
}
}
}This file holds everything else: which model starts a session, how the agent behaves, what the barrier allows, and how the UI looks. It is one of several layers resolved lowest-to-highest (default → ~/.ajent/config.json (user) → <workspace>/.ajent/config.json (project) → AJENT_* env vars → command-line flags).
Any scalar key at dotted path p.q.r binds to the environment variable AJENT_P_Q_R, so permissions.mode is AJENT_PERMISSIONS_MODE.
{
"model": "anthropic/claude-opus-4",
"reasoning": { "level": "high", "retain": "wholeTurn" },
"agent": { "maxSteps": 40, "turnRetries": 3 },
"permissions": {
"mode": "auto",
"safeCommands": ["git status", "npm test"],
"deniedCommands": ["rm -rf"]
},
"compaction": { "auto": true, "threshold": 0.8, "minSteps": 2, "verbatimFraction": 0.1 },
"subagent": { "maxConcurrent": 4 },
"control": { "enabled": true },
"ui": { "render": "auto", "theme": "dark-warm" },
"disableUpdateCheck": true
}The top-level blocks:
model- the model a fresh session starts with. A/modelchange writes your most recent choice here, so it is remembered across restarts.reasoning- the default reasoning level (level, one of the seven), how much thinking to retain when sending history (retain:none,lastTurn,wholeTurn,all), an optional tokenbudget, and whether reasoning is hidden from view (hide). Shown by default, so sethide: trueto stop streaming it. Leaveretainunset to take the model's default:wholeTurnon priced providers,allon local ones (LM Studio, llama.cpp) so their cache keeps serving.agent.maxSteps- an optional cap on one turn's tool-calling iterations, where absent or zero means unlimited.agent.turnRetries- how many times a failed model call is re-requested before the turn fails (default 4). Covers dropped connections, truncated streams and overloaded providers, with backoff, and permanent errors (bad request, no credentials) never retry.agent.systemPrompt- replaces ajent's default prose guidance in the system prompt (the opening sentence and the two guideline bullets) when set. Environment facts, project instructions (AGENTS.md) and extension snippets still follow, since tools depend on them, and a trailing newline is trimmed. The CLI flag--system <text>sets it for one run.tools.enabledandtools.limits- which built-ins start enabled (defaults are justread,write,edit,bash) and per-tool output bounds (lines/bytesfor bash, read, find, grep, ls, git, other, refInject, refTotal, withgitcovering all fourgit_*readers).tools.shellCommands- extra shell commands thebashtool description may advertise, alongside a built-in set (ls,grep,find,diff,wc). A name is advertised only if it exists on PATH and is not refused bypermissions.deniedCommands.tools.diffEcho- when thewrite/edittool responses echo a unified diff of the change:never,validate(default),overwrite(write's displaced-content diff only) oralways.AJENT_TOOLS_DIFFECHO=neversets it for a single run.permissions.mode- the interactive barrier mode:allow-read(default),auto,auto+write,allow-all, orblock-all. See Tool Barriers above, since Shift+Tab or Shift+←/→ cycles it for the session only.permissions.headlessMode- the one-shot (-por piped) barrier mode,auto+writewhen unset. Kept separate frommodebecause unattended sessions gate so differently.permissions.safeCommands/deniedCommands- extra auto-allow and hard-deny rules. Each entry is an exact tool name, a whole MCP server namespace, or a bash command line matched at token boundaries (sogit statuscovers its subcommands, and wrapping incd … &&never defeats either list).compaction.auto/threshold- whether automatic context reduction is on, and the fraction of the window (or an absolute token count) at which it fires, default 0.8.auto: falsestops the automatic trigger only:/compactstill works, and an overflow still recovers. A model that sets its owncompactThresholdinmodels.jsonkeeps it, andthresholdis the default for the ones that do not.compaction.minSteps/verbatimFraction- how much recent work a compaction keeps byte-exact. A step is one assistant message plus the tool results it produced. At leastminStepsof them survive whatever they weigh (default 2), extended with older steps while the kept region stays withinverbatimFractionof the compaction point (default 0.1). Nothing in that region is ever stubbed, elided or thinning-stripped, and everything older is replaced by a single structured summary. Every trigger keeps the same band.subagent.model/maxConcurrent/maxPollWait/maxSteps- subagent specific configuration allows you to set a different model, as well as control concurrency and expected speed for running on local hardware.ui.images- terminal image protocol:auto(default),kitty,iterm2ornone.autodetects fromTERM,TERM_PROGRAMand friends, and reports none inside multiplexers, while an explicit value forces the capability the same wayui.colorcan.AJENT_UI_IMAGES=kittysets it for a single run.images.block- when true, no image block reaches the model: pastes,@file.pngreferences and tool images all become anImage reading is disabled.text note. Off by default, and/settingscan flip it for the session, whileAJENT_IMAGES_BLOCK=truesets it for a single run.control.enabled- turn on the local control socket (see Control socket below). Off by default, restart to change it.ui.render,ui.theme- paint mode (auto,inline,alt,plain) and palette.ui.color- color depth:auto(default),none,basic,256ortrue.autoreadsTERMandCOLORTERM. Any other value names the depth outright. That is the way out if your terminal is classified badly, since256turns on syntax highlighting where detection was too conservative andnoneturns color off entirely.AJENT_UI_COLOR=nonesets it for a single run.ui.keymap- which keys submit the prompt and which insert a newline. Defaults tosubmit: ["enter"]andnewline: ["ctrl+j", "alt+enter"]. To make Enter insert a newline and Alt+Enter send, setkeymap: { "submit": ["alt+enter"], "newline": ["enter"] }. An invalid keymap warns and falls back to the defaults at startup.disableUpdateCheck- turn off the startup update-available notice. Off by default, and a fork install or an offline machine that does not want to nag can set this once in the user layer.
The CLI is deliberately small. Run ajent --help for the full list, and the important ones:
-v, --version print the build version and exit
-m, --model <key> initial model to use
--render <mode> paint mode: auto, inline (terminal scrollback),
alt (own scrollback), plain
--continue resume the most recent session automatically
--session <name> create a session under this name; if that
name already exists, resume it instead
--resume [id|name] list saved sessions and pick one; with an id or
name, resume that session directly
--delete <id|name> delete that saved session from disk, then exit
--delete-old [days] delete every unnamed session unused for 28 days,
or the given number of days, after confirmation
--update reinstall ajent from @latest in the foreground, then exit
--allow-all barrier mode set to allow-all
--read-only start in auto read-only mode
--allow-auto start in auto mode: unverifiable calls are model-
classified, and a one-shot takes the verdict as final
--allow-autowrite like --allow-auto plus workspace-confined writes run
without approval
--block-autodenied deny what the model classifier does not approve instead
of opening an approval dialog; one-shot runs are
unaffected (already unattended)
-p, --prompt <text> run one turn non-interactively, print the result and exit
-o, --output <shape> one-shot output: text (final answer) or json (one event per line)
--system <text> replace ajent's default prose guidance in the system prompt
--allow-tools one-shot: tool names or bash command heads to allow
for the session
--deny-tools one-shot: tool names to withhold
--no-tools one-shot: withhold the whole tool set, built-ins and
MCP alike
--stats one-shot: print a tool and token summary when the run ends
--serve run with no terminal, serving the session over the
control socket
--attach [name|id|socket]
mirror a running session on this terminal and
interact with it from either process
--force with --delete: remove the session even while another
ajent instance holds it live; with --attach: take
the control seat from its holder
Sessions are normally identified by an opaque id, and --resume takes the full id or any unique prefix of it. --session <name> adds a human-readable name instead: the first run creates that session and every later one resumes it, so --resume <name> reaches it too. /session <name> names or renames the running session, and /session alone reports its name. When a named session ends, the exit hint prints ajent --resume <name> rather than its id.
--delete <id|name> removes one saved session from disk, resolving its target exactly as --resume does. A session another ajent instance still holds live refuses the delete, naming the count; --delete --force removes it anyway. --delete-old sweeps the tail: it deletes every unnamed session in the workspace that has not been used in over 28 days (--delete-old 7 for a different window), listing what it will remove and asking to confirm first. A name marks a session as worth keeping, so --delete-old never touches one, and sessions a running instance holds are listed as skipped rather than deleted. Both flags act on the current workspace only, and both exit without starting a session.
The -p/--prompt flags turn the interactive agent into a scriptable one shot. Piping stdin or stdout takes the same path, so echo "question" | ajent runs one turn and exits, the pipe standing in for --prompt. Bare --resume still wants its interactive picker, so a piped run refuses it (exit code 1) instead of silently starting a fresh transcript. Scripts should name the session (--resume <id|name>) or use --continue. There is no dialog in headless mode, so without a scope flag the barrier runs at auto+write (permissions.headlessMode overrides, and a scope flag overrides both): workspace-confined writes run free, and anything else the static analysis cannot verify is model-classified, its verdict final. Scope flags (--allow-all, --read-only, --allow-auto, --allow-autowrite, --no-tools) are mutually exclusive. --allow-tools pre-grants an "allow for session": a tool name (--allow-tools write, writers included), bash for any nameable shell call, or a bare word for bash commands with that head (--allow-tools python3). A nameable bash call no grant covers is still model-reviewed under the auto scopes, and a line static analysis cannot parse (redirect, substitution) refuses outright. --deny-tools withholds tools. --no-tools withholds the whole set, built-ins and MCP alike, so a scripted helper can guarantee the model answers from its prompt alone (--read-only is not that guarantee, it still offers every verifiably read-only tool). A refusal in a one-shot reads permission not given, so a script never prompts and never hangs on a dialog.
Setting "control": { "enabled": true } exposes the running session on a local
unix socket, ~/.ajent/control/<session-id>/<token>.sock, so another program can watch
it live: streaming output, tool activity, notices and every dialog the session
opens are announced as it happens, and the transcript can be read back to
reconcile. A controller that claims the control seat (attach) can also drive
the session with the same authority as the keyboard: submitting prompts,
commands and shell lines, answering dialogs (approval prompts included),
interrupting and ending it. Only one connection holds the seat; everyone else
is a read-only observer. It is off by default and same-user local only
(the socket and its directory are locked to your account), so enabling it
shares nothing beyond your own account. Each serving instance owns its own
socket and registry file under the session's directory, so two ajent processes
can serve one session side by side without fighting, and a killed instance's
files are collected the next time an ajent instance starts.
ajent --serve runs the same session with no terminal at all: the socket is
the only front end, prompts and dialogs hold until a controller answers them,
and the run stays up until it is ended. Serve implies control whatever
control.enabled says, since without a socket it would have no prompt source.
A plain one-shot (-p, piped stdio) never opens a socket: it exits with its
one turn, so there is nothing to attach to.
ajent --attach [name|id|socket] turns a second ajent process into that controller,
with a full terminal of its own: it backfills the conversation, then mirrors
streaming output, tool activity and status live, and every input works from
either side — prompts, slash commands and !shell submit remotely, Esc and
ctrl+c interrupt, Shift+Tab cycles the permission mode, double-Esc rewinds,
and dialogs (approval prompts included) open in both terminals with the first
answer winning. Attach claims the control seat; if another controller holds it,
--force takes the seat from that holder, announced to every attached terminal,
and without it the second process attaches as a read-only viewer instead. Detaching is local
to the attached terminal: ctrl+c twice leaves the session running, and only
/exit (from either side) ends it. The target resolves like --resume does:
an exact name, a full id or a unique prefix. Bare --attach opens a picker of
live sessions; with no match, ajent says what is running. If the connection
drops while the session lives on, the attach terminal keeps its scrollback and
reconnects automatically, following a restarted instance when exactly one
remains.
You never need the socket to share a session between terminals: two ajent --continue (or --resume <name>) processes on one session work concurrently.
Their entries interleave in one transcript tree, each writer keeps its own
branch, --delete refuses while a peer is live, and resume picks up each
writer's branch after a crash. Attach is for mirroring one live run, not a
prerequisite for sharing.
{ "providers": { "lmstudio": { "baseUrl": "http://127.0.0.1:1234/v1", "api": "openai-completions", "timeouts": { "idle": "0s" } }, "anthropic": { "apiKeyEnv": "ANTHROPIC_API_KEY" } } }