
Agent Mode gives Memo the ability to use tools — it can read and edit files, run terminal commands, search the web, check your calendar, message on WhatsApp, and more. The agent operates within a sandboxed environment with a strict, danger-level-based permission system.
Agent Mode needs a model that actually supports tool/function calling to be any good. An active external provider (OpenAI, Claude, Gemini, etc.) is the reliable path. A running local llama.cpp model is also wrapped into the same tool-calling request — it isn't external-provider-only — but real tool-calling quality on local models varies a lot by which GGUF you loaded; many small/quantized models handle it poorly or not at all.
As of v3.3.3, an agent mode toggle sits directly in the Chat top bar next to the web-search toggle — no more leaving the Chat screen for a separate Agent tab just to turn tools on. Skill tools can also actually execute: a skill's SKILL.md can define a command: field, wired into the same agent tool pipeline and permission-prompt UI as run_command/file-edit tools, so a skill's tools get the same "ask before running" safety net with zero extra setup.
The agent ships with 22 tools (up from an original 8), each defined by a JSON Schema and registered with a danger level:
| Tool | Danger | Function |
|---|---|---|
read_file |
Safe | Read a file's contents (max 1MB) |
write_file |
Medium | Create or overwrite a file, keeps a .bak backup |
edit_file |
Medium | Find-and-replace or line-range replace in an existing file, with a diff preview |
insert_line |
Medium | Insert content at a specific line number |
delete_lines |
Medium | Delete a range of lines |
delete_file |
Dangerous | Delete a file or directory (.git/ is protected) |
list_directory |
Safe | List a directory's contents (max 1000 entries) |
get_file_info |
Safe | File metadata — size, mode, modified time |
search_files |
Safe | Glob-pattern file search |
run_command |
Dangerous | Run a shell command (60s timeout, 23-pattern blacklist) |
read_env |
Medium | List environment variables (sensitive keys masked) |
web_search |
Safe | DuckDuckGo search — see below, this one recently changed |
self_clone |
Dangerous | Copy the whole project to another local directory |
configure_provider |
Dangerous | Add/update a provider from inside a chat, needs confirmation |
get_calendar_events |
Safe | Read real events from your calendar for a date range |
create_routine |
Medium | Create a scheduled routine from a plain-language description (v3.9.0) |
list_routines |
Safe | List existing routines (v3.9.0) |
cancel_routine |
Medium | Cancel a scheduled routine (v3.9.0) |
whatsapp_send |
Medium | Send a WhatsApp message |
whatsapp_search |
Safe | Search your WhatsApp message history |
whatsapp_latest |
Safe | List your most recent WhatsApp chats |
whatsapp_messages |
Safe | Get a specific chat's message history |
web_search, and how it recently changedThe agent's web_search tool searches DuckDuckGo — the model is instructed, via the tool's own description, to reach for it only for current events, prices, or facts that may have changed since its training cutoff, not for greetings or things it already knows.
Until recently, the chat-level "web search" toggle (the globe icon next to the agent toggle) worked completely differently from this tool: turning it on made every single message — greeting or not — trigger a blind web search, whose results got stuffed into the prompt whether or not the message needed them. That's gone. The toggle now runs the exact same tool through a scoped, single-tool version of the agent pipeline: the model decides, per message, whether it actually needs to search — at zero extra cost when it decides not to — without exposing the rest of the agent's toolset (no file writes, no run_command) to a plain chat that never asked for full Agent Mode. A bare "how's it going" now triggers zero searches; "what's today's exchange rate" triggers exactly one, with a real, on-topic query instead of your entire raw message getting thrown at the search engine.
{
"name": "run_command",
"description": "Executes a terminal command in a sandboxed environment",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute"
},
"cwd": {
"type": "string",
"description": "Working directory (defaults to the project root)"
}
},
"required": ["command"]
}
}
Every tool has a danger level — safe, medium, or dangerous — and that decides whether the user is ever asked at all. safe tools always auto-run, no prompt, no exceptions. medium/dangerous tools check for an existing session/permanent decision first, and only fall through to an actual prompt if neither exists. When a prompt does fire, the user picks one of six policies:
| Policy | Behavior | Persistence |
|---|---|---|
AllowOnce |
Allow this one call | Cleared right after it runs |
AllowSession |
Allow for the rest of this session | In memory, lost on restart |
AllowForever |
Allow permanently | Saved to data/permissions.json |
DenyOnce |
Deny this one call | Cleared right after |
DenyForever |
Deny permanently | Saved to data/permissions.json |
| (no response) | Auto-denied after a 60s timeout | — |
23 patterns are blacklisted from run_command regardless of any permission policy — an AllowForever on run_command in general doesn't reopen these:
| Category | Examples |
|---|---|
| Destructive | rm -rf /, rm -rf ~, rm -rf . |
| Disk operations | dd, mkfs, format, fdisk, parted |
| Permission changes | chmod 777, chown on system paths |
| Privilege escalation | sudo, su, pkexec |
| Fork bombs | :(){ :|:& };: and variants |
| Reverse shells | nc -e, bash -i, mkfifo |
| System control | shutdown, reboot, halt, poweroff |
File-touching tools validate every path: symlinks are resolved (so a symlink can't point an edit outside the sandbox), the resolved path must stay within the project root, .. traversal is rejected, and a fixed list of system directories (/etc/, /usr/, /boot/, etc.) is off-limits outright.
Tool calls are capped at 30 per minute across all tools combined, and the exact same call (same tool, same arguments) can't repeat more than once every 5 seconds — a cooldown, not a hard block, so a legitimately repeated action still goes through once the window passes.
The agent runs an iterative loop: build the message list, attach every registered tool's schema, call the model (non-streaming, temperature 0.2), then either execute whatever tool calls came back and loop again with the results appended, or — if the model returned plain content instead — that's the final answer, done.
User Message
│
▼
[Iteration 1] Model sees the tools, decides what (if anything) to call
│
├─▶ Tool executes (permission-checked, sandboxed, timed) ──▶ Result
│
▼
[Iteration 2] Model receives the tool result(s), decides the next step
│
├─▶ Another tool call, or...
│
▼
...
│
▼
[Final] Model returns plain content — no more tool calls — done
| Parameter | Value |
|---|---|
| Max iterations | 40 |
| Per-tool timeout | 120s |
run_command timeout |
60s |
| Rate limit | 30 calls/min, 5s cooldown per identical call |
| Context budget | Sized per-turn from the conversation + a 32K headroom for tool iterations, never below 64K |
The pipeline stops when the model returns a final response with no tool calls, the iteration cap is hit, a permission wait times out (60s, auto-denied), or the request is cancelled.
Every tool call is logged — timestamp, tool name, arguments (sensitive ones masked), result summary, and the permission decision. The last 1000 entries live in memory; every entry is also appended as a JSON line to agent-audit.jsonl on disk, so the trail survives a restart, not just the in-memory buffer.
┌─ Agent Run ──────────────────────────────────────────┐
│ Model: claude-3-5-sonnet │
│ Iterations: 3 │
│ │
│ [1] web_search("sqlite-vec go example") │
│ → 4 results (0.9s) │
│ [2] read_file("internal/memory/store.go") │
│ → 312 lines (0.02s) │
│ [3] run_command("go test ./internal/memory/...") │
│ → PASS (2.1s) │
│ │
│ Final response generated │
└──────────────────────────────────────────────────────────┘
Agent mode relies on the active backend's function-calling (tool-use) support:
| Provider | Function-calling support |
|---|---|
| OpenAI | Native |
| Anthropic Claude | Native (tool_use blocks) |
| Google Gemini | Native (function declarations) |
| Groq | Via compatible hosted models |
| OpenRouter | Depends on the routed model |
| xAI Grok | Limited |
| OpenCode Zen / OpenCode Go | Native |
| Ollama (configured as an external provider) | Depends on the loaded model's chat template |
| Memo's own bundled local llama.cpp model | Wrapped into the same request as everything above — depends entirely on the loaded GGUF's own tool-calling support, which varies widely by model |
A v3.3.3 fix removed an internal bookkeeping field that was accidentally leaking into tool definitions sent to the provider's API — this had caused agent mode to fail outright with a cryptic400 invalid_request_erroragainst some external providers, even though plain chat worked fine on the same provider. Error messages shown in chat now surface just the human-readable message instead of the provider's full raw JSON error response.
A v3.3.4 fix budgets tool definitions against the model's context window — previously they weren't counted at all, so even a one-word message could fail on a small-context local model with a confusing "request exceeds context size" error. Starting a local model whose chat template doesn't support tool calling now shows a clear warning up front instead of failing unexplained mid-conversation.