Documentation

A light, AI-first terminal. Everything is a terminal command and every setting is a TOML file — there is no settings UI. This page is the complete reference.

Install & first launch

One command installs aiTerminal on macOS 12+ — it clones the source at the newest release tag, installs Rust if you don't have it, builds the app and puts aiTerminal.app in /Applications:

 curl -fsSL https://mourad-ghafiri.github.io/aiTerminal/install.sh | sh

The same command updates it later, to whatever the newest release is by then. Set AITERMINAL_REF to build something else — AITERMINAL_REF=v0.4.0 for an older release, AITERMINAL_REF=main for the tip. To uninstall, add -s -- remove (your settings in ~/.aiTerminal are kept unless you add --purge):

# update
curl -fsSL https://mourad-ghafiri.github.io/aiTerminal/install.sh | sh

# uninstall (add --purge to delete your settings too)
curl -fsSL https://mourad-ghafiri.github.io/aiTerminal/install.sh | sh -s -- remove

It builds for the Mac you are on — Apple Silicon or Intel — so there is no build to choose. Or do it by hand:

# zero external crates — this is the whole build
git clone https://github.com/mourad-ghafiri/aiTerminal.git
cd aiTerminal
cargo build --release
./target/release/aiTerminal

Prefer a real macOS app? One script bundles a self-contained aiTerminal.app (binary + the builtin/ data + icon):

./tools/bundle-macos.sh          # this Mac's architecture → dist/aiTerminal.app (+ a .zip)
open dist/aiTerminal.app         # run it — or install it:
cp -R dist/aiTerminal.app /Applications/

# the release set: dist/aiTerminal-macos-{arm64,x86_64,universal}.zip
./tools/bundle-macos.sh all      # needs: rustup target add aarch64-apple-darwin x86_64-apple-darwin

First launch seeds ~/.aiTerminal/ with the default config, 19 themes, 30 plugins, keymaps, agents, flows and skills — every one an editable file. Nothing is ever overwritten after that: your edits win.

Enable AI

AI is off until you declare a model — no vendor is assumed. Add one [[ai.model]] to ~/.aiTerminal/config.toml:

[[ai.model]]
provider = "anthropic"       # or openai · openrouter · deepseek · groq · grok
id       = "claude-opus-4-8"  #    qwen · kimi · minimax · ollama · lmstudio · local
api_key  = "sk-…"             # or "$MY_VAR", or omit → $ANTHROPIC_API_KEY

Declare several models and they form a weighted pool — every request draws one from it. aiTerminal never scans your machine for keys: only your config or an environment variable supplies them. See Models & providers for pools, strategies and per-model tuning.

The ~/.aiTerminal folder

~/.aiTerminal/
├── config.toml        # the one config file (+ per-profile overlays)
├── profiles/          # <id>/{profile.toml, config.toml, workspace.toml}
├── themes/            # 19 builtin + yours
├── keymaps/           # keybinding layers
├── plugins/           # user-installed plugins (builtins ship with the app)
├── i18n/              # locale overrides
├── ai/
│   ├── aiTerminal.md  # the global AI instructions — edit the persona
│   ├── agents/  skills/  prompts/  flows/  mcp/
│   ├── models/        # provider catalogs (12 ship)
│   ├── memory/        # global BM25-ranked markdown memories
│   ├── jobs/          # <id>/{job.toml, runs/<n>.md}
│   └── sessions/      # <id>/ per-folder AI memory (digest + memory/)
├── logs/  cache/  shell/  crash.log

Without writing code

None of this needs a repo. If you never open a source file, most of it is still for you — and the table says exactly what each one needs, so nothing here fails on your machine for a reason you could not have known.

You want toTypeNeeds
understand a long PDF, or a photo of a page@ai summarise @~/Downloads/lease.pdfa model whose entry declares enable_document (images: enable_vision) — otherwise the file is dropped
research a decision, with real sources@flow research "what changed in the tokio 1.42 release"[ai] network = true. Search is keyless — no extra account
tidy or rename a pile of files@ai move every screenshot into folders by montha model. @ai proposes the command and preloads it for you to read
read a long web page@researcher "what does this say — <url>"[ai] network = true
draft something, saved to a file@writer "turn @notes.md into a brief at brief.md"a model
hold writing under a word count@loop "cut intro.md under 200 words" --agent writer --check "test $(wc -w < intro.md) -lt 200"a model, and a command whose exit status decides
a weekly digest, in plain English@job "every Monday at 9, summarise ~/Documents/inbox"a model to read the schedule once
write and preview at the same time@md edit letter.mdnothing — no model, no network
ask your terminal from your phone@gate telegram starta bot token
Two things before you copy a line off this page. @ai has no tools — it answers, or proposes one command for you to review. It cannot open a file, fetch a URL or read your clipboard. What it can see is an attachment: any @<path> naming a real file rides along with the question. To have something read a page or a folder, use an agent (@researcher, @explorer) — those have tools. And an attachment can be silently dropped: a PDF only reaches a model that declares enable_document; if yours does not, the question still goes, without the file.

These need no model at all: @md · @theme · @profile · @config · @plugin · @agent · @flow check · @flow graph · @job -- <command>.

@ai — a command to run, or an answer

 @ai list files
du -sh .                             # the proposed command
 press Enter to run (or edit)   # preloaded — edit or Enter

 @ai why is my docker build slow?
The most common causes are cache invalidation and copying node_modules…

@ai is dual-mode: it turns your request into one shell command — proposed at your prompt to review, edit, and run — or, for a question, a prose answer. It never runs anything itself. The contract is deliberately tiny and streamable: either the whole reply is one RUN: <command> line, or it is prose. Only the undecided first few characters are held back, so an answer starts rendering while it is still arriving — and a model that keeps talking after the command cannot get a second line onto your prompt.

@<agent> · @agent — the specialists

 @agent                     # everything you have
 @agent researcher          # one in full: tools, skills, what it returns
 @coder "fix the failing parser test"
✦ @coder · claude-opus-4-8
⠹ thinking…  ·  a cached prompt prefix costs about a tenth of a fresh one
  ⚙ fs.search   "parse_line" · 18ms · 6 results
  ⚙ fs.edit     src/parser.rs · 6ms · 1 replaced
  ⋯ sys.run     cargo test --workspace
The fix: the parser dropped the final line …
✓ 8.4s · 3 tools · 12.3k in / 1.8k out

One thing writes to that region. The answer repaints in place as it streams while the tool trace has to stay where it was printed, and those used to be two writers on two streams that knew nothing about each other — so the next repaint climbed back over lines it had never painted and ate them. Everything now goes through one sink that takes the live tail off, writes the line, and puts the tail back. @agent, @loop and a foreground @job all draw through it, so all three look alike; a tracked @job keeps a copy of the text in its log, and only the text.

A call still running after a moment says so (the row) and its line is replaced when it lands — a forty-second cargo test used to print nothing at all until it was over, which on screen is indistinguishable from a hang. And while you wait, one dim line keeps you company: see a wait says what it is for.

A tool line says what the call was acting on, not the argument JSON it arrived as — fs.read {"path":"crates/framework/src/cli/runn is the wire format cut at a fixed width, and it reliably takes the one part you were reading. A long subject is elided in the middle, because a path's last component is what you were looking for. What came back is read off the JSON's shape: an array is a count of results, a listing counts its entries, and multi-line output counts its lines. Nothing in that knows a tool by name, so a tool an MCP server exposes that this build has never heard of still gets a readable line.

An agent is a Markdown file with TOML frontmatter — system prompt, allowed tools, step budget, skills. @agent lists the eight that ship; @agent <name> shows one in full.

AgentRole
@plannerTurns a goal into a small plan with a concrete "done when" — reads, never writes.
@explorerFast read-only scout — maps relevant code, reports tightly.
@researcherFinds sources, reads them, and reports what they actually say — with links.
@coderSenior engineer + orchestrator — explores, makes the smallest correct edit, verifies, delegates sub-agents.
@testerFinds and runs the project's own tests; reproduces a failure, then fixes it.
@reviewerRead-only review — correctness, security, tests, design.
@writerDocumentation and reports for the person who will read them — and saves the file.
@aiGeneral assistant — proposes a command to review/run, or answers a question.

A turn may carry several tool calls — one @tool line each, up to eight. They run in the order written and every result comes back together, so four file reads cost one model round trip instead of four. A round trip is the expensive part: each one re-sends the whole transcript, which is longer than the last. The one rule is that a batch must be independent — a call whose arguments come from an earlier call's result belongs on the next turn. Other dialects are accepted for models that will not emit ours — including a bare <tool> {json} line with no marker at all, recognised only when the leading token is a tool this agent declared, so prose that mentions one is still prose — and each is read for every call it carries rather than the first.

A run that hits a bound still answers. When max_steps runs out — or the stuck-loop breaker fires — the loop spends one more turn with the tools withdrawn, asking for the best answer the transcript supports. The outcome is unchanged ( in the footer, exit 1), because the bound really did fire; what changes is that you get the findings rather than a sentence about a counter. This matters most inside @flow: a node whose agent ran out of steps used to fail, block everything downstream of it and end the run — after doing most of the work.

Every agent ends by stating what it returns. That is not a style rule: a flow node chains on that text, so {{explore.output}} is only as good as the agent's discipline. @tester and @reviewer go further and promise a final VERDICT: PASS / VERDICT: FAIL line — an agent that reports a failure has still finished its run successfully, so that line is how a workflow tells the difference.

Live chrome rides stderr, content rides stdout — so piping stays clean: @explorer "map the auth flow" > auth-map.md works.

A cheap model is a first-class target. An agent run sends its instructions in the request's system slot and the conversation as real role-tagged turns, because role separation is the strongest structural signal a model gets about who said what — and the weaker the model, the more of the work that signal does. A turn that botches the tool-call format is corrected and retried rather than accepted as the answer; three identical calls in a row stop the run; and the context is measured against the model's own window and compacted before that model would refuse a turn.

@flow — a workflow declared as a graph

 @flow                            # the installed flows
 @flow check build                # prove it runs — no model needed, nothing spent
 @flow graph review               # draw it, with what each node reaches
 @flow review "this branch"       # three reviewers, at the same time
 @flow build --bg "add a --json flag"
 @flow nodes last                 # every node of a run, side by side
 @flow resume last                # run only what did not finish

Five flows ship, and none of them names a build tool, a test command or a language — @tester finds the project's own runner, which is what makes them yours rather than somebody else's. research is the one that is not about code at all: it answers any question, needs [ai] network = true, and its search is keyless — there is no second account to create.

FlowWhat it does
buildplan → map the code and its conventions in parallel → implement → test → fix until green → review → summarise
fixreproduce the failure first → find its cause → patch → prove it is gone
reviewmap once, then three reviewers at the same time → one merged verdict
researchbreak the question into sub-questions → research each in parallel → compare → report, with sources
documentread the real code → write the file → check every claim against the source → revise until it holds

A flow is a TOML file of [[node]] entries — each one an agent run, a shell command, or a pause for you — and needs names what must finish first. That is the whole idea, and it buys four things a chain cannot do.

# ~/.aiTerminal/ai/flows/ship.toml — the whole vocabulary in one file
[[node]]
id     = "build"
agent  = "coder"
prompt = "Implement: {{input}}"

[[node]]
id    = "verify"
run   = "cargo test"          # a command node — zero tokens
needs = ["build"]

[[node]]
id     = "fix"
agent  = "coder"
needs  = ["verify"]
when   = "verify.failed"      # a conditional edge
prompt = "Fix:\n{{verify.output}}"
goto   = "verify"             # a bounded retry loop
max    = 3

Nothing runs until the graph is proved. @flow check costs nothing and needs no model: it refuses a dangling needs, a reference to a node that does not run first, a condition naming a node that does not exist, an agent that is not installed, and a command the guard would refuse. A node cannot read {{other.output}} unless other is ordered ahead of it — otherwise what it reads depends on which thread finished first, and that is a race, not a workflow.

The graph is a document, not just a picture

@flow graph builds a Markdown document — a heading, the diagram as a mermaid fence, and a table of the facts — and hands it to the renderer @md already uses. So inside aiTerminal the diagram is drawn by the same GPU renderer that draws every other diagram, and in a pipe it degrades to box art and a plain table. Neither is a special case; both fall out of it being ordinary Markdown.

A picture answers "what runs after what" and nothing else. The questions people arrive with are which agent is behind that box, what can it reach, and what is the condition on that arrow — so those are the columns.

 @flow graph review

review
────────────────────────────────────────────────────────────

Map the code, review it three ways in parallel, then merge into one verdict

5 nodes · 3 parallel · 20m · 4 at a time · needs an input


                             ┌───────────────┐
                             │ map @explorer │
                             └───────────────┘
                                     │
             ┌───────────────────────┴──┬───────────────────────┐
             ▼                          ▼                       ▼
 ┌───────────┴───────────┐   ┌──────────┴─────────┐   ┌─────────┴────────┐
 │ correctness @reviewer │   │ security @reviewer │   │ design @reviewer │
 └───────────────────────┘   └────────────────────┘   └──────────────────┘
             │                          │                       │
             └───────────────────────┬──┴───────────────────────┘
                                     ▼
                            ┌────────┴─────────┐
                            │ report @reviewer │
                            └──────────────────┘

╭─────────────┬───────────┬──────┬────────────────────╮
│ node        │ runs      │ when │ reaches            │
├─────────────┼───────────┼──────┼────────────────────┤
│ map         │ @explorer │ —    │ 7 tools · 1 skill  │
│ correctness │ @reviewer │ —    │ 7 tools · 5 skills │
│ security    │ @reviewer │ —    │ 7 tools · 5 skills │
│ design      │ @reviewer │ —    │ 7 tools · 5 skills │
│ report      │ @reviewer │ —    │ 7 tools · 5 skills │
╰─────────────┴───────────┴──────┴────────────────────╯

@flow show <id> prints the same document after a run, with each node's real state, model and cost written over it. A window too narrow to draw the diagram in gets the outline instead — never raw diagram syntax.

Every node is written down, so a run can be picked back up. Each result lands in ai/flow-runs/<id>/ the moment it happens, and @flow resume replays the finished nodes from disk — a six-node flow that died at node five costs one node to finish, not six. A failed node stops its own branch and nothing else, so the independent work is kept. An approve node asks on a terminal; detached, it parks the run as waiting instead of deadlocking a background job.

A goal on its own — the graph gets built

 @flow explain how the export command works
◈ no flow named — building a graph for this goal
◈ built a 3-node graph: read the code, then explain it · @flow show 1785371201-90257
▸ explain-how-the-export · explain how the export command works

You do not have to already have the right flow. Name none and one is written for this goal — by the model, out of the agents this machine actually has — and then held to every check a graph you wrote by hand is held to: it goes through the same parser and the same verifier, so an agent that does not exist, an edge that points nowhere and a command the guard refuses are all caught before a token is spent running it. When the checker refuses it, its own errors are handed back for one more attempt; a second failure prints them and stops, having spent two small calls and no agent runs.

The graph lives in the run's own record (ai/flow-runs/<id>/flow.toml), so @flow show, node, log, retry and resume all work on it and you can read exactly what was made for you — and @flow keeps listing the five flows you meant to have rather than filling up with one-off graphs. A build the checker refused is kept too: seeing what it tried to make of your sentence is the fastest way to understand what it misread.

The first word decides, against what you have installed:

You typedWhat happens
@flow document this projectdocument is a flow you have → it runs, with this project as its input
@flow revieew the parserclose enough to review to be a typo → refused, with the suggestion
@flow explain this projectneither → the whole line is a goal, and a graph is built for it

The typo guard is why the third row is safe. @flow revieew the parser must never quietly become a different flow — and it must not become a goal either, because building and running a graph for a misspelling is the same footgun in a newer coat.

Watching it run

A chain can narrate itself; a graph cannot. Four nodes start together and finish in whatever order they finish, so a stream of start/done lines hides the most useful thing about the run. So you watch it as the graph it is — and the layout is the graph, not the file: a rank is a column. What runs first is on the left, what runs at the same time stacks in one column, and the arrows only ever point the way the work moves.

▸ build · add a --json flag to the export command
  8 nodes · 6 agents · 69 tools · 24 skills · 4 at a time · slowest path plan→explore→apply→verify→review→summary
  ╭────────────────╮    ╭────────────────╮    ╭────────────────╮    ╭────────────────╮    ╭────────────────╮    ╭────────────────╮
   ✓ plan      ⚙3      ✓ explore  ⚙12      ⠼ apply  ⚙4 ×2      ○ verify            ○ fix               ○ summary      
   @planner       │───▸│ @explorer      │───▸ @coder         ───▸│ @tester        │───▸│ @coder         │╌╎ ▸│ @writer        
   4.2s · 3.1k       ││ 8.1s · 9.4k        ⚙ fs.edit src…                       ││ when verify.o…   ││                
  ╰────────────────╯   │╰────────────────╯   ╰────────────────╯   ╰────────────────╯   │╰────────────────╯  │╰────────────────╯
                                                                                                          
                       │╭────────────────╮                                             │╭────────────────╮  
                       ││ ✓ conventions                                               ││ ○ review         
                       ▸│ @explorer      │───┘                                          ▸│ @reviewer      │───┘
                         7.6s · 8.8k                                                    when verify.o…  
                        ╰────────────────╯                                               ╰────────────────╯ 
                                                                                                            
                                                                  ╎╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╎
 apply · @coder · claude-sonnet-5
    running · attempt 2 · 4 tool calls · needs explore, conventions
    ⚙ fs.read src/export.rs · 9ms · 8.2KB
    ⚙ fs.edit src/export.rs · 12ms · 1 replaced
    ⚙ sys.run cargo test · 2.1s · 48 lines
  3/8 done · 1 running · 21.3k tokens · 24.6s

The shape. explore and conventions share a column because they run at the same time; apply is to their right because it waits for both. Depth is horizontal position and nothing else — it used to be reading order, so two cards side by side meant only that they were declared next to each other. The ranking and the crossing reduction are the same layered-graph passes the diagram renderer uses, so @flow graph <name> and the live board agree about the shape.

What is not drawn. An edge the graph already implies is left off. If d needs a, b and c where b and c both need a, the direct a → d says nothing new, and drawing it puts three arrows into one card where one carries the meaning. The dependency is untouched — the scheduler still honours it; what is dropped is saying it twice. A goto travels in the band under the whole board and turns in the gaps, so a loop never runs through the cards it loops over.

The slowest path on the header line is the chain that decides the wall clock. On a graph that overlaps work that is not the slowest node — a slow node with three fast ones beside it costs nothing extra — and it cannot be read off the picture, so it is stated.

A run that goes wrong says so, on the card and in the tally. A settled failure's third line is why it stopped rather than what it cost — the cost of a failure is the least interesting thing about it. A node that could never run because something it needed failed is drawn ⊘ blocked, in amber, distinct from the · of a node its own condition ruled out; nothing is left reading "waiting" on a run that has finished. And the tally names every state, not only the two that are going well:

  0/5 done · 1 failed · 2 blocked · 2 skipped · 30.0k tokens · 11.5s

The footer under the board then names the first node that failed and the first line of what it said, because by the time you read a footer the board has usually scrolled.

Under the cards, a pane follows whichever node is working — and when something breaks, the one that broke: its agent, model, state, attempt, elapsed, cost, what it needs, and the last few tool calls it made. A card is three lines and has to hold a name; these are the questions asked of a run that is going wrong, which is the only time anybody watches a board closely. There is no selection because the board does not read your keyboard — for the whole story of one node, @flow node <id> and @flow log <id> are the commands.

An edge takes the colour of the node it leaves once that node has settled, so the path that actually ran lights up behind the board and stops exactly where the run did — in the theme's own green, red and amber, alongside the accent the running card pulses in.

Two views. [flow] view = "list" — or --view list for one command — puts every node back on a single dense row in file order. It is the shortest board that can exist, which is what a twenty-node flow in a six-line split wants; and the graph view hands over to it by itself when the cards will not fit the window, in either direction — depth costs width now, so a nine-deep flow asks for more columns than a terminal has. Off a terminal — --bg, a pipe, CI — neither view applies: the same state machine prints [node] event lines instead, nothing is overwritten, and the attribution a plain stream could never give is still there.

Your keyboard is left alone. While the board owns a region of the screen it asks the terminal to stop echoing, because the board repaints by climbing back over the block it drew and an echoed keystroke moves the cursor out from under it — which used to strand a copy of the board on screen for every Enter pressed during a run. Ctrl-C still works, and an approve node gets echo and the cursor back for as long as its question is on screen.

Node control

show is the whole run and log is one node's text. Between them sits the question people actually ask when an answer looks wrong — what is this node.

 @flow nodes last
 900-1 failed · flow 'build'
   plan         done      @planner · claude-sonnet-5 · 4.2s · 9000 tokens · 3 tool call(s)
   explore      done      @explorer · claude-sonnet-5 · 8.1s · 6900 tokens · 12 tool call(s)
   apply        failed    @coder · claude-opus-5 · ×2 · 12.3s · 4 tool call(s)
  ⊘ verify       blocked
  · review       skipped

  left to do apply, fix, summary
CommandWhat it does
@flow nodes [<id>]every node of a run, side by side: state, agent, model, attempts, time, tokens, tool calls, exit
@flow node [<id>] <node>one node in full — its edges and condition, then what it was asked and what it answered, rendered as the Markdown it is
@flow watch [<id>]attach to a run that is still going, from any pane — including a --bg run with no terminal of its own. No id attaches to the newest live run; Ctrl-C detaches and the run keeps going
@flow retry [<id>] <node>run one node again and everything built on it, printed before anything starts

Which model served a node is a fact the record has to keep: a pool that picks per run cannot be read backwards for it. And the cascade in retry is the point — re-running apply while verify keeps the answer it derived from the old one is not a retry, it is a record that contradicts itself, where a downstream {{apply.output}} names text that no longer exists. What came before is exactly what a resume keeps.

@loop — iterate until it verifies

 @loop "make the config tests pass"
🔁 loop 'coder' — up to 5 iteration(s)
  verifier: cargo test -p framework config:: — proposed from the goal
▶ iteration 1/5 … ▶ iteration 2/5 …
✓ goal reached after 2 iteration(s)

The verifier is the whole game. --check "<cmd>" is a binary stop condition — exit 0 = done, no judgement involved. Give none and the AI reads the goal once and proposes a real command, because the alternative — a model deciding for itself whether it is finished — is the single most common way agent loops fail. The proposal is printed before anything runs and the command guard still adjudicates it: a "verifier" that would deploy, push or install is a side effect, not a measurement, and is refused. Only if nothing verifiable turns up does an independent reviewer agent grade each iteration.

And it is proven before it costs anything. The check runs once before iteration 1: guard-refused or unrunnable → exit 2 with nothing spent; already exits 0 → the goal was already met, zero iterations; fails → that failure seeds iteration 1, so the maker starts on the real error instead of a guess.

FlagMeaning
--check "<cmd>"the verifier; killed if it hangs past [loop] check_timeout
--no-checkskip inference — grade with a reviewer agent
--max Niteration cap (default 5)
--budget TOKENStotal token budget across iterations
--timeout 30mwall clock for the whole run
--agent <name>the maker (default coder)
--bg · --dry-rundetach it · show the plan and the proven verifier, run nothing

Iterations, tokens and wall clock are three different ways to run away, so all three are bounded — and a flag value that can't be read is an error, never a silent default. No progress is detected: the loop remembers its last few verifier observations, so a run that repeats itself and one that oscillates between two bad states both count. The first time, the maker gets one more iteration, told what was already tried and asked for a materially different approach; the second time ends the run.

 @loop                              # recent runs: verifier · iterations · outcome
 @loop show last                    # goal, bounds, what was already tried
 @loop log 4310 -f                  # the newest iteration, live
 @loop resume 4310 --budget 200000  # carry on — with more rope

Every iteration is written to ai/loops/<id>/loop.toml (goal, verifier, bounds, progress) plus iterations/<n>.md. A run stopped by Ctrl+C, a timeout or the cap resumes from where it stopped, attempt log and all, rather than paying for the whole thing again. Exit codes: 0 reached · 1 a bound stopped it · 2 setup error · 130 interrupted.

@job — say what to do, and when

 @job "check the logs at midnight"
⧖ every day at 00:00 — check the logs · job 1753112100-4310
  fires in 7h · list: @job · cancel: @job cancel 1753112100-4310
 @job "run ./backup.sh every weekday at 6pm"               # a command job
 @job "remind me to stretch in 20 minutes"                 # one-shot
 @job "post the update every monday morning" --dry-run     # plan only
 @job --every 15m -- ./sync.sh                             # explicit: no model consulted
 @job                                                      # list
background jobs (3):
  ⧖ 1753112100-4310 scheduled check the logs   (fires in 7h)
      cron 0 0 * * * · 12 run(s) · last ok
  ▶ 1753112000-4242 running   audit the deps …  (2m ago · 2m)
   1753111800-4101 done      create a CHANGELOG …  (9m ago · 45s)
 @job show last  ·  @job log 4310 -f  ·  @job cancel 4310

Write the request the way you'd say it. The AI reads when out of the sentence once, at creation, and writes its answer into the record as a cron expression — so occurrence #47 of an hourly job costs nothing and behaves exactly like #1. The interpretation is printed before you accept it, and --dry-run shows it without scheduling anything. Explicit --every/--cron/--at/--in skip the model entirely, and everything after -- is a command job that needs no AI at all to run — checked by the same command guard as everything else, where "ask first" is a refusal because a detached job has nobody to ask.

It survives the machine: the sleeper that waits for a fire-time dies with a reboot, so on the next launch the supervisor re-arms anything still ahead and runs anything overdue once — an hourly job that missed six hours runs once, not six times. Each job is a folder — job.toml (what to run, when, and how the last run went) plus runs/<n>.md, one log per occurrence, pruned and size-capped so an hourly job can't fill the disk. Statuses are always honest: scheduled · running · done · failed · cancelled · died · missed — a crashed job's record heals on the next list, and @job clear prunes everything that is neither running nor scheduled.

A wait says what it is for

Most of what a command does is instant — a job's record, its arming, its spawn are all under a millisecond. What is not instant is a model call, and there are four made outside a run's own loop: the planner reading a @job request, the verifier proposal that opens a @loop, the graph built for a @flow goal, and a run folding its own history when the window fills. Every one of them used to happen with nothing on screen — @job "summarise the logs every morning" sat on a dead terminal until the model answered.

 @job summarise the logs every morning
⠹ reading when to run this…
⧖ every day at 09:00 — summarise the logs · job 1785371201-90257
  fires in 14h · list: @job · cancel: @job cancel 1785371201-90257

Not "thinking": these happen before a run exists, and why you are waiting is the useful part. The spinner also holds its first frame for a moment, so work that finishes at once draws nothing at all — @job -- echo hi and @job --every 15m … never consult a model and show nothing new.

@job also says what it understood — but only when that differs from what you typed. The planner does not just pick a schedule: it strips the timing words out of the task, and may turn a sentence into a shell command. That rewrite changes what the job is. An echo of your own sentence is noise, so there is none. And a planner that was asked and could not answer says so rather than falling silently back to the word parser — you waited for that call.

Something to read while you wait

⠹ thinking…  ·  @flow retry <node> re-runs it and everything built on it
⠹ thinking…  ·  "Simplicity is prerequisite for reliability" — Dijkstra

One dim line keeps you company. It costs no rows — it rides inside the spinner's own line, or one constant row under a flow board — it only appears while nothing else is happening (silent until a wait has lasted after, gone the instant the answer starts, so a run that answers in three seconds never shows one), and it cannot reach anything but a screen: a pipe, a --bg job, a job log and CI never see one.

The lines are written by the model, once, into cache/motivation.toml and reused — in the background, so no run ever waits for them. Tips are drawn from this tool's own command list rather than imagined, which is what stops a "tip" teaching you a flag that does not exist. With no model configured there is no cache and the feature is simply absent. [motivation] turns it off, picks which kinds to draw from, and sets both timings.

@md — read & live-edit Markdown

Needs nothing — no model, no network, no config.

CommandDoes
@md render <file.md>pretty-print it in the pane; a long file opens a scrollable pager
@md edit <file.md>a full-screen split — Markdown left, live preview right

Anything else prints the usage and exits 2.

@md render

This is the real output, at 72 columns — headings get a rule, tables are drawn, and the mermaid block becomes a diagram:

 @md render release.md
Release plan
────────────────────────────────────────────────────────────

  • cut the branch
  • run the suite

╭──────┬───────╮
│ step │ owner │
├──────┼───────┤
│ cut  │ ada   │
│ ship │ grace │
╰──────┴───────╯

 ┌───┐   ┌───┐   ┌───┐
 │ A │──▶┤ B │──▶┤ C │
 └───┘   └───┘   └───┘

GitHub-flavored Markdown plus the HTML subset (alerts, footnotes, <details>, centered blocks, HTML tables), syntax-highlighted code, images drawn as pixels, and every mermaid diagram type — flowchart, sequence, class, state, ER, gantt, pie, journey, mindmap, git graph and the rest. Inside aiTerminal a diagram is drawn as real pixels; piped or in another terminal it is the same picture in Unicode box art, which is what you see above.

A file taller than the pane opens a pager that starts at the top and reflows on resize:

  ↑↓/j k scroll · Space/b page · g/G top/bottom · ←→ pan · wheel · q quit

@md edit

A full-screen split: the raw Markdown with a gutter on the left, the rendered preview on the right, re-rendered as you type. The status bar carries the file, a when there are unsaved edits, and the line count; the bar at the foot is the whole keymap.

 @md edit release.md

 release.md ●  (10L)                                        saved release.md 
  1 │ # Release plan          │ Release plan
  2 │                         │ ────────────
  3 │ - cut the branch        │
  4 │ - run the suite         │  • cut the branch
  5 │                         │  • run the suite
  6 │ ```mermaid              │
  7 │ flowchart LR            │  ┌───┐   ┌───┐   ┌───┐
  8 │   A --> B --> C         │  │ A │──▶┤ B │──▶┤ C │
  9 │ ```                     │  └───┘   └───┘   └───┘
  ^S save   ^W focus:editor   ^Q quit    ·  scroll: ↑↓ ←→ · wheel   ·  shift+wheel = horizontal 
KeyDoes
Ctrl+Ssave — the status bar confirms saved <file>
Ctrl+Wmove focus between editor and preview (the bar shows which has it)
Ctrl+Q · Escquit — with unsaved edits it asks (y) save · (n) discard · (esc) cancel
· PageUp/PageDownscroll and pan the focused half
wheel · Shift+wheelscroll · scroll horizontally; click to focus a half and place the caret

aiTerminal reports SGR mouse events to whatever is running, so vim and less get the mouse inside aiTerminal too — not just @md.

@gate — drive your terminal from a chat app

Gates ship off. This is remote code execution over a chat app, so nothing listens until you turn it on, and an unpaired chat gets no reply at all — not even a hint that a bot is there.

Setting up Telegram, step by step

Five minutes, and four of them are Telegram's.

1 — Create a bot. Open Telegram and message @BotFather — it is Telegram's own bot for making bots. Send /newbot. It asks for a display name (anything, e.g. My Terminal), then a username that must be unique and end in bot (e.g. mourad_term_bot). It replies with a token — a run of digits, a colon, then a long random tail. Treat it as a password: anyone holding it can drive your bot.

2 — Put the token in your environment, not in the config file. Add this to your shell profile (~/.zshrc), so it is not sitting in a file you might share or commit:

 export TELEGRAM_BOT_TOKEN='<paste the token BotFather sent you>'

3 — Turn gates on in ~/.aiTerminal/config.toml. The "$VAR" form is read from the environment at start:

[gates]
enabled = true

[gates.telegram]
token = "$TELEGRAM_BOT_TOKEN"

4 — Start it in whichever tab or split you want to share:

 @gate telegram start
  ⬤ telegram gate live · @mourad_term_bot
  pair from the chat: /pair 418-207   (nothing runs until you do)

5 — Pair. Open a chat with your bot and send the six-digit code exactly as the pane printed it. Until you do, the gate ignores every message. After that, send git status and it runs.

One honest caveat. The token is passed to curl, so it is visible in the process list on your own machine. It never leaves your machine except to Telegram's API, and nothing else is sent — but if that matters where you work, know it before you start.

If something is missing, @gate says which piece and how to fix it — it will not start half-configured.

What it looks like

 @gate telegram start                                      # hand this pane to Telegram

  ⬤ telegram gate live · @mourad_term_bot
  pair from the chat: /pair 418-207   (nothing runs until you do)

 ls                                                        # you, still typing normally
README.md  crates  docs

  ▸ Mourad: cargo build                                     # arrived from the chat
 cargo build
   Compiling aiTerminal…
  ◂ sent 12 lines

@gate telegram start turns a tab or split into a shell you share. You keep typing locally while a paired chat drives the same shell — same working directory, same history, same running program. Every remote action prints a dim line in the pane first, so nothing anyone does from the chat is invisible.

In a paired chat a plain message is a command; the reply comes back as a code block with its exit status and duration. /shot sends a PNG of the live terminal, which is worth asking for when a heavily-styled TUI is on screen: the live text frame keeps the box drawing but loses the colours. If a command goes quiet waiting for a password, your next message is routed to its stdin. A long build sends progress notes and still reports its real exit status. And if you have a half-typed line at the prompt, a command from the chat waits its turn rather than being spliced into it.

From the chatDoes
<anything> · /run <cmd>run it in the shared shell (through the command guard)
/sh <cmd>run out-of-band — works even while a full-screen app owns the shell
/shota screenshot of the terminal as it looks right now
/key · /keys · /cancelpress a key, type text without Enter, or Ctrl-C the running command
/ai <prompt>ask this terminal's own AI, from the chat
/full · /status · /help · /stopuntruncated output as a file · what's running · the menu · end the gate

Driving a program — Claude Code, Codex, vim, a REPL

Start something interactive in the gated shell and the gate attaches to it. The chat becomes that program's screen: one message that keeps updating as it redraws, with buttons for whatever it is currently asking. Anything you type is typed into the program and submitted. When it exits, the gate detaches and you are back at the shell.

You ▸ claude
Bot ◂ ▶ attached to claude — it has taken over the terminal.

You ▸ add a --json flag to the export command
Bot ◂ ┌ claude ────────────────────────────┐
       │  Edit src/export.rs                │
       │  │ + #[arg(long)] json: bool       │
       │                                    │
       │  Do you want to make this edit?    │
       │  ❯ 1. Yes                          │
       │    2. Yes, and don't ask again     │
       │    3. No, tell Claude what to do   │
       └────────────────────────────────────┘
       [ 1 · Yes ] [ 2 · Yes, and… ] [ 3 · No, tell… ]
       [ ↑ ] [ ↓ ] [ ⏎ ] [ esc ] [ ^C ] [ 📷 ]

Tap 1. The same message updates in place — taps add nothing to the conversation, so the live screen stays at the bottom where you can see it.

Nothing here knows about Claude Code. The same thing works for Codex, opencode, aider, vim, htop, python3, psql — and whatever ships next month. The terminal protocol is the signal: a program that manages the screen says so in DEC private modes. The catch is that a shell's line editor arms two of them at every prompt (zle_bracketed_paste and smkx), so bracketed paste and application cursor keys mean nothing on their own — only the alternate screen and mouse reporting are shell-proof. For the ambiguous pair the shell integration already tells us the truth, since a command runs between the preexec and precmd marks: alt || mouse || ((bracketed || app_cursor) && command_running). At a prompt nothing is running; once claude starts, the same modes become decisive. A REPL sets no modes at all, so the fallback is the shape of a prompt — quiet output and the cursor parked after a prompt character, which is also what tells it from a download that stalled mid-line. Buttons follow the shape of a question, so an agent listing its plan is left alone.

Reading those modes is not only detection — it is also how input is encoded. Arrows go out as ESC O A when the program asked for application cursor keys and ESC [ A when it did not; many programs accept only the form they requested. A multi-line prompt is wrapped in ESC[200~ … ESC[201~ when the program enabled bracketed paste, so it arrives as one paste instead of the first line being submitted and the rest becoming follow-ups. That is what makes sending a real prompt from a phone work at all.

While attached, /run is refused rather than queued — there is no shell to run it in, and firing it minutes later when the program exits would be worse; use /sh <cmd> for an out-of-band shell. The command guard does not apply to program input: that is the honest description, and also the point — the program's own confirmations, which you now answer from your phone, are the control. Frames wait for the screen to settle and never redraw more than once every couple of seconds, so a program streaming a long answer cannot flood the chat. [gates] attach = false turns it all off.

This is remote code execution over a chat app. It ships off ([gates] enabled = false), and a bot accepts messages from anyone who learns its @name — so a chat id is an address, not a credential. The real control is pairing: a six-digit code printed in your terminal, which only someone looking at your screen can read. Until a chat pairs it gets no reply at all. Five wrong codes close pairing for the session, only one chat may be paired at a time, and every command still passes your guard, with your secret rules applied on the way out. Read docs/gate.md — including what it does not protect against — before enabling it.

@profile — identities that switch everything

Needs nothing. A profile is a named terminal identity: its own config overlay and its own saved tabs, splits, working directories and terminal content.

CommandDoes
@profilelist them — marks the active one
@profile <id>switch by id or name; a running window follows within a second
@profile currentprint the active id (one word — made for scripts)
@profile create "<name>" [emoji]create one and print its config-overlay path
@profile rename <id> "<new name>" [emoji]rename it
@profile delete <id>delete it
@profile edit [<id>]open its config overlay in $EDITOR; saving applies live
 @profile
profiles in ~/.aiTerminal/profiles (1):
   🚀 Default          (default)

switch:  @profile <id>   ·  settings:  @profile edit   (a running window follows live)

 @profile create "Work" 💼
created profile 'Work' (work) — switch with: aiTerminal profile switch work
its config overlay: ~/.aiTerminal/profiles/work/config.toml

 @profile
profiles in ~/.aiTerminal/profiles (2):
  ○ 🚀 Default          (default)
   💼 Work             (work)

switch:  @profile <id>   ·  settings:  @profile edit   (a running window follows live)

 @profile default
switched to profile 'default' — a running window applies it within a second

Every profile owns profiles/<id>/config.toml — an all-commented template where only the keys you uncomment override the global config. A different theme, a different AI pool, different keybindings per profile, all TOML. Its open tabs and splits persist to workspace.toml on quit and autosave, including each pane's working directory, zoom and terminal content with its colours — so switching back restores what you left, not an empty shell.

@theme — 19 themes, live

Needs nothing. Switching restyles the window, its panes and the running shells' colours within a second — there is no settings window in this product.

CommandDoes
@themelist all 19 — marks the active one
@theme <name>apply it to the active profile, live
@theme pathprint the themes directory
@theme export <name>print that theme's full normalized TOML, every token resolved
 @theme
themes in ~/.aiTerminal/themes (19):
  ○ alpine
  ○ coral
  ○ cosmic-orange
  …
   midnight
  …
  ○ sunset
  ○ titanium

switch:  @theme <name>   ·  export a reference:  @theme export <name>

 @theme sunset
theme 'sunset' applied to profile 'default' — a running window restyles within a second

 @theme export nebula
name = "Nebula"
dark = true

bg       = "#0B0E14"
surface  = "#161A23"
fg       = "#F2F4F8"
muted    = "#8A90A0"
accent   = "#D85BFF"
…

A theme is one TOML of semantic tokens. Themes are seeded into ~/.aiTerminal/themes/ on first launch and read from there, so dropping a file in adds a theme — @theme export gives you a complete, correct starting point rather than a blank page.

@config — the one file

Needs nothing. Everything is TOML and there is no settings UI: inspect here, edit the file, reload with , or restart.

CommandDoes
@configthe effective settings — after the profile overlay is applied
@config pathprint the config file's path (for $EDITOR "$(@config path)")
 @config
config: ~/.aiTerminal/config.toml
  theme       = sunset
  font_family = Menlo
  font_size   = 13
  zoom        = 1
  tab_bar     = top
  shell       = $SHELL
  scrollback  = 10000

edit the file, then reload in the app with Cmd-, (or restart)

What it prints is the effective value, so it is the fastest way to find out whether a profile overlay is overriding what you think it is. The full key reference is below.

@plugin — what is running, and what you add

Needs nothing. A plugin is a folder with a plugin.toml — declarative data, not code. 31 ship with the app and load from the bundle; the ones you add live in ~/.aiTerminal/plugins/. Both are listed, because both are running.

CommandDoes
@plugin · @plugin listevery plugin — bundled and installed
@plugin info <name>one in full: version, description, whether it is bundled or yours
@plugin install <path>copy a plugin folder into the plugins dir
@plugin enable <name> · disableturn one on or off — bundled ones included
@plugin remove <name>delete an installed plugin (bundled ones cannot be removed, only disabled)
 @plugin
plugins (30 bundled · 0 installed):
   ai-guard           1.0.0    Default rules for the AI guard: what may run, be touched, leave
   ai-terminal        1.0.0    @ai · @<agent> · @flow · @loop · @job · @md · @gate …
   alias-hints        1.0.0    Suggests the shortest alias for any command you type
   autosuggest        1.0.0    Inline suggestions from history as you type — → accepts
  …

bundled plugins live in the app; yours go in ~/.aiTerminal/plugins
one in full:  @plugin info <name>   ·  turn one off:  @plugin disable <name>

 @plugin info git
git  v1.2.0
Comprehensive git integration: status segment (staged + unstaged dots), 100+ aliases,
branch-aware helpers, abbreviations, completions
bundled with the app · enabled: true

Only trusted plugins — the bundled ones — may contribute exec providers or shell snippets, because a snippet is shell code your shell runs. An installed third-party plugin still supplies aliases, abbreviations, completions and status segments, but its exec providers are skipped. Disabling a plugin takes effect on the next shell.

Exit codes & scripting

Every AI command tells the shell the truth, so $?, && and CI compose:

CodeMeaning
0completed (for @loop: the goal verified)
1failed — model/transport error, step limit, tool stall; loop stalled/exhausted/out of budget
2setup error — unknown agent/flow, AI not configured, guard-blocked check
130interrupted — Ctrl+C cancelled cleanly (the in-flight request is killed mid-token)

Tabs, splits & the quick switcher

Profiles & session restore

A profile = a config.toml overlay (theme, AI pool, plugins, locale…) + a saved workspace (tabs, splits, focus, per-pane zoom and cwd, window size — and the styled pane content itself). Switching restores exactly the state you left, colors included, silently. Autosave: 5 s after a structure change, every 30 s for content (skipped when nothing changed).

Themes

19 ship: Midnight (default) · Graphite · Alpine · Nebula · Deep Purple · Lavender · Pink · Product RED · Sage · Gold · Mist Blue · Titanium · Coral · Cosmic Orange · Solar Flare · Sunset · Sky Blue · Starlight · Light Gold. A theme is one TOML of semantic tokens (bg surface fg muted accent success warn error), a 16-color ANSI palette and filetype colors that drive theme-matched ls output. Drop your own in ~/.aiTerminal/themes/@theme export gives you a starting point.

Keybindings

Layering, later wins: builtin default → plugin keybindings → your ~/.aiTerminal/keymaps/*.tomlconfig.toml [[keybinding]]:

[[keybinding]]
key = "cmd+shift+enter"
action = "zoom_pane"

Chords follow the keycap, not the position: a binding on ⌘⇧M matches whichever key types M on your layout. ⌘⇧←/→ are deliberately unbound at the window level — they reach the shell as select-to-line-edge, like every macOS text field. The full action list ships in the docs folder of the repo.

Closing things

Needs nothing. ⌘W, ⌘⇧W and ⌘Q can ask before they act — because ⌘Q sits beside ⌘W and takes every tab, split and running shell with it.

[behavior]
confirm_close_pane = false   # a split is cheap to reopen
confirm_close_tab  = true
confirm_quit       = true

The dialog takes keyboard and mouse: esc cancels, / (or Tab) move between the buttons, chooses the focused one, and either button is clickable. A click on the backdrop cancels; ⌘Q pressed again while it is open confirms.

Cancel holds focus when it opens. A hand that hit ⌘Q by accident hits next, and that has to keep the session rather than end it. Confirming is deliberate — , a click, or the chord again.

Closing the last split, or the last tab, ends the session — so it asks the quit question and obeys confirm_quit, whatever the other two say. It tells you what is at stake, counted from the live window rather than guessed:

  Quit aiTerminal?
  3 tabs · 5 splits will close

                          [ Cancel ]   [  Quit  ]
  esc cancel · ←→ move · ↵ choose
The red close button and the menu's Quit item are not covered. macOS tears the window down before the app is asked, so those two stay immediate. The chord is the one that gets hit by accident; a click on the red button is deliberate.

Languages (i18n)

English and French ship; select with [appearance] locale = "fr" — per profile if you like. Every chrome/CLI string flows through the catalog; add ~/.aiTerminal/i18n/<locale>.toml to translate or override anything. Missing keys fall back locale → en → key, and CI enforces en/fr parity.

Shell integration

Non-destructive, and your rc files always win: zsh rides a generated ZDOTDIR that sources your own .zshrc first; bash uses --rcfile. The master switch is [shell] integration = false. What rides in:

config.toml reference

SectionKeys
[appearance]theme · locale · font_family (default Menlo) · font_size (default 15) · cursor_style (block / bar / underline)
[behavior]zoom · tab_bar = top|bottom|left|right · shell · scrollback (default 10000) · confirm_close_pane (default false) · confirm_close_tab · confirm_quit (default true) — see Closing things
[ai]share_terminal_context · memory · show_reasoning (show thinking text; default off) · mode = manual|auto · network · budget (USD cost soft-cap) · context_window (tokens to budget against; 0 = the serving model's own) · compact_at (fraction of the window that triggers compaction; default 0.75) — then [ai.balance] strategy + the [[ai.model]] blocks, last
[[ai.model]]provider · id · api_key · weight · temperature · top_p · top_k · max_tokens · thinking
[gates]enabled · require_pairing · plain_text = run|ignore · screenshot = document|photo · max_reply_messages · idle_timeout_minutes — then a [gates.<channel>] table (token · allow), last
[jobs]max_concurrent · keep_runs · max_log_bytes
[loop]max · timeout · check_timeout · keep_runs · propose_check (let the AI infer a verifier command)
[flow]concurrency (nodes in flight at once) · timeout · node_timeout · keep_runs · max_map (the ceiling on a fan-out) · view (graph | list — how a run is watched and drawn)
[motivation]enabled · kinds (tips | facts | quotes | encouragement) · after · every — one dim line beside the spinner while a run waits, written by the model into cache/motivation.toml and reused; absent with no model
[md]syntax · image_max_rows · remote_images
[plugins]enabled · disabled = [...]
[shell]integration
[logging]level = off|error|warn|info|debug|trace · retention_days (default 7)
[[guard.command]]pattern · rule = deny|confirm|allow|auto
[[guard.path]]pattern · rule = deny|read-only|allow
[[keybinding]]key · action
[[guard.secret]]pattern · name · scope = ai|terminal|all · literal
Live reload. A running window follows config edits, profile switches and @theme within a second — and ⌘, reloads instantly.

Plugins

A plugin is a folder with a plugin.toml — declarative data over generic core primitives; no plugin code runs inside the terminal process:

[plugin]
name = "my-tools"
description = "my aliases and status segment"

[aliases]
gs = "git status -sb"

[[segment]]
align = "right"; template = "🔧 {my.var}"

# plus: [[abbr]] [[completion]] [[keybinding]]
# [[guard.command]] [[guard.path]] [[guard.secret]] shell.zsh shell.bash

30 builtins ship (git, docker, kubernetes, rust, python, node, github, extract, jump, autosuggest, syntax-highlight, history, lineedit, sudo, clipboard, encode, weather, web-search, ai-guard, …). Trusted plugin shell snippets are sourced into your shell; third-party installs go under ~/.aiTerminal/plugins/.

Models & providers

12 provider catalogs ship as TOML under ai/models/ — Anthropic, OpenAI, OpenRouter, DeepSeek, Groq, Grok (xAI), Qwen, Kimi (Moonshot), MiniMax, plus local Ollama, LM Studio and a generic OpenAI-compatible local. Any model id a known provider serves works, even if not pre-declared. Per-model capabilities (vision, document, thinking, tools) gate what each request carries; per-model pricing powers the token accounting in every run footer.

⚠️ Keep [[ai.model]] blocks LAST in the [ai] section. config.toml is plain TOML: every bare key = value belongs to the table header above it. A model block opens a new table, so an [ai] setting written below one silently joins that model. The seeded file is already laid out this way, and aiTerminal warns at startup if it ever happens.

Getting started — one model

There is one pool and one strategy: every request (@ai, agents, flows, loops) draws a model from it. No separate “fast” tier, no global key.

[[ai.model]]
provider = "openrouter"                # any provider (see ai/models/*.toml)
id       = "deepseek/deepseek-chat"    # any id that provider serves
api_key  = "sk-or-v1-…"

That is the whole setup — weight is optional, a lone model serves every request.

API keys — three ways

Keys belong to the model that needs them, so a mixed pool carries one key per provider.

In config.tomlResolves to
api_key = "sk-…"the key itself
api_key = "$MY_KEY" or "${MY_KEY}"that environment variable's value
api_key omittedthe provider's own variable — $ANTHROPIC_API_KEY, $OPENAI_API_KEY, $OPENROUTER_API_KEY, $DEEPSEEK_API_KEY, …

Expansion happens at request time, so exporting or rotating a key takes effect without touching config.toml. An unset variable resolves to nothing (never the literal "$MY_KEY") and you get a hint naming the variable to set. Local providers need no key at all:

[[ai.model]]
provider = "ollama"
id       = "llama3.1"

A pool of several models

Add more blocks. weight is each model's share of requests; omit it and a model gets a full 100. Sampling settings are optional per model.

[[ai.model]]                # ~10% — keep the pricey one rare
provider    = "anthropic"
id          = "claude-opus-4-8"
api_key     = "$ANTHROPIC_API_KEY"
weight      = 10
temperature = 0.3           # 0.0–1.0, lower is more deterministic
max_tokens  = 8000          # response cap (clamped 1–200000)
thinking    = true          # force extended thinking on/off

[[ai.model]]                # ~90% — the everyday workhorse
provider = "openrouter"
id       = "deepseek/deepseek-chat"
api_key  = "sk-or-v1-…"
weight   = 90
top_p    = 0.95             # nucleus sampling
top_k    = 40               # top-k sampling

How the pool picks

Weighted by default — omit [ai.balance] unless you want another strategy, and keep it above your model blocks.

[ai.balance]
strategy = "weighted"       # weighted (default) | round_robin | cost | failover

weighted picks at random proportional to weight; round_robin cycles the entries; cost always takes the cheapest; failover uses the first with the rest as ordered fallbacks the agent path retries on a hard error.

Agents, skills & prompts

All plain files under ai/. 8 agents are Markdown + frontmatter (tools, max_steps, skills) — run @agent to see them, @agent <name> for one in full. 12 skills ship. The global ai/aiTerminal.md is the system-prompt base for every run — edit it to change the persona. Project-local ai/ folders shadow the global set, so a repo can ship its own agents and flows.

A skill is a Markdown file of standing instructions for one kind of work. An agent names the ones it wants in its frontmatter, and they splice into its system prompt under ## Skill: <name>in the order the agent declared them, so the list reads as a priority and the same agent always sends the same prompt.

SkillWhat it holdsAgents that name it
conciseanswer first, cut the hedging and the preambleall seven specialists (ai carries its own instructions inline)
planningsmallest checkable steps, name the acceptance check, say what is out of scopeplanner · coder
researcha snippet is not a source — open it; cross-check; record dates and versionsresearcher · planner
writinglead with the answer, concrete over abstract, never describe behaviour you have not checkedwriter · researcher · reviewer
verificationreproduce before fixing, re-run after changing, never weaken a check to make it passtester · coder · reviewer
testingcover the edges, keep tests hermetic, add a regression test for every bugtester · coder
debuggingreproduce → isolate → root cause, not the first plausible guesstester · coder
code-reviewcorrectness, then security, then tests, then design — with file:linereviewer · coder
security-reviewthe classes worth looking for, and what silence about them meansreviewer
orchestrationwhen to delegate breadth to a sub-agent and when it is slowercoder · planner
gitthe commands and the conventions around a changecoder
refactoringbehaviour-preserving change, one thing at a timenone by default — attach it yourself

Add your own by dropping a file in ai/skills/ and naming it in an agent's skills = [...]. @agent <name> prints the skills an agent carries, in the order they splice. prompts = [...] (ai/prompts/) is the same mechanism under a second name, for your own blocks — nothing ships in it, because one bundled answer to "how do I reuse a block of prompt" is enough.

Every agent's frontmatter is checked at build time: a tool it declares must exist in the capability registry, a skill or prompt it names must be installed, and its max_steps must be sane. A misspelled tool used to reach the model with a generic description and fail three minutes into a run.

Attachments

Any @<path> token that names a real file becomes an attachment — in @ai, agents, flows and loops alike. Images (png/jpg/gif/webp, ≤4 MB) ride as vision blocks; PDFs as document blocks; text files inline fenced (≤48 KB, truncated beyond); up to 16 per prompt; everything passes redaction first.

Context & compaction

Needs a model. Every run measures its own context against the window of the model serving that run, and gives context back before that model would refuse the turn. The window comes from the model's own context_window in ai/models/*.toml — so a 32k local model gets a 32k budget and a 1M model gets a 1M one, out of the same code. Nothing here is per-vendor.

[ai]
context_window = 0      # 0 = use the serving model's own; >0 overrides it
compact_at     = 0.75   # fraction of the usable window that triggers compaction

context_window is for the case a model file cannot know about: a local model served with a smaller window than its card claims. For a mixed pool, set it on the entry instead, so a 32k local model and a 200k hosted one each budget against their own:

[[ai.model]]
id             = "my-local-model"
context_window = 16000

The reply's reservation is capped at half the window. context_window is a setting you type and max_tokens comes from a model file, so the two routinely disagree — and a 16k reply declared against an 8k window used to leave a quarter of the window for everything else, which meant compacting on turn one and buying a summary on every turn after.

A big tool result never enters the transcript

A tool result does not cost its tokens once. It is stored, and the transcript is re-sent on every remaining turn — so a 200 KB build log carried inline is paid for again and again, and it crowds out the reasoning it was fetched to support. So a result over 8 KB is written to cache/offload/<run>/ the moment it arrives, and the model is handed a preview plus the path.

   Compiling framework v0.0.0 (/work/crates/framework)
   …
[full output saved to …/cache/offload/1785-42/003-sys-run.txt] — 4000 lines, 214887 bytes.
Read it with fs.read when you need more.

Lossless in the way that matters: fs.read is not workspace-confined (only writes are), so the agent can pull any of it back when it turns out to matter. The threshold is deliberately generous — every source file you read and every short command is under it.

The ladder — cheapest rung first

When something still grows past the line — a long conversation rather than a large result — compaction runs cheapest rung first and stops as soon as the transcript fits.

RungWhat it doesCosts
offloadAny large tool result that slipped through is written out and replaced by a preview plus its path.nothing — no model call
summarizeThe oldest turns fold into one ## Earlier work (compacted) block, sized from the budget so the result actually fits.one model call

Most runs now finish without the ladder running at all — results never enter the transcript at full size in the first place, and the cheapest compaction is the one that does not have to happen. A run that does compact says so; it never shrinks its own history silently.

Paying for the prompt once

A turn re-sends everything before it: the agent's system prompt — its instructions, its skills, its whole tool catalogue — and the entire conversation so far. Left unmarked, a twelve-step run pays full price for that prompt twelve times.

So every turn declares what is settled: the system block is fixed for the run, and every message but the newest has already been sent. That is a fact about the conversation rather than a vendor feature, so it rides on the neutral request and each provider decides what to do with it — Anthropic gets two cache_control breakpoints (one static, one rolling), and OpenAI-compatible endpoints cache a matching prefix by themselves and need only that we do not disturb the order.

The saving is a number, not a claim:

 @coder "add a --json flag to the export command"
  ⚙ fs.read src/cli.rs · 9ms · 6KB
  ⚙ fs.edit src/cli.rs · 12ms · 1 replaced

Added --json to the export command, with a test.
✓ 8.4s · 3 tools · 12.3k in / 1.8k out (11.1k cached, 90%) · ~$0.004

The first turn of a run writes the cache; every turn after reads it, and the share climbs as the run goes on. A run showing no cached share is the signal that something in the prompt has stopped being stable — a tool list that changed order, a timestamp somebody added. The transcript is append-only and the MCP tool catalogue is sorted for exactly this reason, and both are held in place by tests.

ctx.* — an agent managing its own context

Two tools every agent has, answered by the run loop itself rather than by a capability (they read and rewrite the run's transcript, which is loop state). An agent does not declare them, and they never reach the tool runner.

ToolDoes
ctx.status {}{used, window, usable, pct, turns} — check before a big read
ctx.compact {"keep": "…"}run the ladder now; keep names what must survive

@ai has no transcript — it is a single turn — so it gets no ctx.* tool. What it gets is the budget: its grounding preamble is trimmed to fit before egress, dropping whole blocks from the least valuable end (the terminal snapshot, then the session digest, then recalled memory). Your standing instructions and the files you attached are the last things to go, and a trim is announced rather than silent.

Sessions, memory & MCP

Folder sessions: every AI feature (@ai, @<agent>, @flow, @loop, @job) persists a session for the folder it runs in, so returning to a project restores what the AI knows about it — no flags. A folder maps to its project root (the git top-level if you're in a repo, so subdirectories share one session; else the working dir). Each session lives under ai/sessions/<id>/: session.md (a byte-capped rolling digest of recent runs), a folder-scoped memory/, and meta.toml. It's lean — a run appends one digest line with no extra model call.

Memory: plain Markdown notes ranked by a from-scratch BM25 retriever — no database, no embeddings service. There are two stores: global (ai/memory/, durable across every project) and per-folder (inside the session). With [ai] memory = true, relevant memories auto-inject each run — folder-first, then global — and agents curate their own via memory.* tools, writing to the folder store during a folder run.

One fact, one memory. memory.add reinforces instead of duplicating. An agent re-learns the same thing constantly — it reads a config, saves what it found, and does it again next run. When what it is saving already exists, the stored note is reinforced (salience up, tags merged) and returned, rather than a near-copy being written; two files saying the same thing both ranked and both got recalled, so the model paid twice to be told once.

Links. A note can name related ones — links = ["…"] in the frontmatter, or [[id]] written in the body, both read and merged — and recall follows them one hop. This is what lexical ranking structurally cannot do alone: a decision ranks because it shares words with your question, while the reason it was made usually shares none. memory.link {from, to} relates two notes in both directions.

Ranking is Okapi BM25 over body + tags + kind, re-ranked by salience (reinforced on every recall and re-learn), recency (decayed per day), and an exact-tag boost — a tag is a deliberate act, while the same word in a body may be an aside, and flat BM25 cannot tell them apart.

MCP: declare Model-Context-Protocol servers under ai/mcp/; agent runs launch them and expose their tools as mcp.<server>.<tool> beside the native catalog.

The tool catalog

The full native catalog (each agent declares its own allowlist; undeclared tools are refused): fs.* read/search/glob/write/edit (writes sandboxed to your invocation directory) · sys.run (through the guard, output capped) · diag.check (native cargo check/ruff → structured file:line diagnostics) · web/net/http (SSRF-guarded, off with [ai] network=false) · git.* · memory.* (incl. memory.link) · todo.* · task.run (parallel read-only sub-agents) · data.*/queue.*/store.* (a sandboxed scratch database, queues and KV for agents) · codec.* (hash, uuid, base64/hex/url, JSON, CSV) · time.* date math · clipboard.

What each bundled agent holds (write your own ai/agents/*.md to grant more): @coder — fs read+write, diag.check, sys.run, web.read, memory.*, todo.*, task.run; @explorer/@reviewer — read-only (fs read/search, web.read, memory); @tester — read + write + sys.run. The catalog above is what a custom agent can additionally opt into (e.g. http.* for an API-calling agent, data.* for a scratch DB).

Security

The AI proposes, the guard disposes.

Secrets — out as a placeholder, back as themselves

A secret you cat is yours: it is already on your screen, your disk, your environment. The boundary that matters is egress — the moment text is about to leave for a model, a tool, or a chat app. The guard sits exactly there and swaps each match for a placeholder named after its rule («aws-key-1») — then swaps the real value back in the moment the text returns to this machine, as a command about to run or a tool's arguments. So an agent can use a database password it was never shown.

Before this, redaction was one-way: the model saw «redacted» and the command it wrote back could not connect to anything, so people turned redaction off and their keys went to a model. The round trip is what makes the safe thing also the useful thing. Nothing is written down — the values live in memory for one run, and a placeholder from another run is refused rather than run.

 cat .env
DATABASE_URL=postgres://db.internal/prod
AWS_ACCESS_KEY_ID=AKIA…
ANTHROPIC_API_KEY=sk-ant-…
LOG_LEVEL=debug

# what actually leaves for the model
DATABASE_URL=postgres://db.internal/prod        ← untouched
AWS_ACCESS_KEY_ID=«redacted»
ANTHROPIC_«redacted»
LOG_LEVEL=debug                                 ← untouched
Each key above is written as its prefix only — a page about not leaking secrets should not itself carry anything shaped like one. The rules that match the full values are in the table below.

Two things to read off that. Redaction is targeted — the connection string and the log level pass through, so the model keeps enough context to be useful. And rules compose: each runs over the previous one's output, so a key caught by the sk- rule can be caught again by the KEY=value rule, which takes the key name with it. That is why ANTHROPIC_API_KEY=… collapses to ANTHROPIC_«redacted» while AWS_ACCESS_KEY_ID=… keeps its name. Over-redacting is the safe direction.

Scopes

ScopeApplied to
aiEverything bound for a model — the terminal context @ai grounds on, tool results returned to an agent, @loop/@job context, and the session-context file.
terminalLive PTY output as it is displayed — applied per printable run, never to an escape sequence, so colors and cursor moves can't be corrupted.
allBoth. The default when a rule omits scope.

@gate deliberately applies both scopes to anything it sends to a chat — a phone is off-machine either way, so the narrower reading would be the wrong one.

The nine default rules

They ship as the ai-guard plugin, all scoped ai. The mechanism is native; the plugin only supplies the rules, so you can edit them or @plugin disable ai-guard outright.

CatchesPattern
AWS access key idsAKIA[0-9A-Z]{16}
OpenAI / Anthropic keyssk-[A-Za-z0-9_-]{16,}
GitHub tokensgh[pousr]_[A-Za-z0-9]{20,}
Slack tokensxox[abps]-[A-Za-z0-9-]{8,}
Google API keysAIza[A-Za-z0-9_-]{20,}
Bearer tokens(?i)bearer\s+[A-Za-z0-9._~+/-]+=*
JWTseyJ….eyJ….…
Sensitive KEY=value(?i)(api_key|access_key|client_secret|token|secret|password|credential|auth)\s*[:=]\s*\S+
PEM private-key blocks-----BEGIN … PRIVATE KEY----- … -----END …-----
They are off your screen by default. Every shipped rule is scope = "ai", so cat .env still shows you your own values — only what leaves is rewritten. Add scope = "terminal" rules to mask them in the display too, which is what you want before screen-sharing or recording.

Your own rules

The same three tables work in config.toml, in a profile, and in any plugin's plugin.toml — one parser reads all three. Config rules run before plugin rules, so yours are the one a refusal names.

[[guard.command]]
pattern = "^docker\s+ps\b"
rule    = "auto"                   # deny | confirm | allow | auto

[[guard.path]]
pattern = "/clients/"
rule    = "deny"                   # deny | read-only | allow

[[guard.secret]]
pattern = "acme_[a-z0-9]{32}"      # regex by default
name    = "acme-key"               # names its placeholder: «acme-key-1»
scope   = "all"                    # terminal | ai | all

[[guard.secret]]
pattern = "10.0.42.17"             # an exact string
literal = true                     # skip the regex engine entirely

Properties worth knowing

Logging

A leveled async diagnostic logger writes one file per day under ~/.aiTerminal/logs/, auto-pruned after retention_days (default 7). Default level is error — silence by default. Panics also append to crash.log (rotated at 1 MiB), and the event loop drops the offending frame instead of crashing the app.

Architecture

A four-layer Rust workspace — corelib < platform < framework < app — with four CI gates: zero external crates in the lockfile, strict layer edges, unsafe confined to platform/src/os/ (every other crate root forbids it), and no source file over 1000 lines. ~78k lines of Rust, and a test suite covered in Testing below.

That last gate is aimed at contributors rather than at the binary. A file nobody reads end to end is a file nobody can change safely, so a module that outgrows the limit is split along a boundary it already has — cli/ into one file per surface, caps/backends/ by tool family, term/ by what the VT engine does. Unit tests live in <module>/tests/ beside the code, never inline, so reading the production source means reading only the production source.

The performance model: the event loop idles (~0% CPU, one wake per real change, renders paced to 60 Hz and damage-tracked to the GPU); every stream, subprocess and transcript carries a named byte cap with an over-cap regression test; every deadline kills its child; the regex engine is step-budgeted; a panic degrades to one dropped frame.

Testing

1441 unit tests and 315 scenarios, running in a few seconds with no network and no API key. A unit test proves a function; a scenario proves a product — a real user journey written as TOML and played against the real code.

 cargo test --workspace                          # everything
 cargo test -p framework scenario -- --nocapture # the scenario report

  ai scenarios
   a destructive suggestion is blocked before it can reach the shell
   a chatty model cannot smuggle a second command into your shell
  27/27 passed

The distinction earns its keep. When @gate shipped with a large unit suite and users still hit bugs, 35 scenarios against that same code found 22 defects — failures of product behaviour that a unit test is structurally unable to see. There is now a folder of scenarios per feature: gate 35 · flow 29 · markdown 28 · ai 28 · cli 24 · terminal 22 · jobs 20 · config 16 · security 15 · plugins 14 · loop 13 · shell 10 · keymap 10 · theme 8 · memory 8. Each folder asserts a minimum count, so coverage cannot silently shrink.

The suite is hermetic and harmless by design. All AI is mocked — scenarios write what the model replies, encode it as the provider's real SSE wire format and decode it with the real decoder, so the streaming path runs with no socket and no key. Nothing spawns a process, opens a socket or touches a PTY, which is what makes it safe to write a test about rm -rf /: the string exists only as text asserted to go nowhere. The one deliberate exception is zsh -n/bash -n on the generated shell init — parse-only, no command executed — because a quoting slip there would break every new pane.

Want the deep details? The repo's docs/ folder carries the full engineering documentation — architecture, per-cap performance model, testing policy, packaging. Read it on GitHub →