README
¶
tinyagent
curl -fsSL https://raw.githubusercontent.com/vicgupta/tinyagent/main/install.sh | sh
A minimal coding agent in a single static Go binary. It gives an LLM nine tools (read_file, write_file, edit_file, list_files, grep, bash, and start_process, read_process, stop_process for background commands), plus the opt-in update_plan, skill, view_image, apply_patch, delegate, web_fetch, lsp, and MCP resource (list_mcp_resources, read_mcp_resource) tools, and keeps a linear message history until the model submits.
Install
macOS and Linux
curl -fsSL https://raw.githubusercontent.com/vicgupta/tinyagent/main/install.sh | sh
The script downloads the latest release for your OS and CPU, checks it against SHA256SUMS, and installs it to /usr/local/bin if you can write there, otherwise to ~/.local/bin. No sudo needed. Two optional settings:
# Install a specific version
curl -fsSL https://raw.githubusercontent.com/vicgupta/tinyagent/main/install.sh | TINYAGENT_VERSION=v0.1.1 sh
# Install somewhere else
curl -fsSL https://raw.githubusercontent.com/vicgupta/tinyagent/main/install.sh | TINYAGENT_INSTALL_DIR=~/bin sh
Windows
Download tinyagent-windows-amd64.exe (or -arm64.exe) from the latest release, rename it to tinyagent.exe, and put it in a folder on your PATH. The bash tool needs bash on your PATH, e.g. from Git for Windows.
With Go
go install github.com/vicgupta/tinyagent/cmd/tinyagent@latest
Manual download
Binaries for every platform, plus SHA256SUMS, are on the releases page. Verify with shasum -a 256 -c SHA256SUMS --ignore-missing. On macOS, a binary downloaded with a browser is quarantined; clear it once with xattr -d com.apple.quarantine tinyagent.
Updating
tinyagent --update
This replaces the binary in place with the latest release, after checking its checksum. If it's installed in a directory you can't write to, run it with sudo.
Shell completion (Bash and Zsh)
tinyagent --completion bash and tinyagent --completion zsh print a script that completes flag names (--sandbox-<Tab>, --tool-out<Tab>, short aliases such as -t). Only flag names are completed, not models, profiles or other flag values.
Bash. Load it for the current session:
source <(tinyagent --completion bash)
To keep it, add that line to ~/.bashrc.
Zsh. The script registers itself with Zsh's native completion system (compdef, no bashcompinit), so it must be sourced after compinit. For the current session, or to keep it, put this in ~/.zshrc:
autoload -Uz compinit
compinit
source <(tinyagent --completion zsh)
If your framework (Oh My Zsh, Prezto and the like) already runs compinit, add only the last line, after the framework is loaded. Sourcing it before compinit prints an error and changes nothing.
Other shell names exit 2. In both shells, words that don't start with - (and flag prefixes that match no flag) keep the shell's normal filename completion, and pressing Tab never runs tinyagent: the flag list is fixed when the script is generated, so re-source it (or open a new shell) after updating. The command needs no task, credentials or config.
Build from source
git clone https://github.com/vicgupta/tinyagent && cd tinyagent
make build # ./tinyagent
make test
make dist # linux/darwin/windows × amd64/arm64 in dist/
Usage
export OPENAI_API_KEY=...
tinyagent -t "fix the failing test in pkg/foo" --cwd ~/src/project -o run.json -v
tinyagent -m anthropic/claude-sonnet-4-5 -t "..."
tinyagent -m openrouter/qwen/qwen3-coder -t "..."
tinyagent -m ollama/llama3.1 -t "..."
tinyagent -m codex/gpt-5.6-luna -t "..." # ChatGPT subscription, see below
cat task.md | tinyagent -t - # read a long or multi-line task from stdin
tinyagent --read-only --cwd ~/src/project -t "Review error handling in pkg/api"
cat review-task.md | tinyagent --read-only -t - -o review.json
ChatGPT subscription (Codex)
The codex provider uses your ChatGPT subscription instead of an API key. Sign in once with the Codex CLI (codex login, choose ChatGPT). tinyagent reads the tokens it saves in $CODEX_HOME/auth.json (default ~/.codex/auth.json), refreshes them when they expire and writes them back, so Codex stays signed in too. Requests go to the Codex backend's Responses API. Usage counts against your plan's limits rather than costing money, so cost shows as $0 and --cost-limit has no effect. Only models your plan offers in Codex work (the ones the Codex CLI's model picker shows); others fail with "model is not supported".
Flags
| Flag | Default | |
|---|---|---|
-h, --help |
Print usage to stdout and exit | |
-t, --task |
— | Task prompt (required unless --resume; with --resume of a finished run, the follow-up task; of a NeedsInput run, the answer); - reads it from stdin |
--resume |
— | Continue a validated unfinished trajectory; requires explicit matching --cwd and a new -o path |
--ask-user |
config ask_user.enabled, else off |
Give the model the ask_user tool: it may end the run with one question (status NeedsInput, exit 4), answered with --resume <trajectory> -t "<answer>". Requires -o; can't be combined with --output-schema; --ask-user=false disables it. See Asking for input |
--acp |
off | Serve the Agent Client Protocol on stdin/stdout so editors such as Zed and JetBrains IDEs can run tinyagent as an external agent (experimental). Can't be combined with -t, --resume, --rewind, --transcript, review flags, --worktree, --output-schema, --json, --ask-user, --check, --init-config, --completion, -o, --cwd, --image or --snapshots (exit 2) |
-m, --model |
$AGENT_MODEL, else config model, else openai/gpt-4o |
<provider>/<model>; no / means openai. Providers: openai, openrouter, anthropic, ollama, codex, plus any defined under providers |
--reasoning-effort |
config reasoning_effort, else unset |
Reasoning effort sent to the provider: none, minimal, low, medium, high, xhigh, max; see Reasoning |
--fallback-model <spec> |
config fallback_models, else none |
Model to switch to when the current one keeps failing with 429, 5xx or network errors after retries; repeatable, tried in order, at most 3, and replaces fallback_models; see Model fallback |
--no-fallback |
off | Don't fall back to other models (clears fallback_models); conflicts with --fallback-model |
--openai-api |
config openai_api, else auto |
Transport for the openai provider: auto, responses, or chat; see OpenAI transport |
--init-config |
Write a commented starter config to the user config path (or -c PATH), refuse overwrites, and exit |
|
-c, --config |
user config, else built-in | YAML config file; see Config |
--profile |
none | Apply one named profile from the config file's profiles: section over its base settings; see Profiles |
--list-profiles |
Print the profile names in the selected config file (-c) or the user config, sorted, one per line, then exit. Makes no model request and needs no credentials; see Profiles |
|
--cwd |
. |
Root directory; tools can't reach outside it |
--review |
off | Review the uncommitted changes (staged, unstaged and untracked) against HEAD: tinyagent collects the diff and runs a read-only review; -t adds focus instructions. See Reviewing changes |
--review-base <ref> |
— | Review what the current branch adds on top of <ref> (merge-base..HEAD), read-only. See Reviewing changes |
--review-commit <rev> |
— | Review the changes introduced by one commit, read-only. See Reviewing changes |
--worktree[=name] |
off | Run in a new Git worktree at <repo>/.tinyagent/worktrees/<name> on branch tinyagent/<name>, created from HEAD of the repository containing --cwd and kept afterwards; the name is generated when omitted. Can't be combined with --resume or --rewind. See Worktrees |
--step-limit |
0 (unlimited) | Max model calls |
--cost-limit |
0 (unlimited) | Max USD |
--time-limit |
0 (unlimited) | Max wall-clock seconds; overrides wall_time_seconds |
--max-format-errors |
config max_format_errors, else 3 |
Stop with RepeatedFormatError after this many consecutive steps whose tool calls are all malformed; must be >= 1. See Malformed tool calls |
--max-output-tokens |
config max_output_tokens, else 0 |
Per-request output-token budget sent to the provider; 0 keeps the provider default (8192 max_tokens on Anthropic). Not supported by codex. Unrelated to max_output_chars, which caps tool output. See Output truncation |
--no-compact |
off | Never compact the history; see Long runs |
--plan |
config plan.enabled, else off |
Give the model the update_plan tool; see Plan tool |
--apply-patch |
config apply_patch, else off |
Give the model the apply_patch tool for multi-file, multi-hunk edits, checked before any write; --apply-patch=false disables it. See apply_patch |
--subagents |
config subagents.enabled, else off |
Give the model the delegate tool, which hands a task to a read-only subagent with a fresh context and returns only its report; --subagents=false disables it. See Subagents |
--web-fetch |
config web_fetch.enabled, else off |
Give the model the web_fetch tool, which reads pages from allowlisted hosts as text; needs a nonempty allowlist. --web-fetch=false disables it. See Web fetch |
--lsp |
config lsp.enabled, else off |
Start the language servers configured in lsp.servers (user config or -c only): append compiler diagnostics to write_file, edit_file and apply_patch results and add the read-only lsp tool. Needs at least one server; --lsp=false disables it. See Language servers (LSP) |
--web-fetch-allow-domain <host> |
config web_fetch.allowed_domains, else none |
Hosts web_fetch may read. Repeatable; replaces the config list; requires --web-fetch (or web_fetch.enabled: true). See Web fetch |
--tool-output-files |
config tool_output_files.enabled, else off |
Save the full text of tool results over max_output_chars to a per-run file the model can page with read_file and grep; --tool-output-files=false disables it. See Tool output files |
--images |
config images.enabled, else off |
Let vision-capable models see images: the view_image tool and MCP image pass-through; see Images |
--image <path> |
— | Attach a PNG, JPEG, GIF or WebP file to the task message (or a --resume -t follow-up). Repeatable, up to 10, each within max_image_bytes; needs -t; independent of --images. See Attaching images to the task |
--plan-file |
— | Mirror the current plan to this Markdown file after each checkpoint save (requires the plan tool) |
--skills |
config skills, else off |
Discover Agent Skills and offer the skill tool |
--no-skills |
— | Disable skills even if the config enables them (--no-skills=false enables) |
--read-only |
config read_only, else off |
Permit only read_file, list_files, and grep; blocks edits, shell, and process tools. --read-only=false explicitly disables it. |
--add-dir-read |
config additional_dirs, else none |
Let the file tools read (not write) an existing directory outside --cwd; repeatable. See Additional directories |
--add-dir-write |
config additional_dirs, else none |
Let the file tools read and write an existing directory outside --cwd; repeatable. See Additional directories |
--sandbox |
config sandbox, else off |
Confine bash/start_process at the OS level: writes only under --cwd, a temp dir and sandbox_writable_paths, network off. See Sandboxing commands |
--sandbox-network |
config sandbox_network, else off |
With --sandbox, allow network access |
--sandbox-allow-domain <host> |
config sandbox_allowed_domains, else none |
With --sandbox, let commands reach only the listed hosts over HTTP(S) through a filtering proxy. Repeatable; replaces the config list. See Network allowlist |
--env |
config environment, else local |
Where bash/start_process run: local (on the host) or container (Docker/Podman). See Execution environments |
--command-env |
config command_env.inherit, else all |
Environment bash/start_process inherit: all, core or none. See Command environment |
--redact-secrets |
config redact_secrets.enabled, else off |
Mask known secret values as [REDACTED] in bash, process, MCP, web_fetch and hook output before the model sees it; --redact-secrets=false turns the config setting off. See Redacting secrets from tool output |
-o, --output |
— | Trajectory JSON, rewritten after every step |
--snapshots |
config snapshots, else off |
Snapshot the workspace before every tool batch that can change files, so --rewind can restore it. Requires -o. See Rewind workspace changes |
--rewind <trajectory> |
Restore --cwd to a snapshot recorded in that trajectory, with --to start|<step>|<snapshot-id> (default start) and --dry-run to preview. Makes no model request |
|
--transcript <trajectory> |
Print that trajectory as a redacted Markdown transcript on stdout and exit. Makes no model request and needs no API key; see View a run as a transcript | |
-v, --verbose |
off | Print tool calls and their outputs |
--json |
off | Write machine-readable run events as JSONL to stdout |
--output-schema |
End the run with a JSON result validated against this JSON Schema file (printed to stdout) | |
--log-db[=path] |
off | Also record run events in a local SQLite database (default <cwd>/.tinyagent/tinyagent.db); see Event log database |
--no-stream |
off | Wait for each full response instead of streaming it |
--no-agents-md |
off | Don't append the nearest AGENTS.md (at or above <cwd>, up to the repo root; capped at 32KB) to the system prompt |
--scoped-agents-md |
off | Also deliver AGENTS.md files in directories below <cwd>, appended to the first file tool result that touches a path there; see Project instructions. Can't be combined with --no-agents-md |
-V, --version |
Print the version and exit | |
--update |
Replace this binary with the latest GitHub release (checksum-verified) and exit | |
--check |
Print the resolved config file, model, provider key status, fallbacks, cwd and limits (and ask_user when it is on or set by flag), then exit without a model request (exit 2 on a setup error) |
|
--completion bash|zsh |
Print a Bash or Zsh flag-name completion script and exit (source <(tinyagent --completion bash); for Zsh, after compinit); see Shell completion |
Machine-readable output
--json makes stdout a UTF-8 JSONL event stream, with one complete JSON object and newline per event. It can be combined with --no-stream, --read-only, --resume, and -o (which still writes the trajectory JSON to its file). In this mode, streamed assistant text and live bash output are represented by events, and --verbose has no effect because tool calls and results are already events; the human summary and warnings remain on stderr. Setup failures before a run starts may leave stdout empty.
Every event has a type. Event names and fields in schema version 1 are:
| Type | Fields |
|---|---|
run.started |
schema_version (currently 1), model, canonical root, read_only, resumed, follow_up (true, only for a follow-up run), attachments (index, name, mime_type, bytes, sha256 per --image file, never the data; only with --image), reasoning_effort (only when set), fallback_models (only when a fallback chain is configured), openai_api (the resolved responses or chat, only for the openai provider), profile (the name only; only with --profile), command_env (inherit, exclude_secrets, sorted passed names; only with a command environment policy), redact_secrets (enabled, patterns, sorted variable names; only with secret redaction on), file_access (deny_read, deny_write; only with file access rules), web_fetch (allowed_domains; only with web fetch on), lsp (servers, the configured names; only with language servers on), scoped_agents_md_enabled and scoped_agents_md (scoped files already in context, [] for a fresh run; only with --scoped-agents-md on), additional_dirs (path, mode; only with additional directories), worktree (path, branch, base_commit, repo_root; only with --worktree), review (target, ref, base_commit, head_commit, commit, files_changed, diff_bytes, diff_bytes_sent, truncated, hidden_paths; only with a review flag) |
assistant.message |
text (complete assistant message) |
tool.call |
id, name, arguments (decoded JSON value, or a string if malformed) |
tool.result |
id, name, output (same configured truncation as tool output), is_error; with tool output files, a saved result also has output_file (path relative to --cwd) and output_bytes; with secret redaction, redactions (count, only when > 0) |
hook |
event (pre_tool, post_tool, or stop), tool_call_id, tool_name, command, decision (allow, deny, feedback, continue, or error), duration_ms; tool hooks are emitted between the call's tool.call and tool.result, and stop events have no tool fields |
plan.updated |
step, version, items (id, text, status), note, diff |
checkpoint.saved |
step, path, pending (tool calls without results yet) |
resume.interrupted_call |
id, name of a call that was in flight when the previous process stopped |
resume.process_lost |
id, pid, cmd of a background process from the previous invocation |
compaction |
step, reason, stage, tokens_before, tokens_after, elided_messages, summarized_messages |
workspace.snapshot |
id, step (0 = baseline), kind (baseline, step, final, or pre-rewind), time, files, bytes, skipped_ignored, skipped_large, large_paths (up to 20, when any), store, duration_ms; only with snapshots on. Paths and counts only, never file contents |
rewind.plan / rewind.result |
--rewind --json only: the target snapshot, each {op, path} change and counts; then restored, not_restored, pre_rewind and error (on a partial restore) |
warning / error |
message |
sandbox.network_denied |
host, port, reason; the sandbox allowlist proxy refused a request from a command (see Network allowlist) |
web_fetch |
url, final_url, status, content_type, bytes, truncated, duration_ms, and denied_reason (for a refused URL) or error (for a failed fetch); one per fetch attempt, emitted before its tool.result (see Web fetch) |
lsp.server |
name, state (started, unavailable, crashed or stopped), reason (for unavailable and crashed); one per language-server state change (see Language servers (LSP)) |
instructions.loaded |
path, scope_dir (both relative to --cwd), bytes (file size), truncated, tool, tool_call_id, step; a scoped AGENTS.md was appended to that call's result, emitted before its tool.result (only with --scoped-agents-md) |
instructions.skipped |
path, scope_dir, reason (file_access, outside_root, modified, read_error or cap), tool, tool_call_id, step, and error for read_error; a scoped AGENTS.md that applied was not delivered |
provider.retry |
attempt (the upcoming attempt number), reason (http_<status> or network), backoff_ms; only emitted when a model request is retried |
model.fallback |
step, from, to, reason (http_<status> or network), attempts; the run switched to the next fallback model after from used up its retries |
result |
status, submission (the question for NeedsInput), cumulative steps, structured_output (only with --output-schema and status Submitted), question (tool_call_id, question, options when given; only for status NeedsInput, see Asking for input), input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cost_usd, invocation duration_ms |
When the run starts, the final event is result, including for model errors, interruptions, and limit stops. Exit codes are unchanged. --resume reports cumulative trajectory accounting; duration_ms covers only the current invocation. Event streams can contain prompts, code, and tool output, so handle them with the same care as trajectory files. Credentials and environment dumps are not included.
Consumers should check schema_version on run.started. Event names and field meanings are stable within a schema version; an incompatible change will increment the version. New optional fields and event types may be added without changing it. The JSONL stream is intended for scripting and CI integrations:
tinyagent --json -t "fix the failing test" --cwd . \
| jq -c 'select(.type == "result") | {status, submission, cost_usd}'
tinyagent takes no positional arguments: quote the task and pass it with -t. A stray word such as tinyagent "fix the tests" or tinyagent -t "..." typo --step-limit 5 is an error (exit 2), rather than silently dropping the flags after it.
The limit flags must be >= 0, and --cost-limit must also be finite. A typo such as --step-limit -1 or --cost-limit NaN is an error (exit 2) instead of quietly running without the cap. An explicit 0 means unlimited and overrides the config file's limit.
Environment: OPENAI_API_KEY, OPENAI_BASE_URL (any OpenAI-compatible endpoint), ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, OPENROUTER_API_KEY, OLLAMA_HOST, CODEX_HOME.
Structured output
--output-schema schema.json makes the run end with one JSON object that conforms to a JSON Schema, for scripts and CI:
tinyagent --review-base main --output-schema review.schema.json > review.json
The schema file (at most 64 KiB) must be a JSON object with "type": "object" at the root and is checked before any provider call; a bad schema exits 2. The model is given a submit_result tool whose parameters are the schema, and the run finishes only when it calls that tool, alone in its message, with arguments that validate. Invalid arguments, mixing submit_result with other tool calls, the completion marker, or a turn with no tool call are answered with the validation errors or a reminder; after 3 consecutive invalid attempts the run ends with status InvalidOutput. Hooks never run for submit_result.
On success the status is Submitted, stdout contains only the compact JSON object and a newline, and the summary, streamed text, and -v output go to stderr. With --json, the object is the structured_output field of the result event instead. Any other outcome exits 1 with nothing on stdout. Trajectories (-o) record structured_output, output_schema, and output_schema_digest; submission holds the compact JSON string. Runs started with --output-schema cannot be resumed, and --resume with --output-schema exits 2.
Supported keywords (the same subset as MCP tool schemas): type, properties, required, additionalProperties: false, items, enum, const, minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf, minLength, maxLength, pattern (Go regexp syntax), minItems, maxItems, uniqueItems, minProperties, maxProperties. format is accepted but not enforced. Providers also see the schema, but tinyagent validates locally, so results never depend on provider-side strict modes.
Event log database
--log-db (or log_db.enabled: true in config) records every event the --json stream would contain in a local SQLite file, whether or not --json is also on. It is off by default; nothing is created unless enabled. The database lives at <cwd>/.tinyagent/tinyagent.db unless --log-db=path or log_db.path says otherwise (a flag path is relative to the shell's directory, a config path to --cwd; ~/ is expanded). Add .tinyagent/ to your .gitignore. The file is created with mode 0600, uses WAL mode with a busy timeout so concurrent runs can share it, and needs no cgo (pure-Go modernc.org/sqlite).
Tables: runs (one row per invocation: model, provider, cwd, status, steps, token and cost totals, duration, version), events (run_id, seq, step, ts, type, JSON payload), tool_calls (tool name, arguments, result summary, duration, is_error), and schema_migrations. Timestamps are Unix milliseconds.
sqlite3 .tinyagent/tinyagent.db \
"SELECT tool, count(*), avg(duration_ms) FROM tool_calls GROUP BY tool"
At the default level: metadata no prompt, message, argument, or output bodies are stored: those fields are replaced with their lengths, and the task is stored only as a SHA-256 hash and length. level: full stores bodies, after redacting values of environment variables named *_KEY, *_TOKEN, *_SECRET, or *_PASSWORD, secrets set in MCP or hook env, and common token shapes; payloads over max_payload_bytes are truncated. Old runs are pruned when a run starts (max_days, max_runs; 0 keeps everything). Database errors never fail a run: tinyagent prints one warning and continues without the log. The trajectory JSON (-o) is unchanged and remains the resume format.
Reviewing changes
--review, --review-base <ref> and --review-commit <rev> review one Git change set without giving the model a shell. tinyagent runs fixed, read-only git commands itself, puts the diff in the first user message with built-in review instructions, and runs the model with the read-only tool set (plus any of skill, web_fetch, lsp and delegate you turned on) so it can read the surrounding code. The model reports findings ordered by severity, each with file:line, a severity and a short failure scenario. Nothing in the working tree or index changes.
tinyagent --review # staged + unstaged + untracked vs HEAD
tinyagent --review-base main # merge-base(main, HEAD)..HEAD
tinyagent --review-commit HEAD~1 # changes introduced by one commit
tinyagent --review -t "focus on error handling in pkg/api"
cat focus.md | tinyagent --review-base origin/main -t -
tinyagent --review-base origin/main --output-schema findings.schema.json > findings.json
- Targets.
--reviewcovers tracked changes againstHEAD(staged and unstaged together) and untracked files that aren't ignored, shown as new files.--review-base <ref>covers the commits onHEADsince its merge base with<ref>; uncommitted changes are left out, with a note on stderr when there are some.--review-commit <rev>covers one commit against its first parent (a root commit against the empty tree), with its subject and author. Only one target per run. - Scope. Only changes under
--cwdare included, with paths relative to it, matching what the file tools can read. Binary files show asbinary file changed. Paths hidden byfile_access.deny_readare left out; the prompt, stderr andreview.hidden_pathsgive only their count. - Size cap. The diff text is capped at 200,000 bytes. Over the cap every changed file is still listed with its
+/-counts, whole per-file diffs are included in order while they fit, the others are named so the model can read them withread_file, and stderr andreview.truncatedreport it. - Safety. Refs come only from the command line (a ref starting with
-is rejected). Git runs with--no-ext-diff,--no-textconv,core.fsmonitor=false, every configured filter driver turned off andGIT_OPTIONAL_LOCKS=0, so reviewing an untrusted repository doesn't start programs its config names and doesn't write the index. - Privacy. The diff, including untracked files that aren't ignored, is sent to the selected provider.
- Errors and exit status.
gitmissing,--cwdoutside a work tree, an unknown ref, no commits or no merge base exit 2 before any model request. A clean change set printsnothing to reviewand exits 0 without a request. Otherwise the exit status means the review ran, as for any run; it doesn't encode findings. Two review flags, or a review flag with--read-only=false,--resume,--rewind,--worktree,--snapshots,--check,--init-configor--update, exit 2. - Everything else as usual. The system prompt and AGENTS.md, limits,
--json,-o,--log-db,--output-schema, profiles and stop hooks behave as for other runs.run.started, trajectories and the event log record areviewobject (target, ref, resolved commits,files_changed,diff_bytes,diff_bytes_sent,truncated,hidden_paths), and-vprints onereview:line. A review run saved with-ocan be continued with--resume review.json -t "<follow-up>"(without a review flag) as an ordinary follow-up; it keeps the recorded read-only mode, so use it for questions about the findings, and start a new run to fix them.
A minimal findings schema for CI:
{"type": "object", "required": ["findings"], "properties": {"findings": {"type": "array", "items": {"type": "object", "required": ["severity", "location", "summary"], "properties": {"severity": {"enum": ["critical", "high", "medium", "low"]}, "location": {"type": "string"}, "summary": {"type": "string"}, "scenario": {"type": "string"}}}}}}
Worktrees
--worktree runs a task in a fresh Git worktree, so it never touches your checkout and several runs can work on the same repository at once:
tinyagent --worktree=fix-login -t "fix the login redirect" --cwd ~/src/project -o run.json
tinyagent --worktree -t "add tests for pkg/api" --cwd ~/src/project/pkg/api
- What it creates. A worktree at
<repo>/.tinyagent/worktrees/<name>on a new branchtinyagent/<name>, from the currentHEADcommit of the repository containing--cwd. The run's root becomes the matching path inside the worktree, so--cwd ~/src/project/pkg/apiruns in.tinyagent/worktrees/<name>/pkg/api. A.gitignorecontaining*keeps.tinyagent/worktreesout ofgit status. The location isn't configurable. - Names.
--worktree=<name>uses 1 to 64 letters, digits,.,_and-, not starting with.or-. Bare--worktreegeneratesrun-YYYYMMDD-HHMMSS-<6 hex>. An existing worktree directory ortinyagent/<name>branch is an error rather than being reused. - Uncommitted changes aren't carried over. The worktree starts from the last commit; if your checkout has uncommitted or untracked changes, tinyagent warns once and continues. Commit first if the task needs them.
- Setup output and errors. Setup prints
worktree: <path> (branch tinyagent/<name> from <commit>), and the end-of-run summary printsworktree kept: <path> (branch ...).--cwdoutside a Git work tree, a repository with no commits,gitmissing fromPATH, an invalid or taken name, a--cwdsubdirectory missing atHEAD, or combining with--resume/--rewindexits 2 before any model request and leaves nothing behind. Ctrl-C during setup leaves either no worktree or a complete one. - Hooks. Repository hooks (such as
post-checkout) don't run while the worktree is created. Filters and Git LFS behave as you configured them. - Keeping and cleaning up. The worktree and branch are kept after every run, successful or not; tinyagent never commits, merges or pushes. Review it with
git -C <path> diff, then remove it withgit worktree remove <path>andgit branch -D tinyagent/<name>. - Sandbox and container. Git metadata lives in the main repository's
.gitdirectory, outside the worktree. With--sandboxit isn't writable, sogit commitinside the worktree fails unless you add that directory tosandbox_writable_paths(which also lets commands change hooks and config); with--env containerit isn't mounted, sogitcommands in the container fail. - Resuming and rewinding. Trajectories and
run.startedrecordworktree. To resume, follow up or rewind, pass the worktree path as--cwd(without--worktree).
Resume an unfinished task
# Checkpoint before and after every tool call; this run stops at 20 cumulative model steps.
tinyagent -t "fix the failing test" --cwd ~/src/project --step-limit 20 -o run.json
# Inspect the workspace (for example, git diff) and rerun relevant checks.
# Continue the same task with a ceiling of 40 total steps.
tinyagent --resume run.json --cwd ~/src/project --step-limit 40 -o continued.json
When a run with -o stops at a step, cost or time limit, is interrupted, or hits OutputTruncated, the summary on stderr is followed by a ready-to-paste command with the resolved --cwd, the limit that was hit doubled (no limit flag after an interruption or truncation), and a new -o path (-2, then -3, ... before the extension):
StepLimitExceeded after 20 steps in 4m12s (48,213 input / 3,100 output tokens, $0.41)
resume: tinyagent --resume run.json --cwd /home/me/src/project --step-limit 40 -o run-2.json
It isn't printed with --json, without -o, or for --output-schema runs.
--resume <trajectory.json> is noninteractive and uses the saved task and model. Do not pass --task for an unfinished checkpoint (it is only for follow-ups of finished runs); an explicitly different --model is rejected. The saved model wins over $AGENT_MODEL and the config's default model. Credentials and provider endpoints come from the current normal setup, never the checkpoint. Paths to checkpoints are relative to the invoking shell's directory, independently of --cwd.
An explicit --cwd must resolve to the recorded canonical root (directory symlinks are resolved). For a --worktree run, that is the path inside the worktree. The effective system prompt must match the saved prompt, including {{cwd}}/{{os}} substitutions and the recorded AGENTS.md loading mode. Current config is used to reconstruct it without appending AGENTS.md twice. Changed instructions, a conflicting --no-agents-md, or changed tool definitions/compatibility require a fresh task. In particular, a task that edits its effective AGENTS.md cannot resume under changed instructions.
Only new-format, unfinished checkpoints are eligible: an in-progress save (including one left by a crash or kill -9) or a stop caused by a step/cost/time limit, interruption, or error. Successful Submitted/Completed runs are not resumed this way; continue them with a follow-up task instead (see below). Legacy logs, unsupported versions/state, and malformed or partial tool turns are refused before provider setup. Checkpoint loading is limited to regular files of at most 64 MiB. Larger trajectories remain audit logs.
With -o, the checkpoint is written before tools run (recording the calls as pending_calls) and again after each tool result. If the process dies mid-turn, --resume gives every call without a result the error result [interrupted: tool did not complete before the agent stopped; it may have partially run — verify state before retrying] and emits resume.interrupted_call. Interrupted calls are never re-executed automatically: a half-run bash or edit may have had side effects, so the model decides what to inspect or retry. Background processes started with start_process are recorded with their PID; they do not survive into a new invocation, so resume emits resume.process_lost and tells the model which ones are gone.
Compacted runs resume too. The trajectory always keeps the full history; the model's compacted view is rebuilt from the last compactions record (its message map, elisions, and summary) plus the messages after it, so the resumed request is the same one the original process would have sent. --no-compact is no longer needed to keep a run resumable.
A distinct -o/--output is required, and any existing destination is refused, including symlink and hard-link aliases of the source. The input stays unchanged. The validated starting state is saved to the new destination before the first request; completed tool turns and termination then replace it using a private temporary file. Initial publication requires filesystem hard-link support to publish atomically without replacing another file. A save failure in resume mode stops further work, prints an error, and exits nonzero, preserving the last complete checkpoint. Ordinary fresh-run save failures retain their existing warning behavior. Each save writes a private temporary file, fsyncs it, renames it over the checkpoint, and fsyncs the directory (where the platform supports it), so a crash or power loss leaves either the previous or the new complete checkpoint, never a partial one.
Steps, cost, token counts, and cache totals remain cumulative. Step/cost limits are cumulative ceilings: if already exhausted, no model request is made; increase the ceiling explicitly (or set it to 0 for unlimited). Runtime limits, tool timeouts, output settings, and compaction settings use normal config/flag precedence. Wall time and the stderr elapsed duration restart for each invocation; stopped time is not charged. Normal streaming, summaries, and success/failure exit codes still apply. The next action is a new model query, which incurs ordinary context/token costs; loading a checkpoint never dispatches historical tool calls.
New JSON keeps the existing top-level trajectory fields and adds versioned metadata: schema_version (currently 2; version 1 files still load), root, system_prompt, skip_agents_md, tools, and tool_signature. Version 2 adds pending_calls, processes, plan_enabled, plan, and plan_history; runs with skills enabled also record skills_enabled and skills (name, scope, and path of each discovered skill). resume records the input path, previous status/submission, cumulative step, and continuation-message index, plus follow_up: true for a follow-up boundary; nested previous entries retain earlier resume boundaries. These audit paths are not read as configuration. Metadata excludes credentials and environment dumps. Checkpoints still contain prompts, code, and tool outputs: treat them as private, trusted local execution inputs. New checkpoint and temporary files use owner-only permissions (0600) where supported.
A checkpoint restores conversation state, not files (to restore files, record the run with --snapshots and use --rewind), Git state, shell variables, live processes, or external services. The model receives a continuation notice and stderr warns to inspect and revalidate the current workspace. Commands may have produced unrecorded side effects after the last save, especially during a hard crash in a tool batch. Historical process IDs cannot reattach to children from another invocation. There is no rollback or exactly-once execution guarantee; inspect current state before resuming.
Rewind workspace changes
With --snapshots (or snapshots: true in the config), tinyagent records the workspace so you can put files back the way they were before the agent changed them. It is off by default and needs -o, because the trajectory file is the index of the snapshots.
tinyagent -t "refactor the parser" --cwd . --snapshots -o run.json
tinyagent --rewind run.json --cwd . --dry-run # preview: M/A/D lines and a count
tinyagent --rewind run.json --cwd . # back to the start of the run
tinyagent --rewind run.json --cwd . --to 4 # the state before step 4's tool batch
When snapshots are taken. A baseline after setup and before the first model request; a step snapshot before each tool batch that can change files (write_file, edit_file, apply_patch, bash, start_process, stop_process, and any MCP tool; batches of only read-only tools take none); and a final one when the run ends, whatever its status. Each is listed in the trajectory's snapshots array (snapshots_enabled is true), emitted as a workspace.snapshot event in --json and --log-db, and printed as one line with -v. With --read-only nothing can change files, so no snapshots are taken and stderr says so once.
What is in scope. Regular files and symlinks (stored as links, not followed) under --cwd, with their mode bits. Left out, and never modified or deleted by a rewind:
.git/, the.tinyagent/directory (the store and the event log), and the-otrajectory and its temp files;- paths ignored by
.gitignorefiles and.git/info/exclude, when--cwdis inside a Git repository (your global excludes file is not read); - files larger than
snapshots.max_file_bytes(default 2 MiB). The snapshot records count them and list up to 20; a rewind warns that they were "not restored".
Rewind. --rewind <trajectory> --cwd <root> needs --cwd given explicitly, resolving to the trajectory's root as for --resume. For a --worktree run, pass the worktree path. --to is start (the default: the baseline), a step number, or a snapshot id. A step number means the state before that step's tool batch; if that step has no snapshot (it only read files), the latest snapshot at or before it is used and the output says which. The plan prints one line per path, M (content or mode restored), A (created later, will be removed) and D (deleted later, will be recreated), then a count; --dry-run stops there. Otherwise tinyagent first takes a pre-rewind snapshot, records it in the trajectory file, and prints its id with the command that undoes the rewind (--rewind run.json --cwd . --to <pre-rewind-id>); then it restores each path and removes directories that only exist because of later changes. It makes no model request and needs no API key.
A rewind exits 2 before writing anything when --cwd is missing or doesn't match, --to names no snapshot, the trajectory has no snapshots, or the store is missing or corrupt. A rewind isn't atomic across files: if a write fails it stops, lists what was and wasn't restored and the pre-rewind id, and exits 1.
A rewind doesn't touch Git state (HEAD, the index, branches, stashes), so uncommitted changes you made before the run come back as they were. It restores files only: not processes, services, databases outside --cwd, or anything a command did elsewhere. With --env container, --cwd is mounted into the container, so files the container writes there are covered.
Resume. --resume keeps the source trajectory's snapshots in the new trajectory, so either file can be rewound, including to a step of the original run. Whether the resumed run takes new snapshots depends on this invocation's --snapshots/config.
Storage. Snapshots live in <cwd>/.tinyagent/snapshots/: file contents are stored once each by SHA-256, plus one small manifest per snapshot. Directories are created 0700 and files 0600, and the store has its own .gitignore so it is never committed. It holds full copies of in-scope files, secrets included, so treat it like trajectory files. Nothing is pruned automatically; deleting .tinyagent/snapshots is safe and only makes old trajectories un-rewindable. Each snapshot walks the tree, which can take a moment on large trees; duration_ms in the event shows how long.
View a run as a transcript
--transcript <trajectory> renders a saved -o file as Markdown on stdout, so you can see what an unattended run read, ran and changed, and why, without reading the raw JSON. It reads no config, needs no API key, makes no model or network request and writes no file.
tinyagent --transcript run.json | less
tinyagent --transcript run.json > run.md
What it shows. A header with the exit status, model (and the model serving at the end, after a fallback), profile, steps, token totals (cache included), cost, and the read-only, sandbox, environment, worktree and review settings. Then the task, and a ### Step N section per step, numbered as in steps: the assistant's text, each tool call with its arguments pretty-printed, and each result in a collapsed <details> block (marked when it was a format-error correction). Tool calls without a result, as in an interrupted checkpoint, are listed as such. Plan updates (with the final plan as a checklist), compactions, model switches, subagent delegations and --resume/follow-up boundaries appear where they happened. Every message is shown, including ones compaction removed from the model's view. The footer has the submission (or the --output-schema result as JSON) and the snapshot ids for --rewind. The system prompt is left out.
Safety. All text that came from the task, the model, tools, MCP servers or hooks is written inside fenced code blocks (or inline code) longer than any backtick run it contains, so it can't render as an image, link or HTML wherever the Markdown is shown. Images are never inlined: each is a line like [image: image/png, shot.png, 48,213 bytes]. Secrets are masked as [REDACTED] with the same redactor as the event log: values of environment variables named *_KEY, *_TOKEN, *_SECRET or *_PASSWORD in the environment you run --transcript in, and sk-…, ghp_…, Bearer … and xox…- shaped tokens. Redaction is best-effort: review a transcript before posting it publicly.
Size. Each tool result, tool call's arguments, user message (the task included), plan diff and compaction summary is cut to its first 2,000 characters, with a [... N characters omitted ...] notice. Assistant text and the submission are shown whole.
In CI. Append it to the GitHub Actions job summary (the GitHub Action does this with job-summary: true). A job summary is limited to 1 MiB per step and fails to upload above that, so very long runs may not fit:
- run: tinyagent --profile ci -t "fix the failing test" -o run.json
- if: always()
run: tinyagent --transcript run.json >> "$GITHUB_STEP_SUMMARY"
Any trajectory tinyagent writes renders, finished or not, including old files and ones --resume would refuse; fields it doesn't know are ignored. A missing, non-regular, empty or over-64-MiB file, invalid JSON or an unsupported schema_version exits 2 with one error line and nothing on stdout. --transcript can't be combined with -t, --resume, --rewind, --check, --json, -o or the other mode flags (exit 2).
Continue a finished run with a follow-up task
A run that finished successfully (Submitted or Completed) can be continued with a new instruction by passing -t with --resume. The model sees the whole previous conversation, so it keeps its earlier findings without re-reading the code:
# Stage 1: review and report.
tinyagent -t "review internal/agent for data races; report them" --cwd . -o review.json
# Stage 2: act on the findings in the same conversation.
tinyagent --resume review.json -t "fix the races you found" --cwd . -o fix.json
# Or read the follow-up from stdin.
cat next.md | tinyagent --resume fix.json -t - --cwd . -o next.json
- A finished checkpoint requires a non-empty follow-up;
--resume finished.jsonwithout-texits 2 and says so. An unfinished checkpoint still refuses-t(exit 2): continue it with plain--resume. - Every other resume rule applies unchanged: the same model, root, system prompt, tools and loading mode, a distinct new
-o, the unchanged source file, and every refusal before provider setup. Runs with--output-schemaor MCP tools, runs continued bystophooks, andVerificationFailedruns are not eligible. - The request is the saved history (or the rebuilt compacted view) followed by one user message: a notice that the previous invocation finished and the workspace may have changed, then the follow-up text. No historical tool call is re-executed. The trajectory's top-level
taskstays the original task; the follow-up text is recorded in that message. - Steps, cost, and tokens stay cumulative, so
--step-limitand--cost-limitcount the earlier runs too. If the ceiling is already used up, no request is made and the run endsStepLimitExceededorCostLimitExceeded; raise the ceiling for the follow-up. - Follow-ups chain: a follow-up's finished result can take another follow-up, and a follow-up stopped by a limit continues with plain
--resume. Each boundary is recorded inresumewithfollow_up: true, and in--jsonrun.startedcarriesresumed: trueandfollow_up: true. - Checkpoints with a follow-up boundary use the existing schema version; older tinyagent binaries refuse them with an unknown-field error rather than misreading them.
- A
NeedsInputrun is answered the same way, but the answer is delivered differently; see Asking for input.
Asking for input
--ask-user (or ask_user.enabled: true) gives the model an ask_user tool for headless runs. When information that blocks the task is missing (which of two APIs to change, whether a breaking change is OK), the model can call it with one question instead of guessing. The run then ends cleanly with status NeedsInput and exit code 4; nothing waits on a terminal, so nothing can hang in CI. Whoever started the run answers later with --resume:
tinyagent --ask-user -t "add rate limiting to the API" -o run.json
# stderr:
# NeedsInput after 6 steps in 41s (...): Should the limit be per API key or per client IP?
# options: per API key | per client IP
# answer with: tinyagent --resume run.json --cwd /repo -t "<answer>" -o run-2.json
tinyagent --resume run.json --cwd . -t "per API key" -o run-2.json
- The tool.
ask_usertakes aquestion(required, non-empty, at most 2,000 characters) and optionaloptions: 2 to 6 distinct suggested answers of at most 200 characters each. The person may answer with any text. A short notice in the system prompt tells the model to ask only when it is blocked and the answer isn't in the workspace, and otherwise to proceed and state its assumptions. - Ending the run. A valid
ask_usercall that is the only tool call in its message ends the run. The call is saved in the trajectory as pending (pending_calls), with the question inpending_question(tool_call_id,question,options). The question goes to stdout (soq=$(tinyagent ...)captures it) and, with the options and a ready-to-paste--resumecommand, to stderr. With--json, the finalresultevent hasstatus: "NeedsInput",submissionset to the question, and aquestionobject;--log-dbstores the same, and at levelmetadatait keeps only the question's length. No hooks run forask_user, and stop hooks don't run for aNeedsInputend. - Answering.
--resume <trajectory> -t "<answer>"(or-t -for stdin) sends the saved history and delivers the answer as the result of the pendingask_usercall, after a short notice that the workspace may have changed; no user message is added, so the model sees the answer attached to its own question. Without-t, or with an empty answer,--resumeexits 2 and prints the pending question;--imagecan't be attached to an answer. Every other resume rule applies: the same model, root, system prompt and tools (a conflicting--ask-userexits 2), a new-o, and cumulative steps, cost and tokens. The boundary is recorded inresumewithanswer: true. - Misuse.
ask_usertogether with other tool calls in one message runs none of them; each gets an error asking the model to callask_useralone. Malformed arguments (missing or emptyquestion, wrong types, too few, too many or duplicateoptions) are format errors. Afterask_user.max_questionsquestions (default 3, counted across the whole resume chain and recorded asquestions_asked), further calls get an error telling the model to proceed on stated assumptions, and the run continues. - Where it applies. It is offered with
--read-onlyand in review mode, never to subagents.--ask-userwithout-o, or with--output-schema, exits 2 before any request, since the question could never be answered. With it off, tools, prompts, trajectories, events and exit codes are unchanged; older tinyagent binaries refuseNeedsInputcheckpoints rather than misreading them.
The question is model-written text that can quote workspace files or fetched pages; treat it as untrusted if you forward it to other systems.
Project instructions (AGENTS.md)
Unless --no-agents-md is set, tinyagent looks for AGENTS.md starting at --cwd and walking up through its parents, stopping at the repository root (the nearest directory containing .git). The nearest file wins, and only that one is added to the system prompt. Without a .git above --cwd, only <cwd>/AGENTS.md is checked. The lookup never goes above the repository root, and -v prints which file was loaded. This only changes the prompt: tools are still confined to --cwd.
Scoped AGENTS.md in subdirectories
Monorepos often keep instructions next to the code they cover, such as services/api/AGENTS.md. With --scoped-agents-md (or scoped_agents_md: true), tinyagent also delivers those files, lazily: the first time a successful built-in file tool call touches a path below --cwd, every AGENTS.md in a directory between --cwd (exclusive) and that path is appended to the call's result, outermost first. The startup file above stays in the system prompt and is never sent again. It is off by default; with it off, prompts, tool results, and checkpoints are unchanged.
[AGENTS.md for services/api/ (812 bytes): instructions for files under services/api/; they take precedence over broader project instructions there]
...file contents...
[end AGENTS.md for services/api/]
- Triggers:
read_file,write_file,edit_file,view_image,lsp, every path of anapply_patch(including move targets), and the directory argument oflist_filesandgrep(sogrepfrom--cwditself triggers nothing).bash, background processes, MCP tools, and failed or denied calls never trigger delivery. - Once per context: a file is sent once while its block is still in the model's context. If compaction elides or summarizes that result, the file is sent again on the next access; it is not pinned. Subagents start with nothing delivered, and
--resumedoesn't resend files the checkpoint already has in context. - Limits: each file is capped at 32 KiB (cut on a UTF-8 boundary, with
[AGENTS.md truncated]), and a run delivers at most 64 KiB of distinct scoped files; past that, the result gets a one-line notice instead and stderr a warning. Blocks are added aftermax_output_charstruncation and hook feedback, so they are never cut off, andpost_toolhooks see them. - Safety: files denied by
file_accessand symlinks resolving outside--cwdare skipped (-vprints a line). A file modified after the run started (by the model or anything else) is not delivered, so the model can't inject instructions into its own context. Empty, unreadable, or non-UTF-8 files are skipped with a warning; the run continues. - Records:
-vprints each loaded file,--jsonemitsinstructions.loadedandinstructions.skipped, and-otrajectories recordscoped_agents_md_enabled,scoped_agents_md(files in context) andscoped_agents_md_loads.--resumekeeps the checkpoint's setting; a conflicting--scoped-agents-mdexits 2.--no-agents-mdturns scoped delivery off too, and combining the two flags exits 2.
Trust: like the startup file, a scoped AGENTS.md is prompt input that can steer the agent, so only enable this for repositories you trust. Configs with scoped_agents_md fail to load on older tinyagent versions, which reject unknown keys.
Agent Skills
With --skills (or skills: true), tinyagent discovers Agent Skills: folders containing a SKILL.md with YAML frontmatter (name and description) and Markdown instructions, optionally with scripts/, references/, or assets/. Skills are off by default.
- Locations:
.agents/skills/<name>/SKILL.mdin--cwdand each parent up to the repository root (same walk as AGENTS.md), then~/.agents/skills/<name>/SKILL.md. Symlinked skill folders are followed..claude/skillsis not scanned; symlink those skills into.agents/skillsto use them. - Precedence: on a name clash the nearest project directory wins, and project skills beat user skills; each shadowed skill produces a warning naming both paths.
- Validation: a skill with no description or unparseable frontmatter is skipped with a warning. Name problems (wrong format, mismatch with its folder) only warn. A
SKILL.mdover 1 MiB is skipped. - Progressive disclosure: only the names and descriptions go into the system prompt (a "Skills" section, capped at 8,000 characters: descriptions are shortened first, then skills are omitted with a warning). The
skilltool loads one skill's instructions (frontmatter stripped) wrapped in<skill_content name="...">, with its directory and up to 100 bundled file paths.{"name": ..., "file": "references/x.md"}reads one bundled text file (1 MiB cap; no absolute paths,.., or symlinks that leave the skill folder; binaries refused). Results also honormax_output_chars. Loading a skill that is still in context returns a short note instead of a second copy. - Other tools are unchanged:
read_fileand friends stay confined to--cwd, so user skills are reachable only through theskilltool. Run bundled scripts withbashas usual. - Read-only and hooks: the
skilltool only reads, so it is available with--read-only.pre_tool/post_toolhooks match it asskill. - Compaction: loaded skill instructions are never elided by stage-1 compaction. When old turns are summarized, the latest instructions of each loaded skill are re-attached after the summary (up to 32 KiB combined; any skill left out is named so the model can reload it).
- Resume: the checkpoint records whether skills were enabled and which were found.
--resumekeeps that setting (passing a conflicting--skills/--no-skillsis an error) and refuses to continue if the skills catalog changed. Checkpoints from before this feature resume with skills off. - With no skills found, the prompt and tool list are exactly as without
--skills.-vprints each loaded skill and its path; warnings go to stderr and, with--json, aswarningevents.
Trust: skill instructions are prompt input. A project skill from a repository you don't trust can steer the agent like a malicious AGENTS.md, and bundled scripts run with your permissions if the agent invokes them. Skill text sent to the model goes to your model provider like any other prompt, and the trajectory records loaded skill content.
Editor integration (ACP)
tinyagent --acp speaks the Agent Client Protocol (protocol version 1, newline-delimited JSON-RPC on stdin/stdout), so any ACP client can run tinyagent as an external agent: you type in the editor's agent panel, tinyagent runs its normal loop in the session's directory, and the editor shows the model's text and each tool call live. The mode is experimental: the stop-reason mapping and the MCP default below may change based on feedback.
Zed (settings.json):
{
"agent_servers": {
"tinyagent": {
"type": "custom",
"command": "/usr/local/bin/tinyagent",
"args": ["--acp", "-m", "anthropic/claude-sonnet-4-5"],
"env": { "ANTHROPIC_API_KEY": "..." }
}
}
}
JetBrains IDEs (2025.3+, ~/.jetbrains/acp.json):
{
"agent_servers": {
"tinyagent": {
"command": "/usr/local/bin/tinyagent",
"args": ["--acp", "--profile", "editor"],
"env": {}
}
}
}
Use the full path to the binary. Pass -m, -c, --profile and any policy flags (--read-only, --sandbox, --env, --command-env, --add-dir-*, --plan, --apply-patch, --subagents, --web-fetch, --lsp, --images, --tool-output-files, --skills, --scoped-agents-md, --redact-secrets, --log-db, -v) in args. They mean the same as in a headless run.
How it works
session/newtakes the editor's absolutecwdas the session's root (AGENTS.mddiscovery runs as for--cwd) and checks the config, model and provider key there. A relative or missing directory, an invalid config or a missing API key is returned as a JSON-RPC error with the same message andhint:a headless run prints. Key values are never printed. A process can run several sessions.- The first
session/promptis the session's task. Each later prompt continues the same conversation, sending the provider exactly what--resume <trajectory> -t "<follow-up>"would. This also works after a turn that was cancelled or hit a limit. A second prompt while one is running in the same session gets a JSON-RPC error. - Prompt content: text blocks are joined; embedded text resources are included as quoted context naming their URI; resource links become a reference line and are not fetched; images are attached like
--image(PNG, JPEG, GIF or WebP, at most 10, each withinmax_image_bytes). Audio, binary resources and invalid images get an invalid-params error. - Streaming: assistant text arrives as
agent_message_chunk(without the submit marker). Each tool call is atool_callwith the model's call id, a short title, akind(read_file→read;write_file/edit_file/apply_patch→edit;grep/list_files→search;bash/start_process→execute;web_fetch→fetch; others →other) and absolutelocationsfor file tools. It is followed bytool_call_updates toin_progressand thencompletedorfailed, carrying the same result text the model sees, aftermax_output_charsand secret redaction.update_plansendsplanupdates, and each model call sends ausage_updatewhen the context window is known. - Stop reasons:
Submitted/Completed→end_turn,StepLimitExceeded→max_turn_requests,OutputTruncated→max_tokens,session/cancel→cancelled. Any other status (CostLimitExceeded,TimeLimitExceeded,RepeatedFormatError,VerificationFailed,Error) ends with a final message naming it andend_turn, and the session stays usable. A provider error a headless run would print a setuphint:for is returned as a JSON-RPC error instead. session/cancelstops the turn the way Ctrl-C does: in-flight requests and commands are cancelled, and background processes and language servers are stopped, as at the end of every turn. Closing stdin cancels running turns, stops MCP servers and exits 0.--step-limitand--cost-limitapply to each prompt (on top of what the session has already used), and--time-limitlimits each prompt's wall-clock time.--log-dbrecords each prompt as a run, with anacp.turnevent naming the session id and turn number. No trajectory files are written.- stdout carries only ACP messages. Warnings, the per-turn summary and
-vtraces go to stderr, which editors show in their agent logs.
Client-supplied MCP servers are off by default: tinyagent starts only the mcp_servers from your config, and a session's first reply names any servers the editor offered. Set acp.client_mcp_servers: true in the user config or -c file to start the editor's stdio servers for that session, with every tool they list exposed as <name>__<tool> and their env values set (never logged). HTTP and SSE servers are not supported (mcpCapabilities.http is false). Read-only sessions start none.
Not supported: session/load, session modes and config options, slash commands, per-tool approval through session/request_permission, delegating files or commands to the client (fs/*, terminal/*; tinyagent uses its own tools on disk), audio prompts, ask_user (it is turned off in this mode), workspace snapshots and reasoning (agent_thought_chunk) streaming.
Trust model: the editor controls the prompts and the cwd, so it has the same power as whoever runs tinyagent -t in that directory. Everything that limits what tinyagent may do (the tool set, --read-only, --sandbox, file_access, command_env, hooks and limits) comes from your config and flags, and ACP adds no way to loosen it. Repository content stays untrusted prompt input, as in any run.
GitHub Actions
action.yml at the root of this repo is a composite action. It installs a tinyagent release on a Linux or macOS runner (amd64 or arm64), checks it against the release's SHA256SUMS, runs one task or review, and sets the result as step outputs. It adds no agent behavior: every input maps to an existing flag.
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- id: agent
uses: vicgupta/tinyagent@v0.5.0
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
with:
model: anthropic/claude-sonnet-4-5
task: Fix the failing test in ./internal/parser.
job-summary: true
Pinning. Under a vX.Y.Z tag the action installs the tinyagent release with the same tag, so @v0.5.0 runs tinyagent v0.5.0. Under any other ref (a branch or a commit SHA) set version (vX.Y.Z, or latest to opt into the newest release) or tinyagent-path, or the step fails. A tag can be moved; for a fixed action pin to the tag's full commit SHA and set version: uses: vicgupta/tinyagent@<sha> # v0.5.0 with version: v0.5.0.
Read-only PR review with a findings schema. Review flags imply --read-only, so the agent can't edit files or run commands:
on: pull_request
permissions:
contents: read
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- id: review
uses: vicgupta/tinyagent@v0.5.0
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
with:
model: anthropic/claude-sonnet-4-5
review-base: origin/${{ github.base_ref }}
output-schema: .github/tinyagent/findings.schema.json
config: .github/tinyagent/ci.yaml
job-summary: true
- run: jq . <<<"$FINDINGS"
env:
FINDINGS: ${{ steps.review.outputs.structured-output }}
Inputs. task or task-file (one is required unless review-base or review-commit is set; with a review it adds focus), model (-m), profile, config (-c), working-directory (--cwd, default the workspace), review-base, review-commit, read-only and sandbox (true/false, default false), output-schema, args (extra flags, one argv element per line, never shell-evaluated or split), version, tinyagent-path (use an existing binary instead of downloading), job-summary (default false) and fail-on-error (default true). The task reaches tinyagent on stdin (-t -) and every input through env:, so text like $(...) or backticks is never run by a shell.
Outputs. status, submission, structured-output (compact JSON, set only for a Submitted run with output-schema), steps and cost-usd, all from the final result event; exit-code (always set once tinyagent ran); and trajectory and events, the paths of the -o file and the saved --json stream under $RUNNER_TEMP. They are not uploaded; add an artifact step if you want them. Each value is written to $GITHUB_OUTPUT behind a random delimiter, and the step fails instead of setting a value that contains it, so a submission can't forge other outputs. While tinyagent runs, workflow commands (::add-mask::, ::error:: ...) in its log output are disabled.
Failing the step. A non-zero exit status (1, 3 OutputTruncated, 4 NeedsInput) fails the step unless fail-on-error: false, in which case later steps can read exit-code and status. A usage error (exit 2), a bad input, or an install failure (unsupported runner, missing asset, checksum mismatch) always fails the step, before any model request.
Job summary. job-summary: true appends the --transcript rendering to the job summary, also when the run failed. It is fenced and redacted but still model-authored. Over GitHub's 1 MiB step-summary limit, a one-line notice is appended instead.
Requirements. jq (preinstalled on GitHub-hosted Linux and macOS runners) and bash. Windows runners are not supported.
Untrusted input
Issue bodies, PR titles, diffs and commit messages can carry prompt injection. The action keeps them out of the shell, but it can't stop the model from following instructions in them, so limit what a hijacked run can reach:
-
tinyagent and its commands can read anything in their environment, including the provider API key, unless a command environment policy excludes it. Use a config such as:
# .github/tinyagent/ci.yaml command_env: inherit: core exclude_secrets: true -
Prefer
review-base/review-commitorread-only: true, which have no command or write tools. Addsandbox: truefor runs that do execute commands. On Linux it needs Landlock (kernel 6.2+, 6.7+ to deny TCP), and a network allowlist also needs unprivileged user namespaces, which Ubuntu 23.10+ can restrict with AppArmor. This hasn't been checked on GitHub-hosted runners; tinyagent fails closed and says what is missing if the runner can't enforce it. -
Set minimal
permissions:and don't putGITHUB_TOKENin the action'senv:; the action never passes it on itself. -
Don't run on
pull_request_target(orworkflow_run) with a checkout of the PR's code. -
Treat every output and the job summary as untrusted: pass them to later steps through
env:, never${{ }}insiderun:.
See GitHub's Secure use reference.
Config
Run tinyagent --init-config to create a commented starter config at the default path below, or tinyagent --init-config -c PATH to choose a path; it creates parent directories, writes with mode 0600, refuses existing files (exit 2), and makes no model request. It cannot be combined with -t, --resume, --rewind, or --update.
Without -c, tinyagent loads a per-user config file if one exists, so settings you always want don't need a flag:
| OS | Default path |
|---|---|
| Linux and other Unix | $XDG_CONFIG_HOME/tinyagent/config.yaml (usually ~/.config/tinyagent/config.yaml) |
| macOS | ~/Library/Application Support/tinyagent/config.yaml |
| Windows | %AppData%\tinyagent\config.yaml |
The lookup order is: -c PATH if given (a missing or unreadable file is an error, with no fallback); otherwise the user config above, if it exists; otherwise the built-in defaults. A user config that exists but can't be read or parsed is an error naming the file, not skipped.
Every key is optional, but an unrecognized key (a typo like step_limt) is an error naming the file and the key, rather than silently ignored. Command-line flags override the file.
Set read_only: true to make repository analysis the default. --read-only overrides the config value, including --read-only=false. In this mode, executor dispatch allows only read_file, list_files, and grep; writes, edits, shell commands, and background process tools are denied even if task text or repository instructions request them. Denials are returned to the model as tool errors. The restriction is model tool authority, not an OS sandbox (for that, see --sandbox under Sandboxing commands): an explicit -o trajectory destination can be written by the CLI, provider requests still send their context to the selected service, and the Codex provider may refresh its credential file, and the opt-in web_fetch tool, which only reads allowlisted URLs, stays available when enabled, as does the read-only lsp tool (its servers are still started and write logs under .tinyagent/lsp). --update is an administrative action outside task-tool policy. Readable secrets (unless hidden with file_access rules), symlinks that point outside --cwd, existing lexical path behavior, and AGENTS.md discovery retain their existing limitations; read-only mode does not make an untrusted repository safe.
model saves a default model, so tinyagent -t "fix the failing test" needs no -m. The model is chosen in this order: -m/--model if given (even -m openai/gpt-4o), then a nonempty $AGENT_MODEL, then a nonempty model in the config, then openai/gpt-4o.
model: ollama/llama3.1 # same <provider>/<model> syntax as -m
system_prompt: | # {{cwd}} and {{os}} are substituted
You are a coding agent...
read_only: true # expose only read_file, list_files, and grep
sandbox: false # OS-confine bash/start_process; see Sandboxing commands
# sandbox_allowed_domains: [proxy.golang.org] # with sandbox, the only hosts commands may reach
environment: local # or container; see Execution environments
command_env: # omitted by default (inherit everything); see Command environment
inherit: core # all, core or none
exclude_secrets: true
file_access: # omitted by default (no rules); see Restricting file access
deny_read: [".env", "*.pem", "secrets/"]
deny_write: [go.sum, "vendor/"]
redact_secrets: # mask known secret values in tool output; see Redacting secrets from tool output
enabled: false # same as --redact-secrets
env: [DATABASE_URL] # more variables (names or globs) whose values are secrets
allow: [PUBLIC_KEY] # secret-named variables not to mask
patterns: false # also mask token shapes (sk-..., ghp_..., Bearer ..., xox?-...)
additional_dirs: # omitted by default (only --cwd); see Additional directories
- {path: /home/me/src/shared-lib, mode: read}
step_limit: 50
cost_limit: 2.0
wall_time_seconds: 1800
max_format_errors: 3 # consecutive malformed-call steps before RepeatedFormatError (>= 1)
bash_timeout_seconds: 120
max_output_chars: 30000 # tool output is truncated, keeping head and tail
max_output_tokens: 0 # per-request model output budget; 0 = provider default (8192 on anthropic)
max_truncations: 3 # consecutive cut-off responses before OutputTruncated (>= 1)
max_background_processes: 4 # start_process processes running at once (0 uses 4)
input_price_per_mtok: 3 # override the built-in price table
output_price_per_mtok: 15
context_window: 200000 # tokens; overrides the built-in table, used for compaction
plan:
enabled: false # give the model the update_plan tool (same as --plan)
skills: false # discover Agent Skills and offer the skill tool (same as --skills)
apply_patch: false # offer the apply_patch tool (same as --apply-patch)
scoped_agents_md: false # deliver AGENTS.md files below --cwd on first access (same as --scoped-agents-md)
subagents:
enabled: false # offer the delegate tool (same as --subagents)
model: "" # child model, e.g. openai/gpt-5-mini; empty uses the parent model
max_steps: 30 # step cap per delegated task; 0 means the default (30)
ask_user:
enabled: false # offer the ask_user tool (same as --ask-user); see Asking for input
max_questions: 3 # questions per run, counted across resumes; 1 to 20
web_fetch:
enabled: false # offer the web_fetch tool (same as --web-fetch)
allowed_domains: [] # hosts it may read, e.g. [pkg.go.dev, "*.python.org"]; required when enabled
max_bytes: 2097152 # response bytes read per fetch (2 MiB); must be >= 1
timeout_seconds: 20 # per fetch, including redirects; 1 to 120
lsp: # language servers; see Language servers (LSP)
enabled: false # same as --lsp
diagnostics_after_edit: true # append diagnostics to edit results; false keeps only the lsp tool
diagnostics_wait_ms: 3000 # longest wait for diagnostics after an edit; 100 to 30000
max_diagnostics: 20 # per file; 1 to 200
severity: error # error, or warning (errors and warnings)
servers: [] # name, command, args, extensions, env, initialization_options, startup_timeout_seconds
images:
enabled: false # view_image tool and MCP image pass-through (same as --images)
max_image_bytes: 5242880 # largest image sent to the model (5 MiB); must be >= 1
tool_output_files: # save over-limit tool output; see Tool output files
enabled: false # same as --tool-output-files
dir: .tinyagent/tool-output # relative to --cwd; must stay inside it
max_runs: 20 # keep this many earlier run directories; 0 keeps all
snapshots: # workspace snapshots for --rewind (same as --snapshots); `snapshots: true` also works
enabled: false
max_file_bytes: 2097152 # larger files are skipped and never restored (2 MiB); must be >= 1
log_db: # opt-in SQLite event log; see Event log database
enabled: false
path: .tinyagent/tinyagent.db # relative to --cwd
level: metadata # metadata (no bodies) or full (redacted bodies)
max_days: 30 # prune runs older than this; 0 keeps all
max_runs: 500 # keep at most this many runs; 0 keeps all
max_payload_bytes: 65536
reasoning_effort: medium # optional; see Reasoning; --reasoning-effort wins
fallback_models: [] # optional, at most 3; see Model fallback; --fallback-model replaces it
openai_api: auto # openai provider transport: auto, responses, or chat; --openai-api wins
providers: # omitted by default; see Named providers
- id: gateway # use as -m gateway/<model>
api: chat # chat, responses, or anthropic
base_url: https://llm.internal.example.com/v1
api_key_env: GATEWAY_API_KEY # optional; names only, never the key itself
models:
vendor/model-x: {input_price_per_mtok: 2, output_price_per_mtok: 8, context_window: 128000}
mcp_servers: # omitted by default; each entry runs a trusted local executable
- alias: docs
command: /usr/local/bin/team-docs-mcp
args: [--stdio]
tools: [search, fetch] # explicit authorization; all entries are required to exist
resources: ["docs://guides/*", "schema://db/orders"] # optional resource URI allowlist; see Local MCP tools
env: [TEAM_DOCS_TOKEN] # names only; tinyagent copies their current values to the child
discovery_timeout_seconds: 15
call_timeout_seconds: 60
command_tools: # omitted by default; see Command tools below
- name: run_tests
description: Run Go tests for the given package patterns. Use instead of bash for tests.
parameters: # JSON Schema, same subset as --output-schema
type: object
properties:
packages: {type: array, items: {type: string, pattern: "^\\./[A-Za-z0-9_./-]*(\\.\\.\\.)?$"}, minItems: 1}
required: [packages]
additionalProperties: false
argv: [go, test, -count=1, "{{packages}}"] # no shell; each placeholder is a whole element
timeout_seconds: 300 # default bash_timeout_seconds, range 1..600
dir: . # optional, relative to --cwd and inside it
hooks: # omitted by default; see Tool hooks below
pre_tool:
- match: "bash|start_process" # Go regexp matched against the whole tool name; omit to match every tool
command: /usr/local/bin/tinyagent-policy
args: [--profile, ci]
env: [POLICY_TOKEN] # names only, as in mcp_servers
timeout_seconds: 10 # default 30, range 1..600
on_error: deny # pre_tool only; deny (default) or allow
post_tool:
- match: "write_file|edit_file"
command: /usr/local/bin/fmt-check
timeout_seconds: 60
stop: # runs before a successful finish; no match or on_error
- command: /usr/local/bin/verify-tests
timeout_seconds: 300
max_stop_continuations: 5 # default 5, range 1..50
acp: # --acp editor mode; see Editor integration (ACP)
client_mcp_servers: false # start the MCP servers the editor sends with session/new
profiles: # omitted by default; named overlays selected with --profile, see Profiles below
review:
model: openai/gpt-5-mini
read_only: true
Profiles
profiles holds named overlays in the same config file tinyagent already loads (the user config, or the -c file). --profile <name> applies one of them over the file's base settings for that run, so you can switch between setups such as a cheap read-only review, CI with a sandbox and stop hooks, or a local model with one flag. A profile only lists the settings that differ from the base, and it can set anything the file can, including config-only settings like command_env, file_access, hooks, mcp_servers, lsp and providers.
model: anthropic/claude-sonnet-4-5
cost_limit: 5
# providers, mcp_servers, ... defined here are shared by every profile
profiles:
review:
model: openai/gpt-5-mini
read_only: true
cost_limit: 0.5
ci:
sandbox: true
command_env: {inherit: core}
step_limit: 80
hooks:
stop:
- command: /usr/local/bin/verify-tests
timeout_seconds: 300
tinyagent --profile review -t "review this change"
tinyagent --profile ci -t - -o run.json --json < task.md
- Precedence, lowest to highest: built-in defaults, the base config file, the selected profile, explicit flags. A profile's
modeltakes the place of the configmodelin the usual model order, so-mand a nonempty$AGENT_MODELstill win over it. - Merging: mappings merge key by key, recursively, so a profile with
web_fetch: {enabled: true}keeps the baseweb_fetch.allowed_domains. Scalars and lists replace the base value wholesale (a profile'sfallback_modelsormcp_serversreplaces the base list;hooks: {stop: []}drops the base stop hooks). A value of a different kind replaces the base value, so a profile can use thesnapshotsmapping over a basesnapshots: true. An explicitnullin a profile is an error; it can't delete a base key. - Validation: the merged result goes through every existing check, and errors name the file and the profile. Every profile is checked on every run, selected or not: profile names may use only letters, digits,
_and-; each profile must be a mapping; profiles can't be nested; and an unknown key inside any profile is an error naming the file, the profile and the key. These are usage errors (exit 2) before any model request, as are--profilenaming an undefined profile (the error lists the defined ones) and--profilewith no config file in use. - Without
--profile,profiles:is validated and otherwise ignored; runs behave exactly as if it weren't there. There is no default profile. - With
-c, profiles come only from that file; the user config isn't read, as usual for-c. - Discovery:
tinyagent --list-profiles [-c <file>]prints the profile names in the selected config file (or the user config without-c), sorted, one per line. It makes no model request and needs no credentials. No config file, noprofiles:section, or an empty mapping prints nothing and exits 0; a missing or unreadable file, malformed YAML, or an invalid profile exits 2 with nothing on stdout. It can't be combined with-t,--profile, or another mode (--check, review,--resume,--rewind,--acp,--transcript,--completion,--init-config,--update). - Recording: the profile name (never its settings) is recorded as
profilein the trajectory (-o) and inrun.started, and-vprints oneconfig profile:line. Without--profileneither has the field.--check --profile <name>checks and reports the merged configuration. - Resume:
--resumeaccepts--profilefor the settings it re-reads from the current setup (credentials, providers, limits, fallbacks). The saved model still wins, and the original run's profile is not re-applied automatically; the resumed trajectory records the profile given to the resuming run.--rewindreads no config and rejects--profile(exit 2);--updateand--versionignore it.
Configs with profiles fail to load on tinyagent versions without this feature, since older versions reject the unknown key.
Named providers
providers defines extra endpoints you can select with -m <id>/<model>, such as an internal gateway, a self-hosted server or another OpenAI-compatible service, without setting OPENAI_BASE_URL. Everything after the first / is sent to the endpoint as the model name, so -m gateway/vendor/model-x sends vendor/model-x.
providers:
- id: gateway
api: chat # chat (POST <base_url>/chat/completions), responses (POST <base_url>/responses), or anthropic (POST <base_url>/v1/messages)
base_url: https://llm.internal.example.com/v1
api_key_env: GATEWAY_API_KEY # optional; sent as Authorization: Bearer (x-api-key for anthropic)
headers: {X-Team: core} # optional static, non-secret headers
env_headers: {X-Tenant: GATEWAY_TENANT} # optional headers whose values come from environment variables
models: # optional per-model metadata
vendor/model-x:
input_price_per_mtok: 2
output_price_per_mtok: 8
cache_read_multiplier: 0.1 # optional; price of cached input relative to input
context_window: 128000 # used for compaction
idmust be unique, contain no/, and not be a built-in provider (openai,openrouter,anthropic,ollama,codex); built-in names always mean the built-in provider.apimust bechat,responsesoranthropic, andbase_urlan absolutehttporhttpsURL without credentials. Prices must be non-negative andcontext_windowpositive. A config that breaks these rules is rejected before any request, naming the file and theproviders[i]entry, with exit status 2. A misspelled key is rejected like any other unknown config key.- Secrets stay in the environment.
api_key_envandenv_headersname variables; tinyagent reads their values at startup and exits 2, before any request, if one is unset or empty. Staticheadersmay not carry credentials (Authorization,x-api-key,api-key). Key and header values never appear in stderr,-v,--json, the trajectory or the event log database, which also redacts them if they show up in tool output. Withoutapi_key_env, no key is sent. - Metadata matches the model name exactly. Top-level
input_price_per_mtok,output_price_per_mtokandcontext_windowstill win over provider metadata. A model with no price behaves like an unknown built-in model: it costs $0 and, with a positive--cost-limit, prints the "no pricing available" warning. Acache_read_multiplierwithout prices refines the built-in price for that model name, if there is one. -vprintsprovider: <id> (api <api>, base_url <url>). A plainhttpbase URL to a host other than localhost or a loopback address prints a warning, because requests and any key travel unencrypted.run.startedin--jsonand the trajectory recordprovider_apiandprovider_base_url(never headers or keys); built-in providers leave them out.--resumeof a trajectory that used a named provider needs the same provider defined with the sameapiandbase_url; otherwise it stops before any request and tells you what changed.openai_api,--openai-apiandOPENAI_BASE_URLapply only to the built-inopenaiprovider. Configs withprovidersfail to load on tinyagent versions without this feature, since older versions reject the unknown key.
OpenAI transport
The openai provider talks to OpenAI over the Responses API (POST /v1/responses) or Chat Completions (POST /v1/chat/completions). openai_api (config) or --openai-api (flag, which wins) picks one:
| Value | Transport |
|---|---|
auto (default) |
Responses when OPENAI_BASE_URL is unset or is https://api.openai.com/v1 (a trailing / is ignored); Chat Completions for any other base URL, so OpenAI-compatible servers keep working |
responses |
Always the Responses API, at OPENAI_BASE_URL if set |
chat |
Always Chat Completions, as before this setting existed |
Newer OpenAI models only support tool calls together with reasoning on the Responses API. On Responses, tinyagent sends store: false (nothing is kept server-side), a prompt_cache_key stable for the run, flat function tools, and reasoning: {effort} when an effort is set. Reasoning items that come back with encrypted_content are kept in the assistant message (and the trajectory) and sent back before that turn's tool calls, so the model keeps its reasoning across tool calls. Cached input tokens (input_tokens_details.cached_tokens) are billed at the cache-read price. Streaming, --no-stream, retries, compaction, --output-schema, and --resume work the same on either transport.
The setting only applies to the openai provider; openrouter, ollama, and the others ignore it. An unknown value exits 2 before any request, and -v prints the resolved transport. The resolved transport is recorded as openai_api in trajectories and in run.started, and --resume keeps it, since reasoning items only replay on the transport that produced them (a different --openai-api is refused, like --model); a checkpoint from before this setting resolves like a new run. If a Chat Completions request fails with a 400 that asks for the Responses API, tinyagent suggests openai_api: responses.
Reasoning
Reasoning models return state that must be sent back during tool use. tinyagent keeps it for the run and replays it unchanged:
anthropic: when a response containsthinkingorredacted_thinkingblocks (thinking is on by default on current Claude models), all of its content blocks, including signatures, are sent back complete and in their original order in the same assistant message. Streaming and--no-streambehave the same.codex: Responses APIreasoningitems that carryencrypted_contentare sent back ininputright before the function calls and text of the same response. Items withoutencrypted_contentare dropped, because withstore: falsethey cannot be referenced.
reasoning_effort (config) or --reasoning-effort (flag, which wins) chooses how much the model reasons. When it is unset, nothing is added to requests and the model's default applies. When set, it maps to:
| Provider | Request field |
|---|---|
codex |
reasoning.effort |
openai |
reasoning.effort on the Responses API; reasoning_effort on Chat Completions (see OpenAI transport) |
openrouter |
reasoning.effort |
anthropic |
output_config.effort (thinking is not turned on explicitly) |
ollama |
not supported; exits 2 |
An unknown value exits 2 before any request. Which values a model accepts varies; a provider rejection is reported like any other model error and is not retried. The effort is recorded as reasoning_effort in trajectories and is kept on --resume (a different --reasoning-effort is refused, like --model). Higher effort means more output tokens, which providers bill and which count toward --cost-limit; on Anthropic, thinking also counts toward the request's max_tokens (8192 unless --max-output-tokens is set). Changing the effort invalidates provider prompt caches.
Reasoning state is stored in each assistant message's extra (anthropic_content, and codex_reasoning for Responses API reasoning items from both codex and openai) in trajectories and checkpoints, so --resume replays it. It may include thinking text and encrypted reasoning blobs; treat trajectory files as private. Reasoning state is not shown in the terminal or in --json events.
Model fallback
A fallback chain lets a run survive a provider outage. It is off by default. List up to 3 models in fallback_models (config) or with repeated --fallback-model flags, which replace the config list; --no-fallback clears it:
model: openai/gpt-5
fallback_models: [anthropic/claude-sonnet-4-5, ollama/qwen3-coder]
When a model request still fails after its retries with 429, a 5xx status or a network error, tinyagent switches to the next model in the list, resends the same conversation, and stays on that model for the rest of the invocation. Every other error (400, 401, 403, 404, context overflow, a failure in the middle of a streamed response, an interrupted run) is reported as before and does not switch models. When the last model also fails this way, the run ends with Error and a message naming every model tried and why.
Each switch prints model fallback: <from> -> <to> (<reason>) at step <n> on stderr, emits a model.fallback event, and is recorded in trajectories under model_switches (step, from, to, reason), along with fallback_models and active_model (the model serving the run when it was saved). model stays the primary model, and with a chain configured each assistant message records the model that produced it in extra.model. The final summary line notes the switch, for example , 1 model fallback (now anthropic/claude-sonnet-4-5).
Every entry is checked before the first request: an unknown provider, a missing API key, an entry equal to the primary model, a duplicate, or more than 3 entries exits 2. reasoning_effort and max_output_tokens apply to a fallback only when it uses the same provider as the primary. Provider-specific reasoning state (Anthropic thinking blocks, encrypted Responses reasoning) is never sent to a different provider. Prices come from each model's own pricing, and --cost-limit warns about every unpriced model in the chain.
Your conversation goes to the fallback provider. After a switch, the whole history so far (task, files the model read, tool output) is sent to the next provider in the list, so only list providers you are willing to share that data with.
--resume uses the chain from the current config and flags, not the saved one, and the resumed run starts on the primary model. Subagents (delegate) don't fall back. Older tinyagent versions reject a config file containing the fallback_models key.
Local MCP tools
MCP is disabled unless mcp_servers is explicitly configured. Each configured executable is trusted code with its own host and network permissions; this is tool preauthorization, not a sandbox or an interactive approval prompt. Configuration is read only from the user config or the explicitly selected -c file, never from the repository or model output. Tinyagent launches the command directly (no shell), with --cwd as its working directory and a minimal environment (PATH, plus the Windows process variables when present) and only the named forwarded variables. Missing forwarded variables fail before launch. Do not forward secrets unless the server needs them. Read-only runs never start MCP servers.
The Go MCP SDK v1.8.0 supports protocol revisions 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26, and 2024-11-05 over stdio. Tinyagent discovers the complete paginated catalog once per run, with a 15-second default discovery timeout, 100-page, 256-selected-tool, 64-KiB-per-schema, and 1-MiB-total-schema caps, and exposes only configured names. Tool names are sent to providers as <alias>__<original-name> and must fit provider-compatible naming rules. The accepted input schema subset supports object, array, string, number, integer, boolean and null types (a single type or an array such as ["string", "null"]), nested properties/items, required, enum, const, numeric bounds and multipleOf, length, pattern, format, item and property counts, uniqueItems, annotations (title, description, default, examples), and boolean additionalProperties. A top-level $schema declaration is dropped. Other keywords, including anyOf/oneOf/allOf and references, are rejected, and a selected tool with such a schema stops startup. Each call defaults to a 60-second timeout; a timed-out call returns an error to the model and the server stays available, while any other call failure disables that server for the rest of the run. Text content is joined with newlines, structured output is appended as JSON, unsupported content is marked as omitted, tool errors remain visible, and model-visible output is capped at 256 KiB before the normal output cap is applied. Stdio protocol data is kept separate from server stderr.
MCP resources
A server can also share read-only context as MCP resources (documentation pages, schemas, tickets). They are off unless the server has a resources allowlist of URI patterns. Each pattern is an exact URI or a prefix ending in * (docs://guides/* matches every URI starting with docs://guides/); * anywhere else, a bare *, an empty string or a duplicate exits 2 before any model request. There is no "all resources" wildcard. A server needs a nonempty tools or resources list, so a resource-only server is allowed. A server with resources must declare the MCP resources capability, or startup exits 2 naming the server. Resource lists are not fetched at startup; the allowlist is checked on every call. URIs are compared as literal strings, without normalization or decoding, and the server must still validate them.
When at least one server has resources, the model gets two read-only tools:
list_mcp_resources(optionalserver, optionalcursorfrom a previous result, which requiresserver) lists each allowlisted resource as one line withserver,uri,name, andmimeType,sizeand a description (cut to 200 characters) when present, then the resource templates whose literal prefix (up to the first{) can match a pattern, markedtemplate. Filtered-out entries are only counted ([N resources hidden by the allowlist on server docs]). A listing fetches at most 100 pages per server; a longer one ends with a note giving the cursor to continue.read_mcp_resource(serveranduri, both required) callsresources/readfor an allowlisted URI. A URI outside the allowlist returnserror: resource not allowed for server <alias>: <uri>without contacting the server. Each content item gets a[resource: <uri>, <mime>]header line; text follows in order, and binary blobs become[omitted MCP resource blob: <mime>, <n> bytes]. With--images, PNG, JPEG, GIF and WebP blobs withinmax_image_bytespass through like MCP tool images.
Both tools use the server's call_timeout_seconds and the same result handling as MCP tools: a 256 KiB cap before the normal output cap, redact_secrets, tool output files, pre_tool/post_tool hooks (matched by the names list_mcp_resources and read_mcp_resource), tool.call/tool.result events and trajectories. Server errors (such as resource not found), interactive input requests and timeouts are returned to the model as tool errors and the server stays available; a transport failure disables it, as for tools. -v prints one line per read (server, URI, bytes, time), the stderr startup line counts resource patterns, --check shows each server's tool and resource pattern counts, and run.started lists each server's patterns as mcp_resources (never contents). Resource content is untrusted input that may carry prompt injection, and it goes to the model provider and into trajectories. Read-only runs, review runs and subagents don't get these tools. MCP prompts, resource subscriptions and list-change notifications, attaching resources from the command line, and following resource_link items in tool results are not supported. Older tinyagent versions reject a config file containing the resources key.
MCP tool calls and results are stored in trajectories along with the external tool names. Trajectories from MCP-enabled runs cannot be resumed. Resuming a built-in-only checkpoint works with mcp_servers configured; the servers are not started and the run keeps the built-in tools. Trajectories and server stderr may contain sensitive data supplied by tools or the server; redact_secrets masks known secret values in tool results, but not other sensitive data or server stderr. On shutdown tinyagent closes MCP sessions and asks their processes to exit, then the SDK applies its bounded termination. Unix process descendants and Windows child-process cleanup have platform-specific behavior; tinyagent does not promise process-tree cleanup on Windows.
Command tools
command_tools gives the model your project's own actions (run the tests, regenerate code, check a migration) as named, typed tools, without writing an MCP server. Each entry has a name (^[A-Za-z][A-Za-z0-9_-]{0,63}$, not a built-in tool, MCP <alias>__<tool> or other command tool name), a description, optional parameters (a JSON Schema with type: object at the root, in the same subset as --output-schema; omitted means no arguments), an argv template, and optional timeout_seconds and dir (see the Config example). Like mcp_servers and hooks, definitions are read only from the user config or -c, never from the repository. Every definition is checked at startup, and a bad one (name collision, empty argv, a placeholder in argv[0], a placeholder for an undeclared or object-typed property, an unsupported schema keyword, a dir outside --cwd) exits 2 before the first model request, naming the tool and key. --check validates them too and prints how many are configured.
When the model calls a command tool, its arguments are checked against parameters first; if they fail, the model gets an error listing each problem and nothing runs. Then the argv template is filled in: a literal element passes through unchanged, and an element that is exactly {{name}} is replaced by that argument. A string becomes one element verbatim, a number or boolean its JSON form, an array of scalars one element per item, and a missing (or null) optional argument drops the element. Placeholders inside a larger string (--pkg={{p}}) are rejected. The command runs without a shell, so ;, $(...), backticks, quotes and newlines in an argument arrive as plain characters in one argv element.
Command tools run where bash runs and under the same controls: the execution environment (argv[0] is looked up on the container's PATH there), --sandbox, the command environment policy (argv[0] is looked up on the PATH it passes, so with inherit: none and no PATH use an absolute path), the working directory (--cwd, or dir), the timeout (which kills the process group on Unix), and max_output_chars and tool output files. The result has the bash format (<returncode> and <output>, or the timeout message); a non-zero exit is a normal result, and an executable that isn't found is an error result. The run continues either way.
They are not offered with --read-only (a forged call is rejected like any other read-only denial), and subagents never get them. Hooks match them by name, with arguments as the decoded JSON the model sent. -v prints one stderr line per call with the final argv, --json and --log-db record the usual tool.call and tool.result events, run.started lists their names as command_tools, and trajectories record each definition (name, argv template, schema_digest). With --resume, the current config's command tools apply; if they differ from the checkpoint's, stderr and the model are told, and a call to one that no longer exists gets an error result.
Security: command tools are trusted definitions that run with your privileges, like hooks and MCP servers. Schema checks limit what the model can pass; they don't make an unsafe argv safe: [bash, -c, "{{script}}"] gives back arbitrary execution. An argument starting with - is passed through as is and can be read as an option (for example --output=/etc/passwd), so constrain every placeholder with a pattern, enum or similar, as in the ^\./ pattern above. Older tinyagent versions reject a config file containing the command_tools key.
Tool hooks
Hooks are disabled unless hooks is configured. A pre_tool hook runs before a matching tool call and can deny it with a reason the model sees; a post_tool hook runs after it and can append feedback, such as formatter or linter output, to the result the model sees. Like MCP servers, hooks are trusted local executables running with your privileges: they are read only from the user config or the -c file, never from the repository, launched directly (no shell) with an absolute command, --cwd as the working directory, and the same minimal environment (PATH, the Windows process variables when present, and only the named env variables, which must be set at startup). Provider API keys are not passed unless named. Hooks are a guardrail and automation point, not a sandbox: they see a call's parsed arguments, not what a shell command actually does.
Each hook gets one JSON object on stdin: schema_version (1), event (pre_tool or post_tool), tool_name, tool_call_id, arguments (decoded JSON, or the raw string if malformed), canonical root, read_only, and step; post_tool also gets output (the model-visible result after normal truncation) and is_error, plus output_file when tool output files saved the full result. The input can contain secrets from tool arguments and output.
| Event | Exit 0 | Exit 2 | Other exit, failure to start, timeout, or invalid stdout JSON |
|---|---|---|---|
pre_tool |
Allow, unless stdout is {"decision":"deny","reason":"..."} |
Deny; the reason is stderr | Apply on_error: deny by default (the reason names the hook and the failure), or allow with a warning |
post_tool |
Optional stdout {"feedback":"..."} is appended to the result |
stderr is appended to the result | Warning on stderr; the result is unchanged |
A denied call is not executed; the model gets the error result error: denied by pre_tool hook <command>: <reason> and the run continues. Matching hooks run one at a time in config order: the first pre_tool deny stops the rest, and every matching post_tool hook runs, each appending its feedback under [post_tool hook <command>] (capped at 8 KiB per hook; the combined result still honors max_output_chars). Read-only denials and unknown tools are rejected before hooks, so hooks never run for them and cannot widen authority; with --read-only, hooks still run for read_file, list_files, and grep. MCP tools are matched by their exposed <alias>__<tool> name, and command tools by their configured name. Ctrl-C or the wall-time limit kills a running hook (its process group on Unix; only the process on Windows). -v prints one stderr line per hook run, --json emits a hook event, and trajectories record the model-visible results, including denial reasons and feedback. Resumed runs use the hooks in the current config. Hooks do not run for update_plan, which only changes the run's plan.
Hooks match tool names, so a hook on write_file|edit_file does not see edits made with the opt-in apply_patch tool; use match: "write_file|edit_file|apply_patch" to cover all three. An apply_patch hook gets arguments as {"input": "<patch text>"}.
Two examples: a post_tool hook on write_file|edit_file that runs gofmt -l on the edited path and prints {"feedback":"..."} when it lists files, and a pre_tool hook on write_file|edit_file that exits 2 with edit schema.sql and run make gen instead when arguments.path is under migrations/.
Stop hooks
A stop hook runs when the run is about to end successfully (Submitted or Completed, including a valid --output-schema result) and can send the model back to work instead, for example when go test ./... still fails. Stop hooks never run for limits, interrupts, errors, RepeatedFormatError, or InvalidOutput, nor for an --output-schema result that failed validation. They use the same launch rules, environment, and timeouts as tool hooks; match and on_error are rejected for them.
Each stop hook gets one JSON object on stdin: schema_version (1), event (stop), status (Submitted or Completed), submission, structured_output (only with --output-schema), canonical root, read_only, step, continuations (how many times stop hooks already sent the model back), and stop_hook_active (true when continuations is above zero, so a hook can avoid looping).
| Exit 0 | Exit 2 | Other exit, failure to start, timeout, invalid stdout JSON, or unknown decision |
|---|---|---|
Accept, unless stdout is {"decision":"continue","reason":"..."} (block and deny are accepted as aliases) |
Continue; the reason is stderr | Warning on stderr and a warning event; treated as accept |
All stop hooks run in config order. If any asks to continue, the model gets a user message that starts with a fixed notice followed by each hook's reason under [stop hook <command>] (capped at 8 KiB per hook), and the run goes on as normal steps, so step, cost, and time limits still apply and win. If the submitting message also contained tool calls, those calls are not run; each gets an error result saying so. After max_stop_continuations continuations (default 5, range 1..50), a further continue ends the run as VerificationFailed with the hooks' combined reasons in the summary line and in stop_reason (--json result and -o trajectory); stop_continuations records the count. If Ctrl-C or the wall-time limit fires while a stop hook runs, the hook is killed and the run ends as Interrupted or TimeLimitExceeded, never Submitted. A run that a stop hook sent back to work cannot be resumed with --resume yet, and VerificationFailed runs cannot be resumed either. With no stop hooks configured, runs behave exactly as before.
Example: a stop hook script /usr/local/bin/verify-tests that runs go test ./... in the working directory, and, when it fails, prints the failing output to stderr and exits 2. The model then sees [stop hook /usr/local/bin/verify-tests] followed by the test failures and keeps working until the tests pass or the cap is reached.
Long runs
tinyagent resends the whole history on every step, so a long run can outgrow the model's context window. Before each request it estimates the request's size (the previous request's reported input tokens plus about 4 characters per token for anything added since). Above 80% of the window, it compacts:
- Old tool outputs are replaced with a placeholder such as
[output elided: 12,400 chars]. The system prompt, the task, and the most recent turns (about 25% of the window, and never fewer than the last 2 assistant turns with their tool results) are kept verbatim. If that brings the estimate under 50% of the window, it stops here. - Otherwise, one extra request asks the same model, with no tools, to summarize everything older than the recent turns. The model then sees the system prompt, the task, that summary, and the recent turns.
- If it still doesn't fit, the run ends as
Errorwithcontext still exceeds the window after compaction.
If a provider rejects a request as too long (prompt is too long, context_length_exceeded, maximum context length), tinyagent compacts and retries that step once; a second rejection ends the run as Error. This works even for models whose window isn't known.
The window comes from a small built-in table (internal/model/context.go), a named provider's per-model context_window (see Named providers), or top-level context_window in the config, which wins. Without either, only the retry-on-error path runs. Each compaction prints one line to stderr, such as compacted context: ~182,000 → ~31,000 tokens (summarized 41 messages). The summary request's tokens and cost count toward the run's totals and --cost-limit, but not --step-limit. With -o, the trajectory keeps every original message, and compactions records each one: the step, the estimates before and after, the stage, the summary, and which messages the model was sent next.
Summaries lose detail. Put instructions that must hold for the whole run in AGENTS.md, which is part of the system prompt and never compacted. Scoped AGENTS.md files from --scoped-agents-md live in tool results instead, so they can be compacted away; they are sent again the next time the agent touches their directory. --no-compact turns all of this off.
Plan tool
--plan (or plan: {enabled: true}) adds an update_plan tool, off by default. The model sends the whole list each time: up to 50 items, each {id, text, status} with status pending, in_progress, done, or blocked, at most one in_progress, plus an optional note. The plan lives in the trajectory (plan, with every change in plan_history), not in the message history: before each request a [Current plan] block is pinned after the system prompt, so it survives compaction (the summarizer also sees it) and --resume. Each update emits plan.updated, and --plan-file plan.md mirrors the current plan as a Markdown checklist after each save (the checkpoint stays the source of truth). The tool is available with --read-only, since it touches nothing in the workspace.
A checkpoint records whether the plan tool was enabled, and --resume keeps that setting: passing --plan to resume a run that started without it is an error, and the config's plan.enabled is ignored on resume. --plan-file requires the plan tool.
Images
--images (or images: {enabled: true}) lets vision-capable models see images. It is off by default; with it off, requests, tools, and checkpoints are unchanged.
view_imagetakes apathinside--cwdand sends the file to the model. PNG, JPEG, GIF, and WebP are supported, detected from the file's bytes (not its extension); SVG, other formats, directories, paths outside the workspace, and files larger thanmax_image_bytes(default 5 MiB) are errors. It is available with--read-only, since it only reads.- MCP image pass-through: image content in an allowed MCP tool's result is sent to the model (up to 8 images per result, each within
max_image_bytes). Unsupported, oversize, or extra images are replaced by an[omitted MCP image: ...]marker. With images off, MCP images are still[omitted unsupported MCP content].
Per provider: anthropic sends images as image blocks inside the tool result, and codex as input_image items in the function call output. The Chat Completions API (openai, openrouter, ollama) cannot carry images in tool results, so those providers get a text note saying the image was not shown; tinyagent does not inject images as a separate user message. (A Responses API transport for openai, #80, would allow it.)
Image data never appears in text channels: the model's tool-result text, --json events, verbose output, hook input, and the event log see a placeholder like [image: image/png, 48,213 bytes, sha256:1a2b3c4d5e6f]. Privacy: the image bytes are sent to the model provider and stored base64 in the trajectory/checkpoint file, so treat those files like the images themselves. For compaction each image counts as about 1,600 tokens, and old images are elided ([image elided: <path>, N bytes]) like old tool output; the summarizer never sees image data.
A checkpoint records whether images were enabled, and --resume keeps that setting: passing --images to resume a run that started without it (or the reverse) is an error, and the config's images.enabled is ignored on resume.
Attaching images to the task
--image <path> attaches a screenshot, design mock, or other image to the task message, so the model sees it with the task text and needs no tool call:
tinyagent -t "fix the layout regression shown" --image artifacts/diff.png
tinyagent -t "make current.png match reference.png" --image current.png --image reference.png
- Rules. The flag is repeatable (no comma splitting), keeps its order, and allows up to 10 images. Each file must be PNG, JPEG, GIF, or WebP, detected from its bytes (a PNG named
.jpgis sent asimage/png), and at mostmax_image_bytes(default 5 MiB). A missing file, a directory, another format, an oversize file, more than 10 images, or--imagewithout-texits 2 before any model request. - Paths. Paths resolve against the directory tinyagent was started in, not
--cwd. They are your own input, not model file access: the workspace restriction andfile_accessrules do not apply. Only the base name reaches the model. - Independent of
--images. Attachments don't need--images, and they don't turn onview_image. - Message shape. The task text gets one label line per image, such as
[attached image 1: mock.png, image/png, 48213 bytes], so the model can refer to "image 1". The images come before the text:anthropicsendsimageblocks with base64 data;openaion the Responses API andcodexsendinput_imageparts withdata:URLs; Chat Completions (openaiwithopenai_api: chat,openrouter,ollama) sends a content array ofimage_urlparts withdata:URLs plus atextpart. Messages without attachments are sent exactly as before. The model must support vision (for Ollama, use a vision model such asllava); otherwise the provider's error ends the run like any other request error, and this applies to fallback models as well. - Text channels.
run.started(in--jsonand--log-db) lists each attachment's index, base name, MIME type, size, and sha256;-vprints oneattached image N:line per file. No text channel carries image data. - Resume and follow-up. The images are stored base64 in the checkpoint with the task message, so
--resumesends them again without the original files.--resume <checkpoint> -t "still wrong, see this" --image after.pngattaches images to the follow-up message.--imageis refused (exit 2) when resuming an unfinished run or a finished one without-t. Older tinyagent versions refuse checkpoints that contain attachments. - Compaction and cost. The task message is never compacted, so its images stay attached and are re-sent with every request. For the context estimate each image counts as about 1,600 tokens, and the summarizer sees only the label lines. Images typically cost 1–5k input tokens each per request, so crop or downscale large screenshots. tinyagent doesn't resize them. Base64 adds about a third to the size, so a 5 MiB file can exceed a provider's per-image limit (for example, 5 MB on Bedrock); lower
max_image_bytesif that matters. - Privacy. Attached images are sent to the model provider and stored in the trajectory/checkpoint file.
Tool output files
Tool results longer than max_output_chars are cut to a head and tail with a ... [N characters elided] ... marker, and the middle is lost. --tool-output-files (or tool_output_files.enabled: true) keeps it: the full result text is saved to a file and the model-visible result becomes a shorter head/tail preview ending in one notice, for example:
[full output: 184,213 bytes, 2,941 lines, saved to .tinyagent/tool-output/20261025T140307.123Z-a1b2c3/call_abc.txt; use read_file with offset/limit or grep on that path]
The preview plus the notice never exceeds max_output_chars, and it is cut on UTF-8 boundaries. The model can then read_file the path with offset/limit or grep it. It is off by default; with it off, results, events, and checkpoints are unchanged and no directory is created.
- What is saved: the exact full text the tool returned (for
bash, including the<returncode>/<output>wrapper, so the command's line N is line N+2 of the file), for built-in tools, allowed MCP tools (whose results are already capped at 256 KiB), and error results. Results within the limit are never saved.read_fileresults are not copied, since the file is already on disk: a truncatedread_fileresult ends with[file has N lines; output was truncated, use read_file on <path> with offset/limit to read the rest]instead.grepandlist_filescalls on the output directory itself are not saved again. - Where: one directory per run,
<dir>/<run id>/<tool call id>.txt, wherediristool_output_files.dir(default.tinyagent/tool-output, relative to--cwd) and the run id is a UTC timestamp plus a random suffix (20261025T140307.123Z-a1b2c3).dirmust be inside--cwdand not--cwditself; otherwise startup fails before any model request with an error namingtool_output_files.dir. The run directory is created (mode 0700) on the first save, and each file is written atomically with mode 0600. Files are written in--read-onlymode too, since they are tinyagent's own output, not a workspace edit. - Exclusions: recursive
grepandlist_fileswalks skip the output directory, so saved copies never show up as duplicate matches; agreporlist_fileswhosepathis inside the directory searches it normally.bashcommands are not filtered. - Retention: at run start, the oldest run directories are deleted so at most
tool_output_files.max_runs(default 20; 0 keeps all) earlier runs remain. Only directories named like run ids are touched. - Errors: if a file can't be written (for example,
.tinyagentis not a writable directory), the result falls back to the normal truncation, stderr gets one warning for the run (also awarningevent with--json), and the run continues. - Recording: with
--json, a saved result'stool.resultevent addsoutput_fileandoutput_bytes;post_toolhooks getoutput_file; with-o, the trajectory liststool_output_filesentries (tool_call_id,output_file,output_bytes). The event log stores the path and size as metadata. The setting is not pinned in checkpoints:--resumecan turn it on or off.
Privacy: saved files can contain anything a command printed, including secrets, just like trajectories and the event log. Add .tinyagent/ to your .gitignore.
apply_patch
--apply-patch (or apply_patch: true) adds an apply_patch tool that applies one patch in the *** Begin Patch ("V4A") format documented by OpenAI. It is aimed at models trained on that format (the OpenAI GPT-4.1 and GPT-5 families, including codex/*) and works with any model that follows the tool description. It is off by default; with it off, requests, tools, and checkpoints are unchanged. edit_file and write_file stay available, and the tool is never offered with --read-only (a call to it gets the read-only denial).
The tool takes one string argument, input:
*** Begin Patch
*** Add File: docs/notes.txt
+first line
*** Update File: src/app.go
*** Move to: src/main.go
@@ func main() {
fmt.Println("hi")
- return
+ os.Exit(0)
}
*** Delete File: old.txt
*** End Patch
*** Add File:is followed by the file's lines, each prefixed with+.*** Delete File:takes no body.*** Update File:is optionally followed by*** Move to:(a rename), then hunks of context (), removed (-), and added (+) lines.- A hunk starts with
@@or@@ <line>: the search for the hunk moves past the first line matching<line>(after the previous hunk), and several@@lines may be stacked to narrow it down.*** End of Filemarks a hunk at the end of the file. There are no line numbers. - Context is matched exactly, then ignoring trailing whitespace, then ignoring surrounding whitespace; a looser match is noted in the result. Context lines keep the file's own text.
- Paths follow the same rules as the other file tools: relative to
--cwd, or absolute inside it; anything outside is rejected. A shell heredoc wrapper (apply_patch <<"EOF" ... EOF) is stripped.
The whole patch is parsed and checked before anything is written: update and delete targets must exist, be regular files, and not be binary; add targets and move destinations must not exist; a path may appear in only one operation; every hunk must match; and the patch must be at most 1 MiB. If any check fails, nothing is written and the error names the operation (by number and path), the reason, and for context failures the lines that were not found. Otherwise the operations are applied in order and the result is ok followed by one line per operation: A <path>, M <path> (<n> hunks), D <path>, or R <old> -> <new>. Updated files keep their permission bits, their line endings (CRLF or LF), and a missing trailing newline (a hunk marked *** End of File leaves the file ending in a newline); new files are written 0644 with parent directories created.
Writes are not transactional: if writing fails part-way (for example, a permission error), work stops and the error lists the operations that were applied, which are not rolled back, and those that were not. Like edit_file, the tool is not covered by --sandbox. A missing or non-string input is a malformed tool call; a patch that does not apply is an ordinary tool error.
A checkpoint records whether apply_patch was enabled (apply_patch_enabled), and --resume keeps that setting: passing a conflicting --apply-patch exits 2, and the config's apply_patch is ignored on resume.
Subagents
--subagents (or subagents.enabled: true) adds a delegate tool. A call such as {"task": "find every caller of LoadConfig and say which ones ignore its error", "label": "callers"} starts a child agent with a fresh context: the same system prompt and AGENTS.md, plus a notice that it is a subagent that must answer first and then give evidence with file:line references. The child runs to completion, and the parent gets back only its final report followed by a footer such as [delegate d1: Submitted, 4 steps, 9200 in / 410 out tokens, $0.0123]. It is off by default; with it off, requests, tools, and checkpoints are unchanged.
- Read-only. The child has only
read_file,list_files, andgrep, whatever the parent has. It gets nowrite_file,edit_file,apply_patch,bash, process tools, MCP tools,skill,view_image,update_plan, ordelegate, and calls to them are refused. Paths follow the parent's rules (--cwdandfile_access). The tool is also offered with--read-only. - Limits. Each child stops after
subagents.max_stepsmodel steps (default 30); hitting the cap, or any other unfinished status, returns a tool error such aserror: delegate d1 did not finish: StepLimitrather than a report. Child steps don't count toward--step-limit; they're reported separately assubagent_steps. Child tokens and cost do count toward the run's totals and--cost-limit, and the child only gets what's left of the budget, so a delegated task can end the whole run asCostLimit.--time-limitcovers children too. Reports longer thanmax_output_charsare truncated like any tool output. Severaldelegatecalls in one turn run one after another. - Model.
subagents.modelpicks a different (typically cheaper) model; empty uses the parent's.reasoning_effortandmax_output_tokenscarry over only when the child uses the same provider as the parent. An unknown provider, or a missing key for a different provider, exits 2 before any request. - Tokens. Delegation saves the parent's context, not tokens overall: the child resends its own history each step, so a task the parent could answer with one
grepis cheaper done directly. Use it for searches that would otherwise pull many large files into the parent's history. - Observability.
--jsonand--log-dbaddsubagent.started(id,label,model,tool_call_id,parent_step) andsubagent.finished(id,status,steps, tokens,cost_usd) events, and the child's own events carry"subagent": "d1".-vprints a line when each child starts and finishes; the child's model output is not streamed. Hooks run for child tool calls withsubagentset in their input, andpre_tool/post_toolhooks also see thedelegatecall itself; stop hooks run only for the parent. With-o, the trajectory recordssubagents_enabled,subagent_steps, and asubagentsarray with each child's task, status, usage, report, and full message history. - Resume. A checkpoint records whether subagents were enabled, and
--resumekeeps that setting: a conflicting--subagentsexits 2. A run interrupted mid-delegation resumes with thatdelegatecall marked interrupted; the child is not resumed.
Not in this version: children that can write or run commands, parallel children, nested delegation, and streaming the child's output.
Web fetch
--web-fetch (or web_fetch.enabled: true) adds a web_fetch tool that reads one URL and returns it as text, for documentation, changelogs and API references. It is off by default; with it off, requests, tools, and checkpoints are unchanged. It is separate from command networking: it runs inside tinyagent, not through bash, so enabling it gives commands no network access, and --sandbox, --sandbox-network and sandbox_allowed_domains neither restrict nor widen it.
web_fetch:
enabled: true
allowed_domains: [pkg.go.dev, "*.python.org"]
max_bytes: 2097152 # 2 MiB
timeout_seconds: 20 # 1 to 120
- Allowlist.
allowed_domains(or--web-fetch-allow-domain <host>, repeatable, which replaces the config list) uses the same syntax assandbox_allowed_domains:example.com,*.example.com(subdomains only), and an optional:port(without one, ports 80 and 443). A bare*, an empty list while enabled,timeout_secondsoutside 1 to 120, ormax_bytesbelow 1 exits 2 before any model request, as does--web-fetch-allow-domainwithout enabling the tool. On start, one line such asweb_fetch: 2 domains (pkg.go.dev, *.python.org)goes to stderr. - What it fetches.
httpandhttpsURLs only, with no user name or password in them, byGETwithUser-Agent: tinyagent/<version>and no cookies or credentials. The host is checked before any connection; loopback, private, link-local (including the169.254.169.254metadata address) and other non-public addresses are refused unless that literal address is listed, and the connection goes to the address that was checked. Up to 5 redirects are followed, each checked the same way.HTTP_PROXYand similar variables are ignored. - What it returns. A header line such as
[web_fetch https://pkg.go.dev/fmt → 200 text/html, 81,234 bytes, 412 lines](noting truncation and a redirected final URL), then the body: HTML is converted to readable text (headings, lists, code blocks and[text](url)links kept; scripts, styles and navigation dropped), and plain text, Markdown, JSON and XML are returned as is. Other content types, non-2xx statuses, timeouts and refused URLs are tool errors. At mostmax_bytesof the response is read, and the result is then subject tomax_output_charsand tool output files like any other tool output. Page content is untrusted input to the model. - Where it is offered. It only reads, so it is offered with
--read-only. Subagents never get it.pre_tool/post_toolhooks match it asweb_fetchand seeargumentsas{"url": "..."}; a denied call makes no request. - Observability.
-vprints one line per fetch (URL, status, type, bytes, time, or the refusal reason).--jsonand--log-dbemit aweb_fetchevent per attempt and listallowed_domainsinrun.started;-otrajectories recordweb_fetch_enabledandweb_fetch.allowed_domains. - Resume. A checkpoint records whether
web_fetchwas enabled, and--resumekeeps that setting: passing a conflicting--web-fetchexits 2, and the config'sweb_fetch.enabledis ignored on resume. The allowlist and limits come from the current invocation.
Language servers (LSP)
--lsp (or lsp.enabled: true) starts language servers you configure, so the model sees compiler errors right after it edits a file instead of on its next build, and can jump to definitions and references. It is off by default; with it off, requests, tools, and checkpoints are unchanged and no server starts.
lsp:
enabled: true
diagnostics_after_edit: true # default
diagnostics_wait_ms: 3000 # 100 to 30000
max_diagnostics: 20 # per file, 1 to 200
severity: error # or warning
servers:
- name: go # letters, digits, - and _
command: gopls
extensions: [.go]
env: [HOME, GOPATH, GOFLAGS] # host variables to forward; each must be set
- name: ts
command: typescript-language-server
args: [--stdio]
extensions: [.ts, .tsx]
initialization_options: {}
startup_timeout_seconds: 15 # start and initialize, 1 to 300
-
Trust. Servers are executables you choose, like MCP servers: they are read only from the user config or
-c, never from a repository file, and launched directly (no shell) in--cwdwith a minimal environment:PATH(on Windows alsoSystemRoot,WINDIR,USERPROFILE,TEMP,TMP) plus the variables named inenv. Many servers need more (gopls wantsHOMEorGOPATH/GOCACHE); forward them by name. A named variable that is unset, an emptyserverslist while enabled, a duplicate name or extension, or a value out of range exits 2 before any model request. Servers run on the host, so--lspwith--sandboxor--env containerexits 2. -
Lifecycle. A server starts the first time a file with one of its extensions is edited or queried, one process per server per run. A server that can't start (missing binary, failed
initialize, timeout) is marked unavailable for the run with one stderr warning; the run continues. A server that crashes is restarted once, then marked unavailable. On every exit (submit, limits, errors, interrupt) tinyagent sendsshutdown/exitand then kills the server's process group. Each server's stderr goes to a log under.tinyagent/lspin--cwd(1 MiB per server, the newest 50 kept). -
Diagnostics after edits. After a successful
write_file,edit_fileorapply_patch(withdiagnostics_after_edit), tinyagent syncs each changed file with its server and waits up todiagnostics_wait_msfor diagnostics, then appends them to the tool result:ok [lsp go: 2 errors in internal/a.go] internal/a.go:12:5: undefined: fooBar internal/a.go:30:2: missing returnPositions are 1-based lines and columns (columns in characters). Errors only by default;
severity: warningadds warnings (prefixedwarning:). Beyondmax_diagnosticsa line such as[lsp go: omitted 7 more diagnostics in a.go]follows, and if the server is slow,[lsp go: diagnostics not ready after 3000 ms for a.go]. A clean file adds nothing. Notes are appended beforepost_toolhooks run, so hooks,tool.resultevents, tool output files and trajectories all see them. They report every current error in the file, not only new ones, and a server that hasn't finished indexing may report false positives; treat them as hints and confirm with a build. -
The
lsptool.{"operation": "diagnostics" | "definition" | "references", "path": "...", "line": n, "column": n}(1-based;lineandcolumnonly fordefinitionandreferences).diagnosticsreturns the file's current diagnostics, or[lsp go: no errors in a.go].definitionandreferencesreturn up to 100path:line:col: <source line>locations, sorted and deduplicated; locations outside--cwdand its additional directories, or denied by file access rules, are left out and counted.pathfollows the same rules asread_file, and a file with no configured server is a tool error. The tool only reads, so it is offered with--read-only(where edits, and so post-edit diagnostics, can't happen); subagents never get it. Hooks match it aslsp. -
Privacy and cost. Servers read the files you edit or query (and whatever else they index under
--cwd); some servers download modules or toolchains or use a lot of memory on large repositories. Files over 4 MiB are not sent. -
Observability.
--jsonand--log-dbemit anlsp.serverevent per state change and listserversunderlspinrun.started;-vprintslsp: go startedand similar lines; on start, one line such aslsp: go (.go); ts (.ts, .tsx)goes to stderr.-otrajectories recordlsp_enabledandlsp_servers. -
Resume. A checkpoint records whether
lspwas enabled, and--resumekeeps that setting: passing a conflicting--lsp(including--lsp=false) exits 2, and the config'slsp.enabledis ignored on resume. Servers and limits come from the current invocation.
Exit status
The run ends when the model writes COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT (Submitted), stops calling tools (Completed), or hits a limit (StepLimitExceeded, CostLimitExceeded, TimeLimitExceeded), Ctrl-C (Interrupted), a non-retryable API error (Error), too many consecutive malformed tool-call steps (RepeatedFormatError, see below), or, with --output-schema, 3 consecutive invalid results (InvalidOutput), or stop hooks still rejecting the result after max_stop_continuations (VerificationFailed, see Stop hooks), or too many consecutive responses cut off at the output-token limit (OutputTruncated, see Output truncation), or, with --ask-user, a question for the user (NeedsInput, see Asking for input). The process exits 0 only for Submitted and Completed, 3 for OutputTruncated, 4 for NeedsInput, and 1 otherwise (2 is reserved for usage errors).
Every run ends with one summary line on stderr, so stdout keeps only the model's text:
Submitted after 12 steps in 1m42s (48,213 input / 3,104 output tokens, $0.23)
Cost is shown only when it's above zero, and the token/cost part is left out if the run ended before any model response. When some input was read from the prompt cache, the line also shows how much, e.g. (48,213 input / 3,104 output tokens, 41,900 cached, $0.09). With -o, the trajectory JSON also records input_tokens and output_tokens totals, plus cache_read_tokens and cache_write_tokens when they're non-zero.
Malformed tool calls
A tool call is a format error when its arguments aren't valid JSON or not a JSON object, its tool name isn't offered, or its arguments miss a field the tool's schema marks required or give a field the wrong JSON type (integers may still be sent as numeric strings). The tool isn't run; the model gets a short correction instead, such as format error: read_file is missing required field "path". Call one of: read_file, ... with arguments matching its schema., and the run continues. Errors from a well-formed call that runs (a command exiting 1, a missing file) are not format errors, and in --read-only mode a non-read-only built-in keeps its read-only denial.
A step whose tool calls are all format errors counts toward a consecutive limit; any step with at least one well-formed call resets it. After --max-format-errors (config max_format_errors, default 3) such steps in a row, the run ends with RepeatedFormatError, an empty submission, and a non-zero exit. Format-error steps are ordinary model calls, so they count toward --step-limit and --cost-limit, which are checked first. A response with no tool calls still ends the run as Completed.
The summary line shows the count (..., 3 format errors: <last reason>). With --json, a malformed call's tool.result event has "format_error": true and the result event has format_errors. With -o, the trajectory records format_errors, consecutive_format_errors, format_error_messages (indexes of the correction messages) and last_format_error, and keeps the original arguments. The consecutive count is restored on --resume, and a RepeatedFormatError checkpoint can be resumed.
Output truncation
When a response stops because it hit the output-token limit (Anthropic stop_reason max_tokens or model_context_window_exceeded, Chat Completions finish_reason length, Responses/Codex incomplete with reason max_output_tokens), tinyagent treats it as cut off rather than finished. None of its tool calls run, not even ones that look complete, and no hooks run for them; each gets the result error: [not run: your response was cut off at the output-token limit ...], and the model is asked to continue in smaller pieces (for example, write a large file in several edits). A cut-off response never ends the run as Submitted or Completed, and does not count as a format error or an invalid structured result. Invalid partial tool-call JSON is replaced by {} when the conversation is sent back to the provider.
After max_truncations (default 3) cut-off responses in a row, the run ends with OutputTruncated and exits 3; any complete response resets the count. Raise the budget with --max-output-tokens (config max_output_tokens; sent as max_tokens on Anthropic and Ollama, max_completion_tokens on OpenAI/OpenRouter Chat Completions, max_output_tokens on the OpenAI Responses transport; codex exits 2). The budget may be changed on --resume, so an OutputTruncated checkpoint can be resumed with a larger one.
A warning goes to stderr, the summary line shows ..., N truncated responses, --json (and --log-db) emit a model.truncated event with step, reason, max_output_tokens, tool_calls_dropped and consecutive, the dropped calls' tool.result events have "truncated": true, and the result event and trajectory record truncations (trajectories also consecutive_truncations and max_output_tokens).
Troubleshooting
When a run fails on a common setup problem, tinyagent prints a hint: line under the error. The error itself is kept, and hints name environment variables without printing their values.
A mistyped flag exits 2 with one line naming up to three close flags, such as error: unknown flag --sandbxo; did you mean --sandbox? (see tinyagent --help), instead of the full usage.
To check your setup before a run, use tinyagent --check with the same -m, -c, --cwd, fallback and limit flags you'd pass to a run. It sends no model request and writes nothing. It prints which config file, model, provider, fallbacks, working directory and limits a run would use, and where each came from, then ok (exit 0). A problem a run would hit, such as an unknown provider, a bad config key or a missing API key, is printed the same way a run prints it, with its hint:, and exits 2. Key values are never printed, only whether the variable is set.
$ tinyagent --check -m anthropic/claude-sonnet-4
config: none (built-in defaults)
model: anthropic/claude-sonnet-4 (from -m)
provider: anthropic, ANTHROPIC_API_KEY is set
fallback: none
cwd: /home/me/project (default)
limits: steps unlimited, cost unlimited, time unlimited
ok
| Symptom | Fix |
|---|---|
HTTP 401 or 403 from openai, openrouter or anthropic |
Set the key: export OPENAI_API_KEY=... (or OPENROUTER_API_KEY, ANTHROPIC_API_KEY). If it's already set, check that it's current. |
HTTP 404 from openai, openrouter or anthropic |
Check the model name after the /. |
connection refused with ollama/... |
Start Ollama with ollama serve, or set OLLAMA_HOST to where it runs. |
HTTP 404 with ollama/... |
Pull the model: ollama pull <model>. |
every model in the fallback chain failed |
Each listed model used up its retries; the message names each one and why. Check the providers' status pages, or add a model from another provider to fallback_models. |
| An editor says the ACP agent failed to start or exited | Use the full path to tinyagent in the agent's command, put the API key in the agent's env (editors don't read your shell profile), and check the editor's agent log, which shows tinyagent's stderr. |
codex: no login |
Run codex login and choose ChatGPT. |
model is not supported with codex/... |
Use a model the Codex CLI's model picker shows for your plan. |
Sandboxing commands
--sandbox (or sandbox: true) confines every bash, start_process and command tool command, and all processes they spawn, at the OS level. Commands can write only inside --cwd, a per-run temporary directory (exported as TMPDIR, removed when the run ends), the directories in sandbox_writable_paths and any write additional directories. Reads stay unrestricted so toolchains work. Outbound network is denied unless you pass --sandbox-network (sandbox_network: true). Flags override the config, including --sandbox=false. It is off by default.
sandbox: true
sandbox_network: false
sandbox_writable_paths: # absolute paths to existing directories
- /home/me/.cache/go-build
Toolchains that cache outside the repo (Go, npm, pip) fail under the sandbox with Permission denied until their cache directories are listed in sandbox_writable_paths.
- Linux: Landlock, applied by a re-executed helper to the command only (tinyagent itself is unrestricted). Needs Linux 6.2+ (Landlock ABI 3) to confine writes, and 6.7+ (ABI 4) to deny TCP. Landlock cannot restrict UDP before ABI 10 and does not cover Unix sockets, so "network denied" means TCP connect and bind. WSL2 counts as Linux.
- macOS: Seatbelt through
/usr/bin/sandbox-exec(deprecated by Apple but still shipped). This backend has not been tested in CI. - Windows: not supported.
- Fail closed: if the host cannot enforce the requested policy, tinyagent exits before the first model request and says what is missing. It never runs commands unsandboxed.
- On start, one
sandbox:line (backend, writable roots, network) goes to stderr, and-otrajectories record it assandbox. With--resume, the current invocation's settings apply. With--read-onlythere are no command tools, so nothing is sandboxed. - Not covered:
web_fetch, which has its own allowlist and is unaffected by--sandbox, the in-process file tools (read_file,write_file, ... and their symlink behavior; see Restricting file access for rules on them), MCP servers, and provider requests. Commands can still read anything you can, including credentials, and inherit your environment variables (API keys included) unless you set a command environment policy. With--sandbox-networkthey can send it to any host; prefer a network allowlist. The sandbox reduces risk; it does not make untrusted code safe.
Network allowlist
sandbox_allowed_domains (or --sandbox-allow-domain <host>, repeatable, which replaces the config list) lets sandboxed commands reach only the hosts you list, over HTTP and HTTPS. tinyagent runs a small proxy outside the sandbox that checks each request's hostname, and sets HTTP_PROXY, HTTPS_PROXY, ALL_PROXY (and their lowercase forms) to it, with NO_PROXY empty. The OS sandbox lets commands connect only to that proxy. It requires --sandbox and can't be combined with --sandbox-network (exit 2 otherwise). Without the key or flag, nothing changes.
sandbox: true
sandbox_allowed_domains:
- proxy.golang.org # Go modules
- sum.golang.org
- registry.npmjs.org # npm
- pypi.org # pip
- files.pythonhosted.org
sandbox_writable_paths: # their caches, see above
- /home/me/go/pkg/mod
- Entries: an exact hostname (
api.example.com),*.example.comfor its subdomains only (notexample.comitself), or an IP literal (10.0.0.5,[::1]). Each may end with:port; otherwise ports 80 and 443 are allowed. Matching ignores case and a trailing dot. A bare*is a config error. - Private addresses: the proxy resolves an allowed name itself and refuses to connect to loopback, private, link-local (including the
169.254.169.254cloud metadata address) and other non-public addresses, unless that address is listed as a literal for the port. - Refusals: the command gets HTTP
403with anX-Proxy-Error: blocked-by-allowlistheader and a body such astinyagent sandbox: host not in sandbox_allowed_domains: evil.example (port 443)(for HTTPS, clients report the proxy's 403 onCONNECT). Each refusal is asandbox.network_deniedevent with--json, is listed undersandbox_network_denialsin-otrajectories, and is printed to stderr with-v. Allowed requests are not logged. - Only proxy-aware clients work: curl, Go, npm, pip, git over HTTPS and most HTTP libraries honor the proxy variables. Programs that ignore them, and anything that isn't HTTP(S) (SSH, raw TCP, UDP, DNS lookups), cannot connect. Proxy variables in your own environment are replaced, and the proxy connects directly rather than through an upstream proxy.
- Linux: each command runs in its own unprivileged user and network namespace, which has only loopback. tinyagent forwards a loopback port there to the proxy's Unix socket (in a private temporary directory removed when the run ends), and Landlock additionally allows TCP connections only to that port. This needs Landlock ABI 4 and unprivileged user namespaces. Ubuntu 23.10+ restricts these with AppArmor (
kernel.apparmor_restrict_unprivileged_userns=1), and other distributions may disable them (kernel.unprivileged_userns_clone,user.max_user_namespaces); there, tinyagent exits before the first model request with a message naming the cause. Commands appear as your own uid, but other users and groups map tonobody. Unix sockets on the filesystem (for example a Docker socket) are still reachable, as without the allowlist. - macOS: the proxy listens on a loopback port and the Seatbelt profile allows outbound connections only to that port on localhost.
- Exfiltration: an allowlist limits where data can go, not what is sent. Commands can upload anything they can read to an allowed host, and broad entries (
github.com,*.amazonaws.com, CDN wildcards) allow uploads to other users' content and domain fronting, since TLS isn't inspected. List only the hosts a task needs. - The startup line shows e.g.
network: allowlist (2 domains: proxy.golang.org, sum.golang.org), and the trajectory'ssandboxrecord includesallowed_domains. The proxy stops when the run ends, however it ends; background processes fromstart_processuse it until then.
Command environment
By default bash, start_process and command tool commands inherit tinyagent's whole environment, including the provider API keys tinyagent itself needs, so a prompt-injected env or echo $OPENAI_API_KEY can read them. command_env (config only, from the user config or -c) controls what they get instead:
command_env:
inherit: core # all (default) | core | none
exclude_secrets: true # default false
exclude: ["AWS_*", "NPM_TOKEN"] # names or * ? [ ] globs
pass: [GOFLAGS, GOPATH, GITHUB_TOKEN] # names or globs copied from tinyagent's environment
set: # literal string values
CI: "true"
--command-env all|core|none overrides inherit for one run. The environment is built once per run, in this order:
- Base from
inherit:allis every variable;corekeepsPATH,HOME,USER,LOGNAME,SHELL,LANG,LC_*,TERM,TZ,TMPDIR, plus on WindowsSystemRoot,WINDIR,USERPROFILE,TEMP,TMP,PATHEXT,COMSPEC;noneis empty. exclude_secretsremoves names ending in_KEY,_TOKEN,_SECRETor_PASSWORD(the same rule--log-dbredaction uses) and alwaysOPENAI_API_KEY,ANTHROPIC_API_KEYandOPENROUTER_API_KEY.excluderemoves matching names.passadds matching names back from tinyagent's environment, even ones removed above. Entries that match nothing are skipped (-vlists them).setapplies literal values.- With
--sandbox, the sandbox'sTMPDIRis applied last.
Name matching is case-insensitive on Windows and case-sensitive elsewhere. tinyagent's own environment is unchanged, so providers, MCP servers and hooks behave as before; every process a command spawns inherits the filtered environment, with or without --sandbox. An invalid inherit, an empty or =-containing name, a malformed glob, a non-string set value or an unknown key exits 2 before any provider request.
When a policy is set (or --command-env is passed), one line such as command env: core, secrets excluded; 14 passed, 37 withheld goes to stderr; -v also lists the passed and withheld names. -o trajectories and --json run.started record command_env (mode, exclude_secrets, passed names). Values are never printed or recorded; --log-db also redacts set values. With --resume, the current invocation's policy applies. --read-only runs have no command tools, so the policy has no effect there (it is still validated). The container environment forwards no host variables and ignores the policy.
For CI on untrusted input (issue bodies, PR diffs), use inherit: core with exclude_secrets: true; for reproducible runs, inherit: none with set. core and none can break toolchains that need other variables (GOPATH, JAVA_HOME, proxy settings): check the -v lists and add them to pass.
This is not a sandbox. It does not protect credentials stored in files (~/.aws/credentials, .env, the Codex auth file), which commands can still read (file_access rules hide them from the file tools only). It does not redact secrets from tool output by itself: pass and set deliberately give commands the values you name, so turn on redact_secrets to keep those values out of what the model sees, trajectories and --json.
Redacting secrets from tool output
redact_secrets (config only, from the user config, -c or a profile; never from the repository) replaces the exact values of secrets tinyagent knows about with [REDACTED] in tool output, before the output enters the model's context. It is off by default; --redact-secrets / --redact-secrets=false overrides enabled for one run.
redact_secrets:
enabled: true
env: [DATABASE_URL, "DEPLOY_*"] # extra variable names or globs whose values are secrets
allow: [PUBLIC_KEY] # secret-named variables not to mask
patterns: false # true also masks token shapes (sk-..., gh?_..., Bearer ..., xox?-...)
- Known values, collected once at startup: tinyagent's environment variables named
*_KEY,*_TOKEN,*_SECRETor*_PASSWORD(theexclude_secretsrule),OPENAI_API_KEY,ANTHROPIC_API_KEY,OPENROUTER_API_KEY; MCP server and hookenvvalues; named providers'api_key_envandenv_headersvalues;command_env.setvalues; the variables matched byenv; and thecodexprovider's tokens once loaded (including ones refreshed during the run). Variables matched byalloware left out. Values shorter than 6 characters are skipped, as GitHub Actions advises, since they would make output unreadable. Each value is also matched in its JSON-escaped form, longest first. - Covered:
bash,start_process,read_process,stop_process, thebackground_processestails saved at run end, MCP tool results,web_fetch,post_toolhook feedback andstophook reasons. Masking happens where the result is created, so the provider request,-otrajectory,--jsonevents,--log-db, tool output files,post_toolhook input and--transcriptall get the masked text. It runs before truncation and before a result is saved to a file, so a truncated result never shows half a secret. Background process output that ends in the start of a secret is held back until the rest arrives or the process exits, so a secret split across reads is still masked whole. - Not covered: the built-in file tools (
read_file,grep,list_files,lsp,view_image,skill) and edit results, because their output feedsedit_file,write_fileandapply_patch, and a placeholder written back would corrupt files. Usefile_access.deny_readto keep secret files away from them. Also not covered: the task,AGENTS.md, skills,--imagefiles, the live terminal echo ofbashstream: true, and subagents (which only have file tools). - Exact matches only. Like GitHub Actions log masking, this finds a secret only as the exact string (or its JSON-escaped form). Transformed values (Base64, URL-encoded, split or reassembled), credentials stored only in files, and secrets tinyagent doesn't know about are not caught.
patterns: trueadds the token shapes--log-dbuses, at the risk of masking test fixtures and docs. This is defense in depth, not a guarantee: a prompt-injected model can still try to exfiltrate secrets through commands, so combine it withcommand_envand--sandbox. - Observability, by variable name and count only, never values: one stderr line such as
redact_secrets: 14 values from 12 variables;-vlists the masked names, the names skipped as too short andenventries that matched nothing.tool.resultevents haveredactionswhen a result was masked, andrun.startedand-otrajectories recordredact_secrets(enabled,patterns,names), so variable names do appear in those files. - Validation: unknown keys, an empty name or a malformed glob in
envorallow, the same entry in both, and a non-booleanenabledorpatternsexit 2 before any provider request. Anenvname that is unset is not an error. - Resume: the current invocation's setting applies to new results only. Earlier, unredacted results in the checkpoint are not rewritten, and with the setting off a resumed run behaves as before.
Restricting file access
file_access (config only, from the user config or -c; a file inside --cwd is never read) denies the built-in file tools access to matching paths. It is off by default.
file_access:
deny_read: [".env", "*.pem", "secrets/", "**/id_rsa"] # also write-denied
deny_write: [go.sum, "/vendor/"] # readable, not writable
- Patterns are gitignore-style and relative to
--cwd:*,?and[...]match within one path segment,**matches any number of segments, a leading/(or any/before the end) anchors the pattern to--cwd, a trailing/matches directories only, and a pattern without a slash matches a name at any depth. A rule on a directory covers everything below it. Empty patterns,./..segments, bad glob syntax and!negation (reserved) are config errors (exit 2). Absolute and~paths are not supported. Matching ignores case on macOS and Windows. - Reads:
read_file,view_image, and alist_filesorgrepaimed at a denied path return an error naming the rule, such as.env is denied by file_access.deny_read rule ".env". Walks (list_files,grep) skip denied files and directories without opening them and end with a neutral count such as(2 paths hidden by file_access rules), which names none of them. The count includes denied files only when they would have matched the pattern orinclude; a denied directory counts once. - Writes:
write_file,edit_fileandapply_patchrefuse paths matchingdeny_writeordeny_read.apply_patchchecks every operation (includingMove todestinations) before writing, so a patch that touches one denied path changes nothing. - Symlinks: a path is checked as named and through its resolved target (for a new file, through its deepest existing directory), so
innocent.txt -> .envor a symlinked directory pointing intosecrets/is denied too. Walks don't follow symlinked directories. - With
-v, one stderr line gives the rule counts.-otrajectories and--jsonrun.startedrecordfile_access(deny_read,deny_write). With--resume, the current invocation's rules apply. Adeny_readrule that hides the tool output directory fails at startup, since the model is told to read it. AGENTS.md and skills loading are not affected. - Not covered:
bash,start_process, hooks, MCP servers and execution environments can still read and write anything you can; combine with--read-only,--sandboxor a command environment policy as needed. Checks happen before each operation, so a file swapped for a symlink in between (a race) is not caught. This narrows what the model's file tools can touch; it is not a security boundary.
Additional directories
By default the file tools reach only --cwd. --add-dir-read <path> and --add-dir-write <path> (both repeatable), or additional_dirs in the config, add up to 10 existing directories outside it, for example a sibling repo to read or a shared output folder to write:
additional_dirs:
- {path: /home/me/src/shared-lib, mode: read} # read_file, list_files, grep, view_image
- {path: /home/me/out, mode: write} # also write_file, edit_file, apply_patch
- Modes: every entry needs an explicit
mode,readorwrite. Read directories refuse writes (including through symlinks that point into them) with an error naming the directory and its mode; anapply_patchthat touches one changes nothing.--read-onlymakes every additional directory read-only. - Paths: config paths must be absolute; flag paths may be relative to
--cwd. Each must exist and be a directory, and may not be the filesystem root,--cwd, inside or containing--cwd, or overlap another entry; any of these exits 2 before the first model request. Any--add-dir-*flag replaces the config list. The model reaches additional directories with absolute paths; relative paths still resolve against--cwd. - Visibility: the system prompt lists the directories and modes,
-vprints oneadditional dir:line each, and-otrajectories and--jsonrun.startedrecordadditional_dirs. With--resume, the current invocation's directories apply; if they differ from the checkpoint's, stderr says so and the model is told. - Other features:
file_accesspatterns apply inside each directory relative to it. Subagents can read them. With--sandbox, write directories are added to the writable roots; with thecontainerenvironment, each is mounted at the same path (read ones:ro). Snapshots cover only--cwd, so stderr warns that--rewindwon't restore changes made in additional directories.
Execution environments
The environment decides where bash and start_process commands run. Choose it with --env local|container or environment: in the config; the flag wins, and the default is local.
local(default) runsbash -con the host, exactly as before.--sandboxis a property of this environment (see Sandboxing commands).containerruns each command with<runtime> exec ... bash -c <command>in one container started for the run:
environment: container
container:
runtime: docker # or podman; default docker
image: golang:1.23 # required; must provide bash
run_args: [--network, none] # optional extra `run` arguments, added after tinyagent's defaults
Before the first model request tinyagent runs <runtime> run -d --rm --init --label tinyagent=1 -v <cwd>:<cwd> -w <cwd> --user <uid>:<gid> -e HOME=/tmp <run_args> <image> sleep infinity and prints one line to stderr, e.g. environment: container (docker, image=golang:1.23); mount: /home/me/repo. -o trajectories record it as environment (name, runtime, image).
- Mount model: only
--cwd(plus any additional directories, read ones:ro) is shared, at the same path, so the in-process file tools on the host and commands in the container see the same files. Commands run as your uid:gid so files they create are yours on the host (for rootless Podman addrun_args: [--userns=keep-id]).$HOMEis/tmpinside the container; writes outside--cwdstay in the container. Laterrun_argssuch as--useror-e HOME=...override these defaults. - What runs where: only
bashandstart_processcommands run in the container. File tools, MCP servers, provider requests and the trajectory stay on the host. No host environment variables are forwarded (pass them withrun_args: [-e, NAME]if needed). The network is the runtime's default unlessrun_argssay otherwise. - Timeouts and
stop_processkill the command's processes inside the container (each command is tagged with aTINYAGENT_EXECvariable that its children inherit), not only the host-sideexecclient. Output format, return codes, the process limit and buffers are unchanged. - Cleanup: the container is removed when the run ends: normally, on error, at a limit, or on Ctrl-C.
docker ps -a --filter label=tinyagentlists any left behind by a killed tinyagent. - Fail closed: a missing runtime, an unreachable daemon or an image that won't start exits with an error before any provider request. tinyagent never falls back to running commands on the host.
--sandboxcan't be combined with--env container(exit 2); userun_argssuch as--network noneor--read-onlyto restrict the container.--read-onlyruns start no container. With--resume, the current invocation's environment applies. Not supported on Windows.
Notes
read_filetakes optionaloffset(1-based start line) andlimit(max lines) to read part of a large file. A partial read ends with[lines A-B of N]; with neither set, it returns the whole file as before. With--tool-output-files, over-limit tool results are saved to files the model reads this way; see Tool output files. Binary files are rejected; usebash(for example,fileorxxd | head) to inspect them.edit_filereplaces exactly one occurrence ofold_strand fails if it occurs zero or several times. Passreplace_all: trueto replace every occurrence (for a rename); it still fails if there are none, and returnsok (N replacements).grepuses Go regular expressions by default. Setfixed_strings: truefor literal text, e.g.{"pattern":"func(","path":".","include":"*.go","fixed_strings":true}finds literalfunc(in Go files without regex escaping.grepacceptsignore_case: truefor case-insensitive content searches, e.g.{"pattern":"error","path":".","include":"*.log","ignore_case":true}matcheserror,Error, andERRORin.logfiles. The default is case-sensitive; inline regex flags such as(?i)still work. Theincludeglob stays case-sensitive.grepacceptscontext_linesfrom 0 to 20 to include that many surrounding lines on each side, e.g.{"pattern":"connection failed","path":"internal","include":"*.go","fixed_strings":true,"context_lines":1}. Matches usefile:line: content; context usesfile-line- content, and--separates excerpts. The default is 0 and preserves match-only output. Context excerpts merge when they overlap or touch; positive-context results stop at 500 matches or 500 source lines and say when output may be incomplete. Useread_filewithoffsetandlimitfor a larger or targeted excerpt.- Each
bashcall runs in a new process;cdand exported variables don't carry over. On timeout the whole process group is killed (Unix). The model can pass an optionaltimeout(1 to 600 seconds) on abashcall to overridebash_timeout_secondsfor that command. bashpassesstream: trueto also print a command's output to stdout as it is produced; the result returned to the model is the same. By default nothing is streamed.- For servers, watch modes, and other long-running commands,
start_processrunsbash -c <command>in the background and returns a process id (p1,p2, ...) with its first output.read_processreturns only output produced since the last read, optionally waiting up totimeoutmilliseconds (max 60000) for some, or just the lasttaillines.stop_processkills the process and its children (the whole process group on Unix) and reports the exit status and unread output. For example: start a server,read_processuntil it logs "listening", run tests against it, then stop it. - At most
max_background_processes(default 4) run at once, and each keeps only its last 1 MiB of output; a read notes how many bytes were dropped. When the run ends for any reason (submission, a limit, Ctrl-C, or an error), every background process still registered is killed and its status and output tail are saved asbackground_processesin the trajectory, so nothing is left running. On Windows only thebashprocess itself is killed. - Path sandboxing is lexical:
..and absolute paths outside--cwdare rejected, but a symlink inside the root that points outside it is followed.bashisn't sandboxed unless you pass--sandboxor--env container(see Sandboxing commands and Execution environments); without them, only run it on code you trust. - Cost uses a small built-in price table (
internal/model/provider.go). Unknown models cost $0 unless you set the price in the config (top level, or per model under a named provider), so--cost-limitdoes nothing for them. With a positive cost limit, tinyagent prints a warning on stderr before the first request when theopenai,openrouter,anthropicor named-provider model has no price, naminginput_price_per_mtokandoutput_price_per_mtok. The run continues as normal. - The
anthropicprovider turns on prompt caching for every request, so each step reads the history the previous step already sent from the cache instead of paying full price for it again. Input tokens in the summary and trajectory include cached tokens. Cost prices cache writes at 1.25× the input price and cache reads at 0.1× (less on models where Anthropic charges less, such as 0.05× for Claude Opus 5.5 and Sonnet 5.5).input_price_per_mtokoverrides the base price and the multipliers apply on top.
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
tinyagent
command
Command tinyagent is a minimal coding agent: an LLM with file and bash tools.
|
Command tinyagent is a minimal coding agent: an LLM with file and bash tools. |
|
Package embed holds files compiled into the tinyagent binary.
|
Package embed holds files compiled into the tinyagent binary. |
|
internal
|
|
|
acp
Package acp is the agent side of the Agent Client Protocol (https://agentclientprotocol.com), protocol version 1: newline-delimited JSON-RPC 2.0 over stdio.
|
Package acp is the agent side of the Agent Client Protocol (https://agentclientprotocol.com), protocol version 1: newline-delimited JSON-RPC 2.0 over stdio. |
|
agent
Package agent runs the step loop: query the model, execute tool calls, repeat.
|
Package agent runs the step loop: query the model, execute tool calls, repeat. |
|
config
Package config loads agent settings from an optional YAML file layered over defaults.
|
Package config loads agent settings from an optional YAML file layered over defaults. |
|
eventlog
Package eventlog fans run events out to sinks: the --json JSONL stream and the opt-in SQLite event log.
|
Package eventlog fans run events out to sinks: the --json JSONL stream and the opt-in SQLite event log. |
|
hooks
Package hooks runs user-configured commands before and after tool calls, and stop hooks that verify a run before it ends successfully.
|
Package hooks runs user-configured commands before and after tool calls, and stop hooks that verify a run before it ends successfully. |
|
hooks/hookstest
Package hookstest provides a portable hook program for tests: the test binary re-executes itself, so tests need no shell or jq.
|
Package hookstest provides a portable hook program for tests: the test binary re-executes itself, so tests need no shell or jq. |
|
lsp
Package lsp is a small Language Server Protocol client: it starts the configured servers lazily over stdio, keeps the files it has shown them in sync with disk, and collects diagnostics, definitions and references.
|
Package lsp is a small Language Server Protocol client: it starts the configured servers lazily over stdio, keeps the files it has shown them in sync with disk, and collects diagnostics, definitions and references. |
|
lsp/lsptest
Package lsptest is a fake language server for tests: the test binary re-executes itself, so tests need no real language server.
|
Package lsptest is a fake language server for tests: the test binary re-executes itself, so tests need no real language server. |
|
mcptools
Package mcptools connects explicitly configured local MCP servers.
|
Package mcptools connects explicitly configured local MCP servers. |
|
model
Package model talks to LLM providers using a single OpenAI-shaped message format.
|
Package model talks to LLM providers using a single OpenAI-shaped message format. |
|
outschema
Package outschema loads a user-supplied JSON Schema for --output-schema and validates a run's structured result against it.
|
Package outschema loads a user-supplied JSON Schema for --output-schema and validates a run's structured result against it. |
|
sandboxnet
Package sandboxnet implements the sandbox network allowlist: entry parsing and matching, and the filtering HTTP proxy that sandboxed commands use.
|
Package sandboxnet implements the sandbox network allowlist: entry parsing and matching, and the filtering HTTP proxy that sandboxed commands use. |
|
skills
Package skills discovers Agent Skills (folders holding a SKILL.md) in the project's and the user's .agents/skills directories, renders the catalog added to the system prompt, and loads a skill's instructions or one of its bundled files for the skill tool.
|
Package skills discovers Agent Skills (folders holding a SKILL.md) in the project's and the user's .agents/skills directories, renders the catalog added to the system prompt, and loads a skill's instructions or one of its bundled files for the skill tool. |
|
snapshot
Package snapshot captures and restores the files under a working directory for opt-in workspace snapshots (--snapshots) and --rewind.
|
Package snapshot captures and restores the files under a working directory for opt-in workspace snapshots (--snapshots) and --rewind. |
|
tools
Package tools implements the agent's file and shell tools, sandboxed to a root directory.
|
Package tools implements the agent's file and shell tools, sandboxed to a root directory. |
|
trajectory
Package trajectory records an agent run as JSON.
|
Package trajectory records an agent run as JSON. |
|
transcript
Package transcript renders a saved trajectory as a Markdown transcript for people: the task, each step's assistant text, tool calls and results, the run's plan, compactions, model switches and resume boundaries, and the outcome.
|
Package transcript renders a saved trajectory as a Markdown transcript for people: the task, each step's assistant text, tool calls and results, the run's plan, compactions, model switches and resume boundaries, and the outcome. |
|
update
Package update replaces the running binary with the latest GitHub release.
|
Package update replaces the running binary with the latest GitHub release. |