A terminal coding agent written entirely in Sema — a Lisp with first-class LLM primitives.
Sema Coder is the reference application for Sema as an application runtime: the
agent loop, tools, slash commands, the full-screen TUI, theming, and config all
live in Sema. Only a thin layer of host primitives (terminal screen control, path
safety) is Rust. It depends on nothing but the sema binary.
sema≥ 1.31 — install withcurl -fsSL https://sema-lang.com/install.sh | sh(orbrew install helgesverre/tap/sema-lang, orcargo install sema-lang; see the sema README).- An API key —
ANTHROPIC_API_KEYorOPENAI_API_KEYin the environment. - Optional:
rg(ripgrep) — the grep tool prefers it, falling back togrep. - Optional:
jake— enables thejake coder.run,jake coder.ask,jake coder.test, andjake coder.helpshortcuts.
# Interactive (full-screen TUI on a TTY)
./coder.sema # or: sema coder.sema
# One-shot (prose to stdout, pipeable)
./coder.sema -- -p "explain this codebase"
# Override the model
./coder.sema -- -m claude-haiku-4-5-20251001./coder.sema works because the file is chmod +x with a #!/usr/bin/env sema
shebang.
sema-coder/
├── coder.sema Entry point — CLI parsing, boot, REPL/TUI dispatch
├── src/
│ ├── agent.sema System prompt + agent construction
│ ├── banner.sema Wordmark + welcome (on-brand gold)
│ ├── cli.sema Argument parsing + usage text
│ ├── commands.sema Slash-command registry + built-ins
│ ├── config.sema Config loading (init.sema as Sema data)
│ ├── display.sema Output sink (emit) + tool-call rendering
│ ├── keymap.sema Global shortcuts, rebindable via config
│ ├── markdown.sema Markdown → styled terminal lines
│ ├── mcp.sema MCP client runtime (connect, tool-merge, autostart)
│ ├── overlay.sema Modal overlays — MCP manager + session picker
│ ├── selection.sema Transcript selection, highlighting + clipboard
│ ├── session.sema Session persistence — conversations as JSONL
│ ├── terminal_input.sema Buffered raw input + terminal event decoder
│ ├── text.sema Width-aware clip/pad/truncate string helpers
│ ├── theme.sema Brand palette (sema gold #c8a855)
│ ├── tools.sema 7 LLM-callable tools
│ ├── transcript.sema Transcript blocks → styled lines (cached)
│ ├── tui.sema Full-screen TUI — frame-diffed, async agent turns
│ └── util.sema Workspace path resolution + shell quoting
├── test.sema Test runner (Sema running Sema)
├── tests/ The test files + harness
└── docs/ Design notes; dated plans live in docs/plans/
It is built on Sema's own primitives: defagent / deftool / agent/run (the
LLM agent loop), async / async/cancel (concurrent turns), make-parameter /
parameterize (the command registry), mutable-array/* (the streaming
transcript), file/* and shell (tools), json/* (config), term/* (theming +
screen control), path/within? (workspace path resolution), llm/session-usage
(token/cost HUD).
In the TUI, an agent turn runs as an async task while a sibling task keeps pumping input, so scrolling, resize, and type-ahead all work while tokens stream in, and Ctrl-C interrupts the turn without killing the app.
Drag across transcript text to select and copy it automatically. Double-click selects a word or punctuation run; triple-click selects a rendered line. The highlight clears on mouse release; Shift-drag remains available for the terminal's native selection. Clipboard copy uses the platform command locally and OSC52 under SSH/tmux or as a fallback.
Built-ins: /help, /model [name], /effort [level], /clear, /tools,
/mcp, /resume, /cwd, /config, /reload, /quit, /exit. In the TUI,
type / to open a fuzzy command palette; once you type /model the same
palette fuzzy-completes the model argument from the config's :models
list, shown as Anthropic: Claude Opus 5 (Tab inserts the selection, Enter
runs it — any model id typed by hand still works). The active model is marked
● and providers whose API key isn't set are annotated · no key. /effort
sets Sema's portable reasoning-effort level the same way (none / minimal /
low / medium / high / xhigh; default resets) — models without
reasoning support simply ignore it, and a (model … {:effort "high"}) record
sets a per-model default. /resume <id> restores a saved session directly
(completing from your session list) and brings back the model and effort it
ran with. Add your own commands in config (see below).
Config is Sema data, not JSON — an init.sema file that calls
(configure! (coder-config {…})). It is created (annotated) on first run,
hot-reloads on save (edit it in any pane; a banner shows and the last-good
config keeps running if a save doesn't parse), and lives at:
<config-dir>/sema/sema-coder/init.sema
<config-dir> is the OS default (~/Library/Application Support on macOS,
$XDG_CONFIG_HOME or ~/.config on Linux). Overrides, in order: the
SEMA_CODER_CONFIG_DIR environment variable, then the OS default. Run /config
to print the exact path, or /config edit (or e in the ⌃O modal) to open it.
A complete init.sema:
(configure!
(coder-config
{:model "claude-opus-5" ; default model; "" = auto-detect from API keys
:effort "" ; reasoning effort (none…xhigh); "" = provider default
:max-turns 50 ; max tool-use rounds in a single turn
:tool-preview-lines 5 ; result lines shown under each tool call
;; Models offered by the /model autocomplete, grouped by provider.
;; Only a picker list — any model id typed by hand still works. A model
;; may carry per-model defaults: (model id label {:effort "high"}).
:models
(list
(provider "Anthropic"
(list
(model "claude-opus-5" "Claude Opus 5")
(model "claude-fable-5" "Claude Fable 5")
(model "claude-opus-4-8" "Claude Opus 4.8")
(model "claude-sonnet-5" "Claude Sonnet 5")
(model "claude-haiku-4-5" "Claude Haiku 4.5")))
(provider "OpenAI"
(list
(model "gpt-5.6-sol" "GPT-5.6 Sol")
(model "gpt-5.6-terra" "GPT-5.6 Terra")
(model "gpt-5.6-luna" "GPT-5.6 Luna")
(model "gpt-5.5" "GPT-5.5"))))
;; MCP servers — each is a value; manage connections in the /mcp modal (⌃O).
:mcp-servers
(list
;; stdio: a local process speaking MCP over stdin/stdout
(mcp-server "sema" {:command "sema" :args ["mcp" "--include" "eval,docs,docs_search"]
:autostart #t}) ; connect at boot
;; http: a remote endpoint (OAuth is prompted when you connect)
(mcp-server "asana" {:url "https://mcp.asana.com/mcp"}))
;; Custom slash commands — argv (no shell), a template, or a Sema handler.
:commands
(list
(command "test" {:desc "run tests" :run ["make" "test"]})
(command "log" {:desc "git log" :run ["git" "log" "--oneline" "-n" :args]})
(command "diff" {:desc "wc diff" :shell "git diff $ARGS"})
(command "hi" {:desc "greet" :do (lambda (state args) (emit :info "hi!") state)}))
;; Rebind any keyboard action (defaults shown in the table below).
:keys {}})) ; e.g. {:mcp "ctrl-p" :resume "ctrl-y"}
| Key | Default | Meaning |
|---|---|---|
:model |
"claude-opus-5" |
LLM model; "" auto-detects from ANTHROPIC_API_KEY / OPENAI_API_KEY |
:effort |
"" |
Reasoning effort (none/minimal/low/medium/high/xhigh); "" = provider default |
:max-turns |
50 |
Max agent tool-use rounds per user turn |
:tool-preview-lines |
5 |
Result lines shown under each tool call in the TUI |
:models |
Anthropic + OpenAI flagships | (provider …) groups of (model …) records driving the /model autocomplete |
:mcp-servers |
'() |
List of (mcp-server …) records |
:commands |
'() |
List of (command …) records |
:keys |
{} |
Action → key overrides |
Each server is a (mcp-server "name" opts) value. opts is either a stdio
launcher (:command + :args) or an http endpoint (:url), plus the
optional app key :autostart:
(mcp-server "fs" {:command "npx" :args ["-y" "@modelcontextprotocol/server-filesystem" "."]})
(mcp-server "asana" {:url "https://mcp.asana.com/mcp"}) ; OAuth on connect
:autostart #t connects at boot; otherwise you connect on demand. Manage
connections in the /mcp modal (⌃O): ↑↓ select, c connect, d disconnect,
t list a server's tools, e edit init.sema. A server that needs auth shows a
▲ — connect it to run the sign-in flow. Connecting merges that server's tools
into the agent for the rest of the session (only add servers you trust — they run
real commands and reach real services).
A (command "name" spec) becomes /name. The spec carries :desc, an
optional :key (a keyboard shortcut that fires the command, e.g.
:key "ctrl-t"), plus exactly one handler:
:run— an argv list run in the workspace, never shell-interpreted (the safe default). The keyword:argsmarks where the text you type after the command is spliced (dropped if you type nothing); without:argsit is appended.["git" "log" "-n" :args]+/log 5→git log -n 5.:shell— a template string with$ARGSsubstituted, run via the shell.:do— a Sema handler(lambda (state args) … )returning the next state (or the symbolquit); write output with(emit :info "…").
Config commands hot-reload — removing one from init.sema unregisters it. You
can also register commands at runtime from Sema, after loading src/commands.sema:
(register-command! "hello" "Say hi"
(lambda (state args) (emit :info "hi!") state))
A command can also register argument completions — the palette switches to
them once you type /name (this is how /model, /effort, /resume, and
/config offer theirs). The function receives the live state map (:config,
:model, :effort, …); an entry with :active #t is marked ● in the palette:
(register-completions! "hello"
(lambda (state)
(list {:value "world" :label "the whole world" :active #t}
{:value "mom" :label "hi mom"})))
The keymap is data, merged from four layers (weakest first): the built-in
defaults below → :key on command records → the config :keys map → runtime
bind-key! calls. A key bound to an action that isn't a built-in fires the
like-named slash command, so all of these bind ⌃T to /test:
(command "test" {:desc "run tests" :run ["make" "test"] :key "ctrl-t"}) ; on the command
:keys {:test "ctrl-t"} ; in the :keys map
(bind-key! "ctrl-t" "test") ; from Sema code
Rebind built-in actions the same way, e.g. :keys {:mcp "ctrl-p"};
(unbind-key! action) drops a runtime bind. Binding one key to two actions
logs a warning at boot/reload (first match wins).
| Action | Default | Does |
|---|---|---|
:mcp |
⌃O |
Open the MCP modal |
:resume |
⌃R |
Open the session picker |
:palette |
⌃K |
Open the slash-command palette |
:quit |
⌃D |
Quit |
:interrupt |
⌃C |
Interrupt the turn / clear input / quit; ⌘C also interrupts mid-turn when the terminal forwards it |
:clear-line |
⌃U |
Clear the input line |
:line-start / :line-end |
⌃A / ⌃E |
Move the caret |
:repaint |
⌃L |
Force a full repaint |
Every turn is written to <config-dir>/sema/sema-coder/sessions/<id>.jsonl — a
meta line plus one message per line, in the exact agent/run shape (tool calls
and results included), so a conversation resumes verbatim. /resume (or ⌃R)
opens a picker of past sessions, newest first: ↑↓ to move, Enter to preview a
session's messages, r to restore the conversation into the current session and
keep going.
read-file, write-file, edit-file, bash, grep, find-files, list-dir.
Every path — including the search tools' — resolves through path/within?,
which keeps reads, writes, and searches inside the workspace root (catching both
../ and symlink escapes). The search tools invoke rg/grep/find/ls
argv-style, so patterns are never shell-interpreted.
This is accident prevention, not a sandbox. The bash tool runs real shell
commands with your privileges, unrestricted — the path check above applies only
to the file/search tools. Treat a session like you'd treat any coding agent:
run it in a workspace you're prepared to let it modify.
./test.sema # run the test suite (or: jake coder.test)
./test.sema -- markdown # only files whose name contains a term
jake coder.help # show CLI help through JakeThe runner is itself Sema — it fans each tests/*_test.sema out to a child
interpreter (crashes stay contained, no state leaks between files) and
reports per-file checks and timings; failing files get their full output.
Tests sit on a tiny check/check-true/check-contains harness
(tests/harness.sema); each file ends with (done), exiting non-zero on
failure — that exit code is the whole runner contract. Design notes are
in docs/ (dated planning documents are archived under docs/plans/;
docs/language-friction.md tracks upstream sema issues this app found, with
their fix status).
MIT
