Overview
Wisemonkey is a simple, open and hackable AI agent for the Linux and macOS terminal. It connects to any service that speaks the OpenAI, Anthropic or Ollama dialect, and gives that model a shell: sessions, persistent memory, a vector store, tools, MCP servers, skills and guard hooks.
It is a single Python process with no daemon to babysit, no database and no hidden state — everything it knows lives in plain files under $XDG_DATA_HOME/wisemonkey, and everything it does goes through a tool you can read.
Any endpoint
OpenAI, Anthropic, Ollama, LM Studio, or any OpenAI-compatible router. Per-endpoint API keys resolve from an ordered chain.
Memory that lasts
Session-scoped chat history, user profile and notes survive restarts, with a rolling window and token-cap compaction.
Vector search
Embed PDFs and documents into a per-session store with /embed; the agent retrieves them with search_knowledge.
Extensible
28 built-in tools, MCP servers over stdio/HTTP/SSE, skills, and external hooks around every tool call.
Linux or macOS, Python 3.13+ if you run from source, and a terminal. The container mode needs only Docker.
Quickstart
Install
Wisemonkey is on PyPI, so the shortest path is uv:
# Install uv
curl -LsSf https://astral.sh/uv/install.sh | sh
# Run without installing anything else
uvx wisemonkeyOr use the install script, which clones the project, builds it and drops a wmk launcher in ~/.local/bin:
curl -fsSL https://codeberg.org/langurmonkey/wisemonkey/raw/branch/master/install.sh | shThat installs to ~/.local/share/wisemonkey/repository. If Docker is present it also installs wmkc, which runs the agent in a container (see Container). With ~/.local/bin on your $PATH, configure the agent interactively:
wmk --onboardFor the rest of this manual we assume wmk is on your path; substitute uvx wisemonkey if you use the PyPI route — the flags are the same.
Run
# The default session
wmk
# A named session — created if it does not exist yet
wmk my-project
# List sessions
wmk --lsAPI keys in .env
If the endpoint needs a key, put it in a .env file. Wisemonkey searches, in order:
./.env— the current directory$XDG_CONFIG_HOME/wisemonkey/.env— the config directory$HOME/.env
echo "OPENAI_API_KEY=your-api-key-here" > .env
echo "ANTHROPIC_API_KEY=your-api-key-here" >> .env.env is loaded with python-dotenv at startup, and the provider SDKs also pick their conventional variables out of the environment, so a key exported in your shell profile works just as well.
From source
git clone https://codeberg.org/langurmonkey/wisemonkey.git
cd wisemonkey
uv build
export WM_API_KEY_GENERIC=your-api-key
uv run wmkAPI keys for several endpoints
Wisemonkey is usually driven through a router — OpenRouter, OpenCode, a local vLLM — and each of those wants its own key. One global variable cannot cover that: you would rewrite it on every switch (into shell history), and one endpoint's key would be sent to another the moment the provider changed.
So the key is resolved from an ordered, per-provider chain. The first non-empty value wins:
| # | Source | Notes |
|---|---|---|
| 1 | model.api_key_env | The environment variable name from config.yaml. Explicit always wins. |
| 2 | WM_API_KEY_<PROVIDER> | Namespaced per provider (GENERIC, OPENAI, ANTHROPIC), so several keys coexist. |
| 3 | OPENAI_API_KEY, ANTHROPIC_API_KEY | The provider's conventional variable. Existing setups keep working untouched. |
| 4 | WM_API_KEY | Generic fallback, tried last for every key-bearing provider. |
Local providers (Ollama, LM Studio) need no key at all.
WM_API_KEY_GENERIC never satisfies an Anthropic request, so switching the provider cannot send the wrong credential anywhere.
Recommended setup for routers
Give each endpoint its own variable, and point api_key_env at it. The value stays in .env; the config only ever holds the name, which is why config.yaml stays safe to share or commit.
cat >> .env <<'EOF'
OPENROUTER_API_KEY=sk-or-...
OPENCODE_API_KEY=sk-oc-...
EOFmodel:
provider: generic
base_url: https://openrouter.ai/api/v1
api_key_env: OPENROUTER_API_KEY # name only, never the key itself
name: anthropic/claude-sonnet-4.5Switching to OpenCode is then two lines in config.yaml, with both keys still in .env and neither of them in your shell history. If you do not care which key is which, the namespaced variables need no config at all:
WM_API_KEY_GENERIC=sk-or-... # used by any OpenAI-compatible endpoint
WM_API_KEY_ANTHROPIC=sk-ant-...Configuring an endpoint from inside the agent
/model configures a whole endpoint in one flow: URL → provider → key variable → model (/url is an alias for the same flow). A canonical URL picks the provider for you, an unknown host asks whether it is OpenAI-compatible, and a local endpoint asks no key question at all.
The key step is a list, not free text: it offers the host's conventional name first (OPENROUTER_API_KEY for openrouter.ai, OPENCODE_API_KEY for opencode.ai), marks the variables that are actually set in your environment, and keeps auto-detect as the default.
Wisemonkey names every variable it tried, so you know exactly what to export:
Missing API key for generic. Tried: WM_API_KEY_GENERIC, OPENAI_API_KEY,
WM_API_KEY. Set one in your environment or .env file, or point
model.api_key_env at a variable holding the key, or run /onboard.Storing a key in config.yaml is not supported, deliberately: config.yaml gets shared and committed, .env does not. See .env.example in the repository.
Run in a container
The wmkc launcher runs the agent inside a container, so it needs no Python or uv on the host, and the agent only ever sees the directory you launch it from. On first use it builds the image from the shipped Dockerfile (a few minutes) and caches it; later runs start instantly.
# From the directory you want the agent to work in:
cd ~/my-project
wmkc # same arguments as wmk
wmkc my-project # a named session
wmkc --versionThe container runs as your user, not root, so files it writes into the workspace and into the session history come out owned by you. Three host directories are mounted:
- the current directory — the workspace; the only part of the host the agent can see and modify
$XDG_CONFIG_HOME/wisemonkey—config.yaml,mcp.jsonand any.envalready in use$XDG_DATA_HOME/wisemonkey— sessions, chat history, notes and vector stores, so conversations survive the container
HOME, USER, TERM, EDITOR/VISUAL, the XDG variables and your API keys (WM_API_KEY_*, OPENAI_API_KEY, ANTHROPIC_API_KEY) are forwarded. Keys are passed by name, never as NAME=value, so they do not show up in ps or in your shell history.
| Variable | Effect |
|---|---|
WMKC_IMAGE | Use a different image (default wisemonkey-agent:local) |
WMKC_BUILD=0 | Never build; fail if the image is missing (for scripts) |
WMKC_REBUILD=1 | Rebuild the image even if it already exists |
WISEMONKEY_HOME | Where the Dockerfile lives (set by the install.sh wrapper) |
The agent runs sandboxed: no Linux capabilities, no privilege escalation, and a bounded process count.
Editing agent/*.py does not change a container that is already built. Rebuild with WMKC_REBUILD=1 wmkc to run changed code.
Tool hooks (agent.hooks) execute in the container, so a guard binary installed only on the host (say ~/.local/bin/dcg) is not found there — and a hook that cannot start fails open. If you rely on a host-side guard, run wmk directly or mount the binary in.
It works, but a daemon started inside a container is private to that container: XDG_RUNTIME_DIR is deliberately not forwarded, so the socket goes to /tmp/wisemonkey-<user>.
Sessions
Wisemonkey separates memory histories into sessions, which you name. The default session is called default, so wmk and wmk default are equivalent. Passing a name starts that session — creating it if it does not exist, restoring it if it does.
wmk my-project
# List the sessions
wmk --ls
Sessions:
- my-project - ~/.local/share/wisemonkey/sessions/my-project
- default - ~/.local/share/wisemonkey/sessions/defaultFrom inside a session, /sessions does the same and adds message counts, marking the current one.
Switching without restarting
/resume # resume the most recently used other session
/resume my-project # switch to 'my-project'/resume rebinds the chat history, notes, user profile and vector store of the running process, and sends the previous session to disk first. An unknown name creates a new session, exactly like wmk my-project. It is not available in client/server mode: there the daemon owns the session, so start a server for the target session and attach to that instead.
What a session holds
- Input history
- Chat memory, notes and user profile
- The vector store of embedded documents
- Its own scratch space,
tmp/— theTMPDIRevery command it runs gets - Its saved plans (see below)
- Optionally, its own
config.yaml
That last one means a session can diverge from the rest of the machine: on startup the agent looks for <session_dir>/config.yaml and falls back to the global <config_dir>/wisemonkey/config.yaml when it is absent. Useful for a session pinned to a cheaper model, or one with a different system prompt.
Session plans
Ask the agent for a plan and it can write it down. A plan is a short markdown file the agent keeps for a piece of work — scope, action items, open questions — stored per session in <session_dir>/plans/ as <NAME>_<TIMESTAMP>.md. The agent creates it with the save_session_plan tool (the create-plan skill is what tells it how), and keeps it up to date afterwards.
Each plan carries front matter — name, created, status and completed — so a plan is not just prose: the agent marks it done or obsolete with update_session_plan_status, and revises the body with update_session_plan_content when your feedback changes the approach, without losing that front matter.
Reference them from inside a session:
/plan-list— list the session's plans with their status and age (aliases/plan-ls,/plans-list,/plans-ls)/plan-read <name>— print one; a partial name matches the most recent plan with that prefix, so/plan-read REFACTORfindsREFACTOR_20260626_120000/plan-edit <name>— open one in$EDITORand fix it by hand
Plans live in the session directory next to the memory files, so they belong to the session they were written in: /resume moves to another session's plans with everything else, and a plan written in one session is not visible from another.
A session is just a directory under ~/.local/share/wisemonkey/sessions, so renaming the directory renames the session — its plans and everything else come along.
Client/server mode
Wisemonkey can split into a server that owns the session (chat memory, profile, notes, vector store, model connection) and thin clients that connect to it and render the UI.
# Start a daemon for the 'default' session
wmk --server
# Or for a named session
wmk --server my-projectThe server binds a Unix domain socket at $XDG_RUNTIME_DIR/wisemonkey/$SESSION.sock (for example /run/user/1000/wisemonkey/default.sock) and stays up until stopped.
Any client started afterwards — the REPL wmk or the Textual TUI wmk --tui — tries to attach to an existing daemon for its session first. If one is running, the client is a thin remote: turns, slash commands, cancellation and memory stats all flow over the socket, while shell commands (!) run locally and their results are recorded in the server's chat history. If no server is up, the client runs normally with an ephemeral server for the duration of its own lifetime.
# Terminal 1: the daemon
wmk --server my-project
# Terminal 2: attach to it (same session name!)
wmk my-projectA client for session2 will never connect to a daemon running session1 — it simply starts its own independent session. Several clients can therefore share one session state through one daemon, and the chat history survives client restarts for as long as the daemon keeps running.
Configuration
The configuration file is created on first run as $XDG_CONFIG_HOME/wisemonkey/config.yaml, from the default config.yaml in the repository. Configure it interactively with wmk --onboard, or open it in your editor with wmk --edit-config (the in-session command is /config).
The configuration directory also holds:
mcp.json— the MCP server definitions.updates.yml— the last update check and its result.env— optionally, the API keys
Model Context Protocol
Wisemonkey speaks MCP. Servers are declared in mcp.json and started when the agent boots, so restart the agent after adding one.
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
},
"github": {
"type": "http", // stdio | http | sse
"url": "https://api.githubcopilot.com/mcp/"
}
}
}Manage it from inside the agent:
/mcp— show the current MCP configuration/mcp edit— editmcp.jsonin your editor/mcp tools— list the tools the MCP servers expose (alias/tools mcp)
Tool hooks
Wisemonkey can run external guard programs around every tool call — before it runs (pre_tool_use) and after it (post_tool_use). The protocol is compatible with Claude Code / Codex hooks: the hook gets JSON on stdin and answers on stdout, so an already-installed guard works with no adapter.
agent:
hooks:
pre_tool_use:
- matcher: "run_command|spawn" # regex over tool names; empty or "*" = all
hooks:
- type: command
command: /usr/local/bin/dcg # use an absolute path
timeout: 5 # seconds
honor_unsafe: false # see below
post_tool_use: [] # logging/audit; output is ignored- A
pre_tool_usehook maydeny,askorallowvia{"hookSpecificOutput": {"permissionDecision": "...", "permissionDecisionReason": "..."}}on stdout, or block with exit code 2 and a reason on stderr. Silent stdout means “no opinion”. - A
denyis fed back to the model as a tool error, like a declined confirmation, so the turn continues and the model can adapt. Anaskturns into an ordinary Wisemonkey confirmation prompt. - Shell tools (
run_command,spawn) are presented to a hook under the Claude nameBash, with the command intool_input.command— the name a stockdcgmatches on. - A hook that cannot start fails open with a warning: a broken install must not refuse every call. A hook that hangs past
timeoutor prints garbage fails closed, because it has not cleared the call; setfail_open: trueon the handler to invert that. honor_unsafe: truemakes a handler skip whileagent.unsafeis set. The default (false) is that hooks always run, soagent.unsafenever silently disables a guard that did not ask to be switched off.
When a guard vetoes a call, the reason is displayed in a framed panel attributed to the hook, clearly distinct from the agent reporting a failure of its own.
Soul file
A soul file is a markdown file that defines the agent's identity and persona. It is injected into the system prompt before the workspace instructions, so it takes precedence over AGENTS.md without replacing it.
Two are considered, in order:
- Global soul —
$XDG_CONFIG_HOME/wisemonkey/SOUL.md. Applies to every session on the machine. - Workspace soul —
SOUL.mdin the working directory or any parent directory (the same walk-up lookup asAGENTS.md). Applies to the project.
Both are loaded when present, global first. If neither exists, nothing is added.
<!-- ~/.config/wisemonkey/SOUL.md -->
# Soul
You are Wisemonkey, a terse and precise engineering assistant.
You prefer concrete answers over hedging, and you never invent facts.The file names are configurable, and an empty string disables the lookup:
agent:
soul_file: SOUL.md # workspace soul file name
global_soul_file: SOUL.md # global soul file name in the config directoryKeep the soul file about who the agent is. Workspace rules belong in AGENTS.md, and task-specific procedures in skills.
File references (@path)
Attach files directly to a prompt with an @-reference. Writing @agent/agent.py (or the backtick-quoted @`agent/agent.py`) inlines the file into the context sent to the model — no extra tool-call round trip.
- Small files, up to
agent.at_file_max_chars(default 8000 characters), are inlined as fenced code blocks appended to your message. - Large files inject only a stub — path, size, first lines — so the model knows the file exists and can read it itself with the
read_filetool. - Directories inject a shallow listing.
- Non-existent or handle-like tokens (
@langur,a@b.com) are left untouched.
The typed text stays as-is on screen and in chat history; only the message sent to the model carries the attachment. Path completion works after @ too (@ag → @agent/), in both the REPL and the TUI. Set agent.at_file_max_chars: 0 to turn the feature off.
Commands & keys
Key bindings
- Enter — submit the prompt
- Alt / Shift / Ctrl + Enter, or Ctrl + j — add a new line
- Ctrl + q — quit
- Ctrl + c — cancel the running turn; at an empty prompt, clear it, and double-tap to quit
- ↑ — walk back through your prompts (slash commands are filtered out)
Newline keys and your terminal
A terminal sends the same byte for Enter, Shift+Enter and Ctrl+Enter, so the agent cannot tell them apart unless the terminal is asked to. On startup it enables the kitty keyboard protocol and disables it again on exit, so your shell is left exactly as it was.
| Your terminal | Newline keys |
|---|---|
| kitty, Ghostty, Alacritty, WezTerm, foot, Rio, iTerm2 | Shift+Enter, Ctrl+Enter, Alt+Enter, Ctrl+j |
Terminals sending xterm modifyOtherKeys | Shift+Enter, Alt+Enter, Ctrl+j |
| macOS Terminal.app | Alt+Enter (needs Use Option as Meta Key), Ctrl+j |
| GNOME Terminal, PuTTY, screen | Ctrl+j |
| tmux | as the outer terminal, plus set -g extended-keys on and set -g extended-keys-format csi-u |
When a terminal ignores the protocol, Shift+Enter simply arrives as Enter and submits, so Ctrl+j is the reliable one. Nothing is printed about this: the fallback is silent.
Questions asked mid-session
Commands like /model and /config ask you things — a URL, a key variable, a yes/no. Those questions are fully line-edited (arrows, Backspace, Ctrl+u, history) with the question styled like the rest of the UI. The one thing that does not work there is a modified Enter: the questions are read by readline, which cannot tell Shift+Enter from a plain Enter, so Enter submits.
Slash commands
List them with /help, and get details on one with /[command] help (for example /config help). The main ones:
| Command | What it does |
|---|---|
/model, /url | Configure the endpoint: URL, provider, key variable, model |
/config | Configure the agent interactively; /config show, /config edit |
/sessions, /resume | List sessions, switch to another one |
/embed | Embed a document into the session vector store |
/mcp, /tools | Show, edit and list MCP configuration and tools |
/skills, /context | List loaded skills, show what is in the context |
/edit, /retry, /undo | Correct the last turn (see below) |
/history, /history-info, /history-compact | Print, inspect and summarise chat history |
/notes, /notes-add | Read and write persistent notes |
/plan-list, /plan-read, /plan-edit | Session plans |
/reasoning, /temperature, /markdown, /vi | Toggle per-session behaviour |
/unsafe | Skip the confirmation prompts (see confirmations) |
/attachimage | Attach an image to the next prompt |
/quit | Exit (aliases /exit, /q) |
Correcting a turn
A prompt you already sent is easy to reach but hard to take back: ↑ re-sends it verbatim, and the bad exchange stays in the agent's context either way, so the model keeps building on it. Three commands deal with that:
| Command | What it does |
|---|---|
/edit | Open the last prompt in $EDITOR and re-run it — the edited prompt replaces the old exchange, so the model never sees both versions |
/retry | Re-run the last prompt unchanged. --drop also removes the old answer first, so the re-run starts from the context the original did |
/undo [n] | Drop the last n exchanges from the context entirely, without running anything |
/undo drops whole exchanges — the prompt, the answer, and any tool calls and results that belonged to it — never a fragment, which would leave a tool result in the prompt with no matching call.
Shell commands
Prefix an input with ! to run it in the shell instead of sending it to the model:
!git status
!ls -la
!docker psThe command runs synchronously (60 s timeout by default), its stdout, stderr and exit code are printed, and the command together with its result is appended to the session chat memory, so the model can use it as context on the next turn. Since you typed the command yourself, no confirmation is requested — the same trust model as running it in your terminal.
Confirmations
Some tools ask before they act: run_command prompts for dangerous patterns, long timeouts, writes into system directories, and paths outside the session; fetch_url and web_search prompt because every call leaves the machine; find_files prompts for a search root outside the session.
“The session” is two directories, not one. Alongside the working directory you launched it in, the agent gets its own scratch space at <session_dir>/tmp, created 0700 on first use, and every command it runs (run_command, spawn) gets TMPDIR pointed there. Scratch files land inside the session instead of the shared, world-readable /tmp, and writing to $TMPDIR needs no confirmation because the agent already owns it — a literal /tmp path still prompts.
/unsafe (or agent.unsafe in the config) skips all of them, which is what makes unattended runs possible. It does not silence tool hooks unless a hook opts in with honor_unsafe: true.
Mid-turn steering
You do not have to wait for the agent to finish a turn before adding to it. While the model is working, Wisemonkey pins the bottom four terminal rows as a sticky footer:
————————————————
⇒ stealth/space-bunny Mem…
⠹ Processing prompt...
Ctrl+C: cancel turn | type + ↵ to steerThe footer stays put while the response scrolls, using the terminal's own scroll region (DECSTBM) — no output interception, so streaming, tools and markdown rendering all behave exactly as they do otherwise.
Steering a running turn
Type in the footer's input row while the turn runs and press Enter. The line is queued and shown as ↳ queued: <text> (next tool result).
It does not have to wait for the turn to end. Wisemonkey delivers it at the next tool-call seam — right after the current batch of tools finishes, just before the model is asked again:
⤷ Read the CSV first, then plot the two columns. [steered mid-turn]The line becomes a regular user message, so it is persisted to chat history and counted like any other user message. That makes it a genuine mid-turn correction rather than a new turn: the model already has its earlier work in context when your line lands.
If the turn finishes before another tool call happens, nothing is lost — the line stays queued and runs as an ordinary next prompt.
Details
- The footer input row is deliberately not a line editor: no history, no completion, and nothing echoed to stdout while you type. The stage spinner and the
💡 Thinking...indicator live on the bottom hints row, to the right of the key bindings, so they never overwrite what you are typing. - Ctrl+c still cancels the running turn, even mid-turn.
- Lines are captured as raw keystrokes, so an accidental keypress is harmless — it just does not appear until you press Enter.
- Steering works in both modes: locally, and attached to a daemon (the client forwards the line and the server confirms delivery before the client drops its copy).
- A queued line survives a cancelled turn.
- Turn it off with
agent.steer_midturn: false.
Tool output
Each tool prints one line when it is activated:
↸ Activating tool: read_file (agent/core.py)That is the only line a successful, quick tool produces — a “finished in 0.0s” line under every call turns a fifteen-tool turn into fifteen lines of noise. The result line is therefore printed only when it tells you something:
- Failures are always reported, with the error text and the duration.
- Slow tools — at least
agent.tool_slow_thresholdseconds (default 1 s) — printTool X finished in Y.Ys, so the tool that made you wait is the one you can name.
agent:
tool_slow_threshold: 1.0 # 0 = always print a result line; a huge value = errors onlyThis is purely about what you see: the tool's full result still goes to the model and into chat history either way.
Set agent.footer_debug_bytes: true and look at $XDG_STATE_HOME/wisemonkey/footer.log, which records every byte the footer writes.
Memory & context
Persistent memory follows the XDG Base Directory spec and lives per session in ~/.local/share/wisemonkey/sessions/$SESSION_NAME:
user_profile.json— information about younotes.json— persistent notes, added with thesave_notetoolchat_history.json— the conversationchat_archive.jsonl— everything trimmed out of the live history (see below)vectordb/— embedded documents
The lifecycle is simple: memory is loaded into the system prompt on each turn, save_note adds notes during a session, save_memory writes everything to disk explicitly, and memory is auto-saved when the agent exits.
Document embedding
Embed documents into the per-session vector store and the agent can search them while answering:
/embed ~/documents/research_paper.pdf
/embed ./notes.mdThe model queries the store with the search_knowledge tool. PDF, Markdown and plain text are supported; embeddings are produced by the configured embedding model and stored under vectordb/ in the session directory.
Chat history
On top of persistent memory, the agent keeps a chat history of recent exchanges, which is what gives it context beyond a single turn:
- Each user message and assistant response is stored; reasoning is omitted.
- The history is attached to the system prompt as the last section (after identity, workspace instructions, memory and skills), so the static prefix stays stable and provider prompt caches are not invalidated every turn.
- Tool results are truncated in the formatted history unless
chat_history_full_tool_resultsis enabled. - The footer shows a live per-turn token delta while the model streams; at the end of the turn the status line shows the exact delta, the provider-reported prompt tokens, and the turn's cost when
model.pricingis configured. - Long-running commands can be backgrounded with
spawn, watched incrementally withpoll_job, and stopped withkill_job.
agent:
max_chat_history: 80000 # maximum history tokens to keep in context
memory_rolling_window_turns: 10 # 0 = keep everything
chat_history_full_tool_results: false # full tool content in the prompt
chat_history_tool_result_max_chars: 500 # cap per tool result when the above is falseA rolling window, and nothing is lost
memory_rolling_window_turns keeps only the last n exchanges — a user message plus everything after it until the next one — in the live history. Older exchanges leave the context (they no longer cost tokens), and the token cap compaction remains as a backstop.
Trimmed entries are never destroyed: both the rolling window and compaction append what they drop to chat_archive.jsonl. That file is append-only, is never read back into the prompt, and never affects token accounting — it exists purely so anything that aged out can be recovered.
Inspect and summarise
/history— print the session's turns (a prompt plus its final answer; tool calls stay out of it)/history n— only the last n turns/history-info— the per-exchange timestamps and sizes/history-compact— summarise in place, keeping the most recent exchange live;/history-compact nsummarises and replaces only the n oldest interactions/history-clear— drop the history (and its context) entirely
History is counted with tiktoken (o200k encoding) when available, falling back to a chars/4 estimate. The count matches what is actually injected into the prompt — including the truncation applied to tool results — so one large tool result cannot trigger premature compaction.
Images
The agent can see images. /attachimage attaches one to your next prompt, and the screenshot and read_image tools let the model capture the screen or read an image from disk. Images are translated to each provider's own format behind the scenes, so vision works on OpenAI-compatible endpoints, Anthropic and Ollama alike.
Architecture
Wisemonkey is built to be modular and hackable. The agent loop lives in agent/agent.py; everything about talking to a model, calling a tool or persisting memory lives in agent/core.py and its neighbours, so a frontend can be replaced without touching the agent.
wisemonkey/
├── agent/ # Core agent code
│ ├── agent.py # REPL frontend: agent loop, prompt handling, key bindings
│ ├── tui.py # Textual TUI frontend
│ ├── client.py # Client-side connection to a daemon server
│ ├── server.py # Daemon server (UDS) owning the session Core
│ ├── ipc.py # IPC protocol: messages, payloads, transports
│ ├── emitter.py # Turn event emitter: core callbacks -> IPC events
│ ├── commands.py # Slash commands (e.g. /embed, /quit)
│ ├── completion.py # Smart path completion for the prompt
│ ├── at_files.py # @file/@dir reference expansion
│ ├── shellcmd.py # ! shell command handling
│ ├── startup.py # Startup banner and info rendering
│ ├── tokens.py # Token counting (tiktoken)
│ ├── history.py # Input history handling
│ ├── config.py # Configuration loading and handling
│ ├── console.py # Rich console output with themed formatting
│ ├── output.py # Output adapter protocol (REPL, TUI, IPC)
│ ├── footer.py # Sticky footer (DECSTBM scroll region) + spinner
│ ├── steer.py # Mid-turn steering input (cbreak key capture)
│ ├── keys.py # Terminal key protocol (modified Enter, kitty keys)
│ ├── core.py # Core agent functions: API connection and tool calls
│ ├── mcp.py # MCP server support
│ ├── mdstream.py # Streaming markdown renderer for the REPL
│ ├── memory.py # Session memory, paste file creation
│ ├── router.py # API router for OpenAI, Ollama and Anthropic
│ ├── skills.py # Skill loading and management
│ ├── tools.py # Tool definitions
│ ├── update.py # Update management
│ ├── utils.py # Utility functions
│ └── vectorstore.py # Vector store wrapper
├── tools/ # Tool implementations available to the model
│ ├── basic.py # Basic and example tools
│ ├── files.py # File read/write tools
│ ├── memory.py # search_knowledge tool
│ ├── network.py # URL fetching
│ ├── screenshot.py # Screen capture tool (base64 JPEG, user-confirmed)
│ ├── terminal.py # Shell command execution
│ └── vectorstore.py # Vector store tool handler
├── skills/ # Skill definitions. Add new skills here
├── tests/ # unittest suite
├── config.yaml # Default config file
├── wmkc # Run the agent in a container (wrapper around docker)
├── Dockerfile # Container image for wmkc
├── pyproject.toml
├── install.sh # Installer script
└── .env.exampleThe system prompt is rebuilt each turn by assembling, in order: the base prompt, the soul files, the workspace AGENTS.md, memory (profile and notes), the loaded skills, and finally the chat history. History goes last precisely because it changes every turn — the prefix above it stays byte-identical, which is what lets a provider cache it.
Extending
The agent is small enough to be extended by adding files. If you build a good tool, skill or command, consider contributing it as a merge request.
Adding a tool
Create a file in tools/ or add to an existing one, and decorate a function with @tool(...). Tools are auto-discovered on startup.
from agent.tools import tool
@tool(
name="my_tool",
description="Does something useful. Be exhaustive here, as it is what the LLM will read to know about your tool.",
parameters={
"type": "object",
"properties": {
"input": {
"type": "string",
"description": "The input parameter."
}
},
"required": ["input"],
},
)
def my_handler(args):
input = args.get("input", "no input provided")
return {"result": f"{input}"}A handler can return a string, a dict, or a dict carrying image_base64 and mime_type to hand the model an image. Malformed arguments from the model are reported back to it as a tool error, never a crashed turn.
Adding a slash command
Write a method — preferably in agent/commands.py — and decorate it with @cmd(name, description, aliases, examples, can_complete). A command returns, in order, ok: bool, msg: str, content: str (Rich-formatted), and markdown: str:
@cmd(
"/my-command",
"This is the description",
aliases=["/mycmd"],
)
def _cmd_my_command(agent, params) -> (bool, str, str, str):
"""This command returns a message but no content"""
return True, "This is awesome!", None, NoneCommands are auto-registered and auto-completed in the prompt. A command cannot run a turn itself — it sets a pending prompt and the frontend sends it, so commands behave identically in the REPL, the TUI and a remote client.
Adding a skill
Skills are markdown files with YAML front matter, loaded from the project's skills/ directory and from ./skills/ in the working directory. The body of a skill is injected into the system prompt, so it is the right place for procedures the agent should always know. Loading can be disabled entirely with agent.skills: false.
---
name: my-skill
description: What this skill does
---
# My skill
## When to use
...
## Steps
1. ...The front matter follows the agentskills.io standard; name and description are parsed and listed by /skills.
Protocol
The client/server wire protocol — a Unix domain socket carrying newline-delimited JSON — is specified in docs/PROTOCOL.md. It is stable enough to build third-party frontends against: attach to a session's socket, send an attach request, and drive turns with prompt while streaming events come back.
No daemon is required, no database is used, and every turn is available in chat_history.json. If the agent did something, there is a file that says so.