zot/README.md

508 lines
30 KiB
Markdown
Raw Normal View History

<img src="internal/assets/zot-logo.png" alt="zot" width="130" height="130" />
2026-04-17 20:36:38 +02:00
# zot
perf(anthropic): fix cost double-count, tighten caching, correct catalog The status-bar was showing 2x the real cost. Anthropic's SSE stream sends the full cumulative usage payload on both message_start AND message_delta, and our code was summing them with += on each. Cache tokens, the biggest cost component on multi-turn sessions, were therefore counted twice on every single API call. Fix: assign instead of accumulate within one Stream() invocation. Cross-call accumulation still happens correctly in core.CostTracker.Add(). Verified end-to-end: a truly fresh "read sample.ts on desktop" session that used to report $0.15 now reports $0.07 with the same cache-hit rate. While chasing that, audited and corrected the rest of the request pipeline so the cache actually hits cleanly. Provider layer (internal/provider/anthropic.go): - cache_control on the Claude Code identity line (was uncached), giving Anthropic a first stable checkpoint independent of the user system prompt. Turns a cold start from R=0 into R>0 for any subsequent fresh session within the cache TTL. - tool_result blocks go in their OWN new user message instead of merging into the preceding user message. Merging was mutating the prior user message's content array between turns, busting byte-identical prefix match in Anthropic's cache. - tagLastUserCache: exactly one cache_control on the last user message (was two), so identity + sysprompt + last-tool + last-user fits Anthropic's 4-breakpoint budget exactly. - user-agent dropped its "(external, cli)" suffix to match the canonical Claude Code string exactly. - ZOT_DEBUG_ANTHROPIC=<path> env hook appends each outgoing request body (one JSON object per line) to that file. Off by default; for debugging cache / cost issues in the field. - Usage field handling now correctly assigns the latest value from each SSE event instead of summing. Core (internal/core/tool.go): - Registry.Specs() now sorts tools alphabetically. Go map iteration order is randomized per call; randomized tool arrays were breaking Anthropic's byte-level prefix match on every single call within a session. System prompt (internal/agent/systemprompt.go): - Restored a substantial default prompt with structured tools + operating guidelines sections. The earlier aggressive trim dropped us under Anthropic's 1024-token minimum cacheable prefix floor: prefixes below 1024 tokens are silently NOT cached by Anthropic, so every fresh session started cold with R=0 no matter what else we did. - Current default ~1040 tokens on its own; with identity and tools it's ~1400, comfortably above the 1024 floor. - --system-prompt, --append-system-prompt, and $ZOT_HOME/SYSTEM.md escape hatches all still work and take precedence. Model catalog (internal/provider/models.go): - claude-opus-4-5: 1M ctx / 128k max -> 200k ctx / 64k max. I had over-extrapolated; 1M context is a 4.6+ feature. - gpt-5.4: 400k -> 272k. Canonical value on both the OpenAI direct API and the ChatGPT Codex OAuth backend. - gpt-5.1, gpt-5.2, gpt-5.3, gpt-5.4-mini: pinned to 272k. OpenAI advertises 400k on direct and Codex caps at 272k. zot serves both from one catalog row per id, so we pin to the smaller number to keep the context-usage meter honest under subscription auth. Direct-API users see a conservative estimate instead of an inflated one. README: - Tiny capitalization touch-up on the opening line.
2026-04-19 18:57:18 +02:00
Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
2026-04-17 20:36:38 +02:00
2026-04-18 09:15:46 +02:00
- one static binary.
- two providers atm (anthropic, openai/codex).
- four tools (read, write, edit, bash).
- three run modes (interactive tui, print, json).
- built-in telegram bot.
- extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zot ext install` or `zot --ext`. See [docs/extensions.md](docs/extensions.md).
feat: skills — reusable instructions discovered from SKILL.md files A skill is a single SKILL.md file with a YAML frontmatter header, discovered from well-known directories at startup. Two integration points: 1. The system prompt gains a short manifest listing each skill's name + one-line description. Cheap (a few dozen tokens). 2. A built-in `skill` tool lets the model load any one skill's full body on demand and follow the instructions there. The on-demand-load model keeps token usage cheap: only the manifest goes into every request; the body is fetched as a tool result the one or two turns the model actually needs it. Discovery (priority order — first match wins per name): ./.zot/skills/<name>/SKILL.md project (native) $ZOT_HOME/skills/<name>/SKILL.md global (native) ./.claude/skills/<name>/SKILL.md project (claude-compat) ~/.claude/skills/<name>/SKILL.md global (claude-compat) ./.agents/skills/<name>/SKILL.md project (agent-compat) ~/.agents/skills/<name>/SKILL.md global (agent-compat) Compat paths are deliberate: any SKILL.md written for a related ecosystem works in zot unchanged. Frontmatter fields: name optional; defaults to directory name description required; shown in the system prompt allowed-tools optional list; informational (no enforcement) permissions optional per-tool patterns; informational allowed-tools and permissions are parsed but not enforced this version. They render in the body so the model can self-regulate. What landed: - internal/skills: discovery + frontmatter parsing (no yaml dep — hand-rolled subset for the limited shape skills use), the on- demand `skill` tool implementing core.Tool, system-prompt addendum, FindByName lookup helper. Real unit tests cover all five locations + dedup priority + parser corner cases. - internal/agent/build.go: Resolve discovers skills, registers the skill tool when at least one was found, appends the manifest to the system prompt's append list. Resolved gains a SkillTool field so the tui can read the live set. - internal/agent/modes/skills_dialog.go: /skills picker with two modes — list view (cursor + paging) and body view (markdown- rendered with scroll). Refreshes its snapshot each open via cfg.SkillSnapshot so edits to a SKILL.md during a session are reflected immediately. - /skills slash command + entry in slashCatalog. - examples/skills/code-review and examples/skills/test-fix as starter skills demonstrating procedural style + frontmatter. - docs/skills.md: full reference covering discovery, frontmatter, inspection, authoring tips, and ecosystem compat. End-to-end verified against the live anthropic backend: prompt: "What skills do you have available?" -> "- code-review\n- test-fix" prompt: "Use the skill tool to load the code-review skill, then summarize step 1." -> [tool_call] skill({"name":"code-review"}) -> [tool_result] body returned -> "Step 1 is to establish what changed by running git status..."
2026-04-19 14:32:30 +02:00
- reusable instructions via `SKILL.md` files; see [docs/skills.md](docs/skills.md).
2026-04-18 09:15:46 +02:00
- no community atm.
2026-04-17 20:36:38 +02:00
## Install
2026-04-17 20:36:38 +02:00
### One-liner (macOS, Linux)
```bash
curl -fsSL https://www.zot.sh/install.sh | bash
```
Detects your OS and architecture, downloads the latest release from GitHub, verifies the SHA-256 against the release's `checksums.txt`, extracts the binary, and drops it in `/usr/local/bin`, `~/.local/bin`, or `~/bin`, whichever is writable first. Pass a version or prefix to pin:
```bash
curl -fsSL https://www.zot.sh/install.sh | bash -s -- v0.0.1 ~/bin
```
### One-liner (Windows, PowerShell)
```powershell
iwr -useb https://www.zot.sh/install.ps1 | iex
```
Drops `zot.exe` into `$HOME\bin` and adds it to the user PATH if missing. Open a fresh terminal afterwards.
### go install
2026-04-17 20:36:38 +02:00
```bash
go install github.com/patriceckhart/zot/cmd/zot@latest
```
### From source
2026-04-17 20:36:38 +02:00
```bash
git clone https://github.com/patriceckhart/zot
cd zot
make build # produces ./bin/zot
make install # into $GOPATH/bin
```
### Prebuilt binaries
Every release on the [releases page](https://github.com/patriceckhart/zot/releases) ships archives for Linux, macOS, and Windows on amd64 and arm64 (except windows/arm64), plus a `checksums.txt` file. Download, verify, `chmod +x`, and drop on your `$PATH`.
## Authenticate
2026-04-17 20:36:38 +02:00
The easiest way is to just run `zot` and type `/login`. The TUI opens even without credentials and walks you through a browser-based login flow.
2026-04-17 20:36:38 +02:00
### Credential lookup order
2026-04-17 20:36:38 +02:00
1. `--api-key` flag
2. `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` env var
3. `$ZOT_HOME/auth.json` (API key or OAuth token; mode 0600)
2026-04-17 20:36:38 +02:00
`$ZOT_HOME` defaults to:
- macOS: `~/Library/Application Support/zot`
- Linux: `$XDG_STATE_HOME/zot` or `~/.local/state/zot`
- Windows: `%LOCALAPPDATA%\zot`
2026-04-17 20:36:38 +02:00
### `/login` flow
Run `zot` and type `/login`. Pick one of two methods:
2026-04-17 20:36:38 +02:00
- **API key**: a small local web server starts on `127.0.0.1:<free-port>`, your browser opens a form, you paste your `sk-ant-...` or `sk-...` key. zot probes the provider once and saves it to `auth.json` if accepted.
- **Subscription**: use your Claude Pro/Max or ChatGPT Plus/Pro subscription. The OAuth flow pins the callback to a fixed port per provider (`localhost:53692` for Anthropic, `localhost:1455` for OpenAI) because those are the only ports their auth servers will redirect to.
- Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers.
- OpenAI uses the Codex CLI OAuth flow. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token.
2026-04-17 20:36:38 +02:00
> **Note on subscription login.** The OAuth client IDs used are the ones published in Anthropic's Claude Code CLI and OpenAI's Codex CLI. Reusing them from a third-party tool is against their terms of service and may be revoked at any time. Use it at your own risk; the API-key flow is the safe default.
2026-04-17 20:36:38 +02:00
### Token refresh
2026-04-18 09:15:46 +02:00
OAuth access tokens are short-lived (Anthropic ~8h, OpenAI ~30d). zot refreshes them automatically:
2026-04-18 09:15:46 +02:00
- At every credential lookup, zot checks the stored `expiry` and, if past it (with a 60s safety margin), hits the provider's `oauth/token` endpoint with the stored `refresh_token`, persists the new `access_token`, `refresh_token`, and `expiry` back to `auth.json`, and hands the fresh token to the client.
- The telegram bridge additionally refreshes once per turn so a bot that runs for days keeps working without manual intervention.
- If the refresh itself fails (the `refresh_token` was revoked, or the account was logged out everywhere), the error bubbles up to the caller: the TUI shows it in the status line, the bot replies with it in your DM. Run `/login` to get a fresh token pair.
2026-04-18 09:15:46 +02:00
All data lives under `$ZOT_HOME`:
2026-04-17 20:36:38 +02:00
```
$ZOT_HOME/
├── config.json # last-used provider/model/theme, saved automatically
├── auth.json # api keys and oauth tokens (mode 0600)
├── sessions/ # jsonl transcripts, one dir per cwd
├── models-cache.json # live /v1/models discovery cache (6h ttl)
├── SYSTEM.md # optional: replaces the default system prompt
├── skills/ # optional: user SKILL.md files (opt in with --with-skills)
├── extensions/ # installed extensions, one dir per extension
2026-04-17 20:36:38 +02:00
└── logs/ # app log files
```
Drop a `SYSTEM.md` in `$ZOT_HOME` to replace the built-in identity and guidelines for every run. `--system-prompt` still wins per-invocation. Delete the file to revert to the default.
## Changelog on update
feat(tui): show github release notes once after upgrading The first time a user launches a newer zot binary, the tui pops a dismissible overlay with the release notes for that version. Press any key to close; the version goes into config.json's last_changelog_shown so the same notes never reappear. Lifecycle: - dev builds (version "" / "dev" / "0.0.0"): no fetch ever - first-ever launch (no LastChangelogShown stored): seed it silently with the current version so fresh installs don't get release notes dumped at them - subsequent launches with the same version: skipped (config already records that version was shown) - launch with a different version: fetch the release page from https://api.github.com/repos/patriceckhart/zot/releases/tags/v<ver> and open the dialog if the body is non-empty - dismiss writes LastChangelogShown so it never repeats Components: - internal/agent/changelog.go: FetchChangelog/Async, and the Should/Mark/Seed helpers around config.LastChangelogShown. Honours $GITHUB_TOKEN exactly like the install scripts and the existing update check, so private-repo fetches work with auth. - internal/agent/modes/changelog_dialog.go: the overlay. Markdown body via the existing RenderMarkdown pipeline, scrollable with up/down/pgup/pgdn, any other key dismisses. - internal/agent/modes/interactive.go: new ChangelogChan and OnChangelogDismiss config fields, single-shot select case in Run() that opens the dialog when a payload arrives. - internal/agent/cli.go: spawns the fetch goroutine, gates it on ShouldShowChangelog, wires OnChangelogDismiss to MarkChangelogShown so the version is persisted. Best-effort: timeouts at 4s, missing tag => silent skip, network failure => silent skip + retry on next launch (no LastChangelogShown update if we never showed anything). Documented in the README under the SYSTEM.md note.
2026-04-19 16:12:13 +02:00
The first time you launch a newer zot binary, the TUI shows the GitHub release notes once in a dismissible overlay. Press any key to close. The version is recorded in `config.json`'s `last_changelog_shown` so the same release notes never reappear. Fresh installs don't see a changelog (no upgrade has happened yet). The fetch is best-effort: a network failure or a missing release page silently skips, with another attempt on the next launch.
feat(tui): show github release notes once after upgrading The first time a user launches a newer zot binary, the tui pops a dismissible overlay with the release notes for that version. Press any key to close; the version goes into config.json's last_changelog_shown so the same notes never reappear. Lifecycle: - dev builds (version "" / "dev" / "0.0.0"): no fetch ever - first-ever launch (no LastChangelogShown stored): seed it silently with the current version so fresh installs don't get release notes dumped at them - subsequent launches with the same version: skipped (config already records that version was shown) - launch with a different version: fetch the release page from https://api.github.com/repos/patriceckhart/zot/releases/tags/v<ver> and open the dialog if the body is non-empty - dismiss writes LastChangelogShown so it never repeats Components: - internal/agent/changelog.go: FetchChangelog/Async, and the Should/Mark/Seed helpers around config.LastChangelogShown. Honours $GITHUB_TOKEN exactly like the install scripts and the existing update check, so private-repo fetches work with auth. - internal/agent/modes/changelog_dialog.go: the overlay. Markdown body via the existing RenderMarkdown pipeline, scrollable with up/down/pgup/pgdn, any other key dismisses. - internal/agent/modes/interactive.go: new ChangelogChan and OnChangelogDismiss config fields, single-shot select case in Run() that opens the dialog when a payload arrives. - internal/agent/cli.go: spawns the fetch goroutine, gates it on ShouldShowChangelog, wires OnChangelogDismiss to MarkChangelogShown so the version is persisted. Best-effort: timeouts at 4s, missing tag => silent skip, network failure => silent skip + retry on next launch (no LastChangelogShown update if we never showed anything). Documented in the README under the SYSTEM.md note.
2026-04-19 16:12:13 +02:00
## Usage
2026-04-17 20:36:38 +02:00
```bash
zot # interactive tui
zot "fix the failing test" # tui, pre-filled prompt
zot -p "list all go files" # print final text, exit
zot --json "refactor main.go" # newline-delimited json events, exit
zot --continue # resume the most recent session for this cwd
zot --resume # pick a session to resume
zot --list-models # show supported models
zot --help
```
## Flags
2026-04-17 20:36:38 +02:00
| Flag | Description |
2026-04-17 20:36:38 +02:00
|---|---|
| `--provider anthropic\|openai` | Pick the provider. |
| `--model <id>` | Pick the model (see `--list-models`). |
| `--api-key <key>` | Override the API key. |
| `--base-url <url>` | Override the provider base URL (tests, self-hosted). |
| `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). |
| `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |
| `--reasoning low\|medium\|high` | Enable reasoning on supported models. |
| `-c`, `--continue` | Resume the latest session for this cwd. |
| `-r`, `--resume` | Pick a session to resume. |
| `--session <path>` | Resume a specific session file. |
| `--no-session` | Don't read or write session files. |
| `--cwd <path>` | Use `<path>` as the working directory. |
| `--no-tools` | Disable all tools. |
| `--tools <csv>` | Only enable the listed tools. |
| `--max-steps <n>` | Cap agent loop iterations (default 50). |
| `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). |
| `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. |
| `--with-skills` | Also load user-installed skills. Without this, only the built-in skills shipped in the binary are loaded. |
| `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. |
| `--no-yolo` | Confirm every tool call before it runs (interactive TUI only). A dialog shows the tool name and a one-line preview of its args with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. Ignored with a stderr warning in print / json / rpc modes, where tools still run freely so scripts and automation keep working. Type `/yolo` in the TUI to disable the gate for the rest of the session. |
2026-04-17 20:36:38 +02:00
## Tools
2026-04-17 20:36:38 +02:00
- `read`: read text files, or inline images (PNG, JPEG, GIF, WebP).
- `write`: create or overwrite files, making parent directories as needed.
- `edit`: one or more exact-match replacements in an existing file.
- `bash`: run a shell command in the session cwd, with merged stdout/stderr and a timeout.
2026-04-17 20:36:38 +02:00
When the sandbox is on (see `/jail`), all four tools refuse paths outside the session cwd.
2026-04-17 20:36:38 +02:00
## Modes
2026-04-17 20:36:38 +02:00
- **Interactive** (default): chat TUI with streaming output, spinner, cost meter, slash commands.
- **Print**: `zot -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout.
- **JSON**: `zot --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md).
- **RPC**: `zot rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zot in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients.
feat: zotcore SDK + zot rpc subprocess protocol two new ways to embed the zot agent runtime in third-party apps: 1. pkg/zotcore - public Go SDK - Runtime type: New(Config), Prompt(ctx,text,imgs)->chan Event, Cancel, Compact, SetModel, State, Messages, Cost, ListModels, Close. Concurrent-safe; one prompt at a time per Runtime, ErrBusy if you try to overlap. Spawn multiple Runtimes for multiple projects. - Public types mirror the JSON-RPC wire schema 1:1 so consumers can share parsing code with the out-of-process clients. - Internal core/agent/provider stay internal; SDK is a thin facade that exposes only what's stable. 2. zot rpc subcommand - newline-delimited JSON on stdin/stdout - 'zot rpc' (or 'zot --rpc') turns the agent runtime into a subprocess that any language can drive via pipes. - Commands: hello, prompt, abort, compact, get_state, get_messages, clear, set_model, get_models, ping. Each optionally carries an id; the matching response echoes it. - Stream notifications: turn_start, user_message, assistant_start, text_delta, tool_call, tool_progress, tool_result, assistant_message, usage, turn_end, done, error, compact_done. Same shape as the existing --json mode events (modes.EventToJSON / ContentToJSON were exported for reuse). - Auth: optional ZOTCORE_RPC_TOKEN env var; first command must be hello {token: ...} when set. Without the env var the spawning process is implicitly trusted. - Concurrency: one prompt or compact at a time per process, enforced by a turnMu mutex. abort fires immediately regardless. Stdin close exits the process. 3. docs/rpc.md - full schema reference 4. examples/rpc/{python,node,shell,go} - reference clients 5. examples/sdk - in-process Go embedding example 6. README updated with a new modes entry and an embedding section
2026-04-19 12:26:48 +02:00
## Embedding
feat: zotcore SDK + zot rpc subprocess protocol two new ways to embed the zot agent runtime in third-party apps: 1. pkg/zotcore - public Go SDK - Runtime type: New(Config), Prompt(ctx,text,imgs)->chan Event, Cancel, Compact, SetModel, State, Messages, Cost, ListModels, Close. Concurrent-safe; one prompt at a time per Runtime, ErrBusy if you try to overlap. Spawn multiple Runtimes for multiple projects. - Public types mirror the JSON-RPC wire schema 1:1 so consumers can share parsing code with the out-of-process clients. - Internal core/agent/provider stay internal; SDK is a thin facade that exposes only what's stable. 2. zot rpc subcommand - newline-delimited JSON on stdin/stdout - 'zot rpc' (or 'zot --rpc') turns the agent runtime into a subprocess that any language can drive via pipes. - Commands: hello, prompt, abort, compact, get_state, get_messages, clear, set_model, get_models, ping. Each optionally carries an id; the matching response echoes it. - Stream notifications: turn_start, user_message, assistant_start, text_delta, tool_call, tool_progress, tool_result, assistant_message, usage, turn_end, done, error, compact_done. Same shape as the existing --json mode events (modes.EventToJSON / ContentToJSON were exported for reuse). - Auth: optional ZOTCORE_RPC_TOKEN env var; first command must be hello {token: ...} when set. Without the env var the spawning process is implicitly trusted. - Concurrency: one prompt or compact at a time per process, enforced by a turnMu mutex. abort fires immediately regardless. Stdin close exits the process. 3. docs/rpc.md - full schema reference 4. examples/rpc/{python,node,shell,go} - reference clients 5. examples/sdk - in-process Go embedding example 6. README updated with a new modes entry and an embedding section
2026-04-19 12:26:48 +02:00
Two ways to drive zot from another program:
feat: zotcore SDK + zot rpc subprocess protocol two new ways to embed the zot agent runtime in third-party apps: 1. pkg/zotcore - public Go SDK - Runtime type: New(Config), Prompt(ctx,text,imgs)->chan Event, Cancel, Compact, SetModel, State, Messages, Cost, ListModels, Close. Concurrent-safe; one prompt at a time per Runtime, ErrBusy if you try to overlap. Spawn multiple Runtimes for multiple projects. - Public types mirror the JSON-RPC wire schema 1:1 so consumers can share parsing code with the out-of-process clients. - Internal core/agent/provider stay internal; SDK is a thin facade that exposes only what's stable. 2. zot rpc subcommand - newline-delimited JSON on stdin/stdout - 'zot rpc' (or 'zot --rpc') turns the agent runtime into a subprocess that any language can drive via pipes. - Commands: hello, prompt, abort, compact, get_state, get_messages, clear, set_model, get_models, ping. Each optionally carries an id; the matching response echoes it. - Stream notifications: turn_start, user_message, assistant_start, text_delta, tool_call, tool_progress, tool_result, assistant_message, usage, turn_end, done, error, compact_done. Same shape as the existing --json mode events (modes.EventToJSON / ContentToJSON were exported for reuse). - Auth: optional ZOTCORE_RPC_TOKEN env var; first command must be hello {token: ...} when set. Without the env var the spawning process is implicitly trusted. - Concurrency: one prompt or compact at a time per process, enforced by a turnMu mutex. abort fires immediately regardless. Stdin close exits the process. 3. docs/rpc.md - full schema reference 4. examples/rpc/{python,node,shell,go} - reference clients 5. examples/sdk - in-process Go embedding example 6. README updated with a new modes entry and an embedding section
2026-04-19 12:26:48 +02:00
- **Go in-process**: import `github.com/patriceckhart/zot/pkg/zotcore`. One `Runtime` per project; `Prompt(ctx, text, images)` returns a channel of `Event`. Small example in `examples/sdk/`.
- **Any language, out-of-process**: spawn `zot rpc` as a subprocess and exchange newline-delimited JSON over its stdin/stdout. Wire format and event schema in [docs/rpc.md](docs/rpc.md). Reference clients live under `examples/rpc/`.
feat: zotcore SDK + zot rpc subprocess protocol two new ways to embed the zot agent runtime in third-party apps: 1. pkg/zotcore - public Go SDK - Runtime type: New(Config), Prompt(ctx,text,imgs)->chan Event, Cancel, Compact, SetModel, State, Messages, Cost, ListModels, Close. Concurrent-safe; one prompt at a time per Runtime, ErrBusy if you try to overlap. Spawn multiple Runtimes for multiple projects. - Public types mirror the JSON-RPC wire schema 1:1 so consumers can share parsing code with the out-of-process clients. - Internal core/agent/provider stay internal; SDK is a thin facade that exposes only what's stable. 2. zot rpc subcommand - newline-delimited JSON on stdin/stdout - 'zot rpc' (or 'zot --rpc') turns the agent runtime into a subprocess that any language can drive via pipes. - Commands: hello, prompt, abort, compact, get_state, get_messages, clear, set_model, get_models, ping. Each optionally carries an id; the matching response echoes it. - Stream notifications: turn_start, user_message, assistant_start, text_delta, tool_call, tool_progress, tool_result, assistant_message, usage, turn_end, done, error, compact_done. Same shape as the existing --json mode events (modes.EventToJSON / ContentToJSON were exported for reuse). - Auth: optional ZOTCORE_RPC_TOKEN env var; first command must be hello {token: ...} when set. Without the env var the spawning process is implicitly trusted. - Concurrency: one prompt or compact at a time per process, enforced by a turnMu mutex. abort fires immediately regardless. Stdin close exits the process. 3. docs/rpc.md - full schema reference 4. examples/rpc/{python,node,shell,go} - reference clients 5. examples/sdk - in-process Go embedding example 6. README updated with a new modes entry and an embedding section
2026-04-19 12:26:48 +02:00
Both interfaces share the same event schema, so transcripts captured by one can be replayed through the other.
2026-04-17 20:36:38 +02:00
## Slash commands
2026-04-17 20:36:38 +02:00
Type `/` in the TUI to open the autocomplete popup. Available commands:
2026-04-17 20:36:38 +02:00
| Command | Description |
2026-04-17 20:36:38 +02:00
|---|---|
| `/help` | Show key bindings and commands. |
| `/login` | Log in via API key or subscription (opens a dialog). |
| `/logout [provider]` | Clear credentials for `anthropic`, `openai`, or all when omitted. |
| `/model` | Pick a model from a list (or `/model <id>` to set directly). |
| `/sessions` | Resume a previous session for this directory. |
feat(session): /session fork + /session tree Branch semantics for conversations: rewind to a past user message and continue from there in a new session, with a visual tree picker to switch between branches later. /session fork Opens the /jump turn picker in fork mode. Pick any past user message; zot copies every message from the session start up to and including that turn into a new session file, records the parent id + fork point in the new meta, and swaps the running agent onto the new branch. The parent session file stays on disk unchanged; you can return to it later via /session tree. /session tree Shows every session in the current cwd arranged by parent/child relationships. Depth-first flatten with two-space indent per level; the current session is tagged "[current]". Pick any other entry to switch into it (same semantics as /sessions). Why both commands: /sessions remains the "flat list of everything in this directory" resume picker. /session tree is the fork-aware variant. /session fork is the equivalent of git branch; /session tree is the equivalent of checkout. core additions: SessionMeta gains two fields: - Parent string (parent session ID, empty for roots) - ForkPoint int (0-indexed message position of the cut) core.BranchSession(parentPath, root, cwd, version, upToIdx) Reads the parent session, writes a new session file in SessionsDir(root, cwd) containing the first upToIdx message rows + any usage rows that came before the cut. The new meta records Parent=<parent id>, ForkPoint=<upToIdx>, fresh id, cwd, Started, Version. core.BuildSessionTree(root, cwd) []*TreeNode Walks every session file in the cwd dir, reads each one's meta, links children to parents by ID. Returns the forest rooted at parentless sessions. Missing-parent sessions (if the parent file was manually deleted) surface as roots so they stay discoverable. core.FindSessionByID(root, cwd, id) string O(n) lookup used when resolving a tree pick back to a file path. Files in the dir are small in practice. readSessionMeta helper (unexported) reads just the first line of a session file and decodes the meta; avoids loading the whole transcript when BuildSessionTree only needs the parent/id pair. tui additions: session_tree_dialog.go Flat list with indent-based nesting to match the other picker dialogs' shape. Up/down moves; enter switches; esc cancels. Rows show "<relative-when> <prompt-preview> N msgs" with a muted "[current]" tag on the current session. interactive.go - sessionTreeDialog field + constructor. - /session fork / /session tree cases in doSessionOp. - doSessionFork flips pendingFork=true and opens the jumpDialog over the agent's current messages. - The jump-dialog key handler checks pendingFork; if set, routes the selection to applyForkSelection instead of the normal applyJumpSelection. pendingFork clears on select OR on dismiss so a later plain /jump isn't hijacked. - applyForkSelection calls FlushSession (so the branch gets everything in memory, not just what was lazy-flushed), then core.BranchSession, then LoadSession to swap. - doSessionTree calls FlushSession first so the tree shows the true current message count, then core.BuildSessionTree, then hands the forest to the tree dialog. - applySessionTreeSelection hands the picked path to LoadSession. tests: TestBranchSessionCopiesPrefix Parent with three messages; branch at upToIdx=2; verify the child has exactly 2 messages, parent ID matches, fork point = 2, ID rotated. TestBuildSessionTree Parent + 2 branches off it; verify roots=[parent], roots[0].Children has both branches. README: /session row expanded to cover all four ops.
2026-04-20 11:10:56 +02:00
| `/session` | Four ops on the current session: `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. |
| `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |
| `/btw` | Side chat with full context that doesn't add to the main thread. |
| `/skills` | List discovered skills (SKILL.md files) and preview their bodies. |
| `/compact` | Summarize the transcript into one message to free up context. |
| `/study` | Run the canned prompt "Read and understand everything in the current directory." so the agent has full project context before you start asking targeted questions. |
| `/jail` | Confine tools to the current directory. |
| `/unjail` | Allow tools to touch paths outside again. |
feat(ext): phase 4 - full-event interception, arg rewrites, /reload-ext Clears every deferred extension todo in one push: 1) Interception expands to three events: tool_call (already shipped), turn_start (gate the turn before the model call, e.g. rate-limit / business-hour), and assistant_message (suppress or rewrite the user-visible text while keeping the model's original output in the transcript). 2) Tool-call args can now be rewritten mid-flight. An interceptor returning modified_args replaces the JSON the tool actually receives, without the model seeing the rewrite. Chains: each subscriber sees the previous one's output, letting guards successively redact / patch / augment. Invalid JSON is dropped safely. 3) /reload-ext hot-reloads every extension without restarting zot. The manager gracefully shuts down all running subprocesses, re-reads extension.json from disk, respawns (including --ext paths remembered from startup), and the host rebuilds the agent's tool registry in-place so freshly-registered tools are callable immediately. Wire-format changes (extproto): - EventInterceptResponseFromExt gains modified_args and replace_text fields (both optional, ignored when block=true). - EventInterceptFromHost gains Step (for turn_start) and Text (for assistant_message) alongside the existing tool_call payload. Core agent changes: - BeforeToolExecute signature now returns (allowed, reason, modifiedArgs json.RawMessage). Non-nil+valid JSON args replace tc.Arguments before Tool.Execute runs. - New BeforeTurn hook, invoked in runLoop before oneTurn. Blocking cancels the turn with an EvTurnEnd{StopError} carrying the reason. - New BeforeAssistantMessage hook, invoked after finalMsg is assembled but before the EvAssistantMessage emit. Supports suppress (block=true) and text rewrite (replace_text). Transcript always gets the original; UI gets the rewritten text. - New SetTools(reg) so /reload-ext can swap the registry on the live agent under the agent mutex. Manager changes: - InterceptToolCall now returns InterceptResult (Block, Reason, ModifiedArgs, ReplaceText), with a chain that folds rewrites. - New InterceptTurnStart and InterceptAssistantMessage. - New Reload(ctx, grace) tears down and respawns everything, returning ReloadStats{Stopped, Loaded, Ready, Errors}. - New SetOnReload(fn) callback the host uses to rebuild the agent tool registry after a reload. - LoadExplicit remembers --ext paths so Reload respawns them. - subscribe accepts "tool_call", "turn_start", "assistant_message" under "intercept". SDK (pkg/zotext): - New handler types: ToolCallHandler, TurnStartHandler, AssistantMessageHandler, and their decision structs (ToolCallDecision with ModifiedArgs, AssistantMessageDecision with ReplaceText). - New registration methods: InterceptToolCallX (rich variant of the existing InterceptToolCall), InterceptTurnStart, InterceptAssistantMessage. - dispatchIntercept routes per-event with panic recovery and always emits exactly one event_intercept_response. TUI: - /reload-ext slash command registered in slashCatalog and runSlash. Added to slashCancelsTurn so it waits for idle like /compact does. - runReloadExt shows a "reloading extensions..." status, runs the Manager.Reload on a goroutine, and reports the resulting stats. Tests: - internal/core/intercept_test.go: verifies args are actually rewritten on the way to Tool.Execute, malformed JSON is ignored, and block surfaces the reason as an error ToolResult. - internal/agent/extensions/intercept_test.go: end-to-end with a bash extension subprocess that blocks rm -rf, rewrites other bash args to "echo GUARDED:", passes through read calls, allows turn_start, and redacts SECRET in assistant messages. Second test verifies Reload respawns the subprocess, re-registers its command, and fires the onReload callback. Docs: - docs/extensions.md: rewrote the intercept section to cover all three events, added a table of event_intercept_response fields, documented the /reload-ext hot-reload command, expanded the SDK section with examples of every handler, moved the old "future" items into a shipped Phase 4. - README.md: extensions summary mentions intercept beyond tool_call, /reload-ext added to the slash-commands table and to the turn-cancel list in "Queued messages".
2026-04-19 17:02:04 +02:00
| `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
feat(tool-gate): --no-yolo flag, confirm dialog, /yolo runtime toggle Adds a per-tool-call confirmation gate. Default stays yolo mode (tools run freely, same as today). Pass --no-yolo to require explicit user approval before each tool invocation. Interactive TUI: A dialog appears before every tool call. Shows the tool name and a one-line preview of its args (command / path / url / etc.) with four choices, selectable by arrow keys or numeric shortcut: 1. yes (run this call) 2. yes, always this tool (skip prompts for this tool, session-scoped) 3. yes, always (skip prompts for every tool, session-scoped) 4. no (refuse and let the model try something else) Esc/ctrl+c refuses the current prompt. Esc during a running turn both cancels the turn AND drains any pending confirm so the agent goroutine doesn't deadlock. Multiple pending confirms are queued and answered one at a time with a count visible in the header. Type /yolo to disable the gate for the rest of the session (equivalent to the "yes, always" choice but without needing a pending prompt). Any currently-open confirm auto-allows so the agent keeps moving. Print / JSON / RPC modes: No interactive prompt is available, so every tool call is auto-refused with a reason the model can learn from: "tool call refused: --no-yolo is active and there is no interactive prompt in this mode; ask the user what to do instead". Observed behaviour: the model pivots to asking the user directly instead of looping on the same tool. Implementation: internal/core/confirm.go - ConfirmDecision, Confirmer interface - ConfirmGate with session-scoped memory for "always this tool" and "always everything" decisions, both concurrency-safe - BuildPreview: turns {"command":"ls"} into "ls", etc. - Lives in core to avoid a modes -> agent import cycle internal/core/confirm_test.go - Tests: nil gate allows, nil-inner refuses with reason, one- shot allow doesn't remember, remember-tool short-circuits only same tool, remember-all short-circuits everything, refusal reasons surface, empty-reason gets a default, runtime AllowAll works, BuildPreview handles each field internal/agent/modes/confirm_dialog.go - Queue-based dialog, HandleKey wiring, CancelAll and AllowAllPending for the two exit cases internal/agent/modes/interactive.go - InteractiveConfig gains NoYolo + ConfirmGate fields - Interactive implements core.Confirmer via a response channel - Confirm dialog dispatched FIRST in the key-handler chain so keys never leak to other dialogs while the agent is blocked - Esc-while-busy also calls confirmDialog.CancelAll so the agent unblocks - /yolo slash command handled in runSlash internal/agent/cli.go - Constructs the ConfirmGate when args.NoYolo is set, BeforeToolExecute calls it first, extensions only see calls the user already approved - After iv is built, SetConfirmer(iv) wires the gate's inner so interactive + gate share the same struct - wireNoYoloAutoRefuse() for print / json modes internal/agent/args.go - --no-yolo flag and help text internal/agent/modes/slash_suggest.go - /yolo added to slashCatalog Verified end-to-end: fresh zot --no-yolo -p "read sample.ts" now returns "I can't read files in this mode (--no-yolo without an interactive prompt). How would you like to proceed" instead of actually reading.
2026-04-19 19:12:45 +02:00
| `/yolo` | Turn off `--no-yolo` confirmation for the rest of this session. |
feat(tui): /telegram connect | disconnect | status The Telegram bridge can now mirror into the running TUI session. Runs inside the zot process (no daemon needed); DMs from the paired user become prompts in the current agent, and the assistant's final text is sent back to Telegram. You see the full conversation in the TUI in real time and on your phone. UI: - /telegram or /tg with no arg opens a picker (connect / disconnect / status) that reflects current state. - /telegram connect starts the bridge. Refuses if bot.json has no token (tells you to run `zot telegram-bot setup`) or if the background daemon is already polling. - /telegram disconnect stops the bridge cleanly. - /telegram status one-liner: "connected as @botname, paired with user X" / "background daemon running (pid N)" / "not configured" / "disconnected". - Status bar gets a "· tg · ~/cwd" tag while the bridge is active, next to the "· jailed ·" tag if that's also on. How it's wired: internal/agent/modes/telegram/bridge.go (new) A slim Bridge type that owns the long-poll loop + typing indicator + reply sender but delegates the agent side to a Host interface. Not an agent itself - just a courier that pushes inbound DMs at a host and relays outbound text. internal/agent/modes/telegram_dialog.go (new) Picker with connect / disconnect / status rows. Shape mirrors the logout dialog: arrow keys, enter, esc. internal/agent/modes/interactive.go - New SubmitOrQueue(text, images) that runs if idle or queues if busy. Telegram Host calls this so DMs use the same queuing semantics as the user's editor submit. - New CancelTurn() for when Telegram sends /stop. - telegramHost adapter wires the Interactive to the bridge without a cyclic import (bridge lives in modes/telegram, interactive in modes; the adapter is in modes so it's fine). - EvAssistantMessage handler now also forwards the final visible text to the bridge when active (goroutine, so the network call doesn't hold the event-loop lock). - Bridge is stopped on zot exit via a defer in Run(). internal/tui/view.go StatusBarParams gains Telegram bool; the cwd line builds a composite "· jailed · tg · ~/cwd" when both tags apply. internal/agent/modes/slash_suggest.go /telegram added to the slash catalog. Collision safety: /telegram connect refuses when the background daemon (telegram.IsRunning via bot.pid) is alive. Two concurrent long-poll consumers of the same bot always race and one drops half the updates; refusing up-front beats half-working silently. Message tells the user exactly what to do. Attachments: Image attachments arriving in Telegram are downloaded and queued as user-prompt images (same code path as drag-drop). Non-image attachments are ignored for now. Pairing: First Telegram user to DM /start claims the bridge; the id is persisted to bot.json so subsequent connects are already paired. Anyone else DMing the bot gets "this bot is paired with a different user." README: /telegram row added to the slash-commands table.
2026-04-20 09:18:04 +02:00
| `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. |
| `/clear` | Clear the chat transcript. |
| `/exit` | Exit zot. |
Extension-registered commands appear under a divider at the bottom of the popup, sorted by name.
2026-04-17 20:36:38 +02:00
### `/sessions`
Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the first user prompt. Pick one with `up`/`down`, `enter` to resume, `esc` to cancel. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. Sessions remember the model they ended on, so resuming picks up on that exact model even if your global default changed.
2026-04-17 20:36:38 +02:00
### `/session`
Four ops on the current session. `/session` alone opens a picker; each is also runnable directly.
- **`/session export [path]`**. Writes the running transcript to a portable `.zotsession` file. Default destination is `~/Downloads/<timestamp>-<session-id>-<prompt-slug>.zotsession`. Pass a path to override; a directory is fine (a dated name is built inside), a bare name gets `.zotsession` appended. The meta's cwd is stripped on the way out so the recipient doesn't see your filesystem layout.
- **`/session import <path>`**. Copies a `.zotsession` file into `$ZOT_HOME/sessions/<cwd-hash>/` with a fresh id and the current cwd, then switches the running agent onto it. Imported sessions are first-class: they show up in `/sessions`, `/jump`, and the tree. Drag-drop paths in the editor are accepted (zot strips the surrounding quotes automatically).
- **`/session fork`**. Opens a turn picker (same shape as `/jump`). Pick any past user message; zot copies every message up to and including that turn into a new session, records `parent` + `fork_point` in the new meta, and switches onto the branch. The parent session stays on disk. Use it to try a different question without polluting the original transcript, or to rewind after the agent went down the wrong path.
- **`/session tree`**. Shows every session in the current cwd arranged by parent/child relationships, depth-first with indent per level. The current session is tagged `[current]`. Pick any entry to switch into it. Parentless sessions are roots; branches created via `/session fork` nest under whichever session they were forked from. Orphaned children (whose parent file was deleted) still show as roots so they stay discoverable.
tui: /jump to scroll to past turns, render cache for long transcripts /jump: - new slash command; opens a picker listing every user turn in the current session (timestamp relative, tool count badge, first line of the prompt). \u2191/\u2193 + enter scrolls the viewport to put that turn's user-message header at the top row. non-destructive, transcript untouched - runes extend a live filter; backspace shortens. '/jump <text>' pre-applies the filter; exactly-one-match auto-jumps without showing the picker - while parked on a past turn the scroll-up note reads 'viewing turn N of M \u00b7 pgdn to catch up' instead of the generic row count. scrolling back to the tail (or starting a new turn, or /clear) resets the parked state automatically - view.go: new MessageAnchor type + BuildWithAnchors so the dialog can resolve msgIdx -> first rendered row perf for long transcripts (the whole ui stutters on ~50 messages): - view.renderCache: per-message memoisation keyed by (fnv1a of role+content, width, expandAll). finalised messages never change so the cache hit rate is ~100% after the first render. streaming partials and in-flight tool-call views stay uncached by design - BuildWithAnchors now pre-sums line counts and allocates in a single make() instead of 50 appends with log2(N) backing- array memcpys - truncateToWidth fast path: byte-length <= cols implies cell-width <= cols, so we skip the rune-width loop entirely. covers the huge majority of lines in a session - cache purged on /clear, /compact completion, and session swap (applySessionSelection); resize invalidates implicitly via the width key. LRU eviction at 4x message count caps memory impact: a 50-msg / 2000-line transcript went from unresponsive- while-typing to drawing in well under a frame. measured locally with go-perf traces; no change to correctness.
2026-04-18 12:22:16 +02:00
### `/jump`
Opens a turn picker for the current session, one row per user prompt, each showing the turn number, how many tools that turn invoked, and the first line of the prompt. `up`/`down` to pick, `enter` to jump, `esc` to cancel. Any printable rune while the picker is open extends a filter; backspace narrows it back. `/jump <text>` pre-applies the filter; if exactly one turn matches, zot jumps straight there without showing the picker.
tui: /jump to scroll to past turns, render cache for long transcripts /jump: - new slash command; opens a picker listing every user turn in the current session (timestamp relative, tool count badge, first line of the prompt). \u2191/\u2193 + enter scrolls the viewport to put that turn's user-message header at the top row. non-destructive, transcript untouched - runes extend a live filter; backspace shortens. '/jump <text>' pre-applies the filter; exactly-one-match auto-jumps without showing the picker - while parked on a past turn the scroll-up note reads 'viewing turn N of M \u00b7 pgdn to catch up' instead of the generic row count. scrolling back to the tail (or starting a new turn, or /clear) resets the parked state automatically - view.go: new MessageAnchor type + BuildWithAnchors so the dialog can resolve msgIdx -> first rendered row perf for long transcripts (the whole ui stutters on ~50 messages): - view.renderCache: per-message memoisation keyed by (fnv1a of role+content, width, expandAll). finalised messages never change so the cache hit rate is ~100% after the first render. streaming partials and in-flight tool-call views stay uncached by design - BuildWithAnchors now pre-sums line counts and allocates in a single make() instead of 50 appends with log2(N) backing- array memcpys - truncateToWidth fast path: byte-length <= cols implies cell-width <= cols, so we skip the rune-width loop entirely. covers the huge majority of lines in a session - cache purged on /clear, /compact completion, and session swap (applySessionSelection); resize invalidates implicitly via the width key. LRU eviction at 4x message count caps memory impact: a 50-msg / 2000-line transcript went from unresponsive- while-typing to drawing in well under a frame. measured locally with go-perf traces; no change to correctness.
2026-04-18 12:22:16 +02:00
Jumping is non-destructive. The transcript is untouched, the viewport just scrolls so the chosen turn is at the top. A muted line at the top of the chat reads `viewing turn N of M, pgdn to catch up`. Scroll back to the bottom with `pgdn` (or keep scrolling with the arrow keys) and the indicator goes away.
tui: /jump to scroll to past turns, render cache for long transcripts /jump: - new slash command; opens a picker listing every user turn in the current session (timestamp relative, tool count badge, first line of the prompt). \u2191/\u2193 + enter scrolls the viewport to put that turn's user-message header at the top row. non-destructive, transcript untouched - runes extend a live filter; backspace shortens. '/jump <text>' pre-applies the filter; exactly-one-match auto-jumps without showing the picker - while parked on a past turn the scroll-up note reads 'viewing turn N of M \u00b7 pgdn to catch up' instead of the generic row count. scrolling back to the tail (or starting a new turn, or /clear) resets the parked state automatically - view.go: new MessageAnchor type + BuildWithAnchors so the dialog can resolve msgIdx -> first rendered row perf for long transcripts (the whole ui stutters on ~50 messages): - view.renderCache: per-message memoisation keyed by (fnv1a of role+content, width, expandAll). finalised messages never change so the cache hit rate is ~100% after the first render. streaming partials and in-flight tool-call views stay uncached by design - BuildWithAnchors now pre-sums line counts and allocates in a single make() instead of 50 appends with log2(N) backing- array memcpys - truncateToWidth fast path: byte-length <= cols implies cell-width <= cols, so we skip the rune-width loop entirely. covers the huge majority of lines in a session - cache purged on /clear, /compact completion, and session swap (applySessionSelection); resize invalidates implicitly via the width key. LRU eviction at 4x message count caps memory impact: a 50-msg / 2000-line transcript went from unresponsive- while-typing to drawing in well under a frame. measured locally with go-perf traces; no change to correctness.
2026-04-18 12:22:16 +02:00
### `/btw`
Opens a side-chat overlay with the full main session as frozen context, so you can ask quick clarifying questions ("does asyncio.gather() catch exceptions?", "btw the bundle budget is 10MB", "what's the default fetch timeout?") without bloating the main thread.
Each question fires a one-off model call against `system + main transcript + side-chat history so far`. Responses render in the overlay and stay there. When you press `esc` to close, **nothing** has been added to the main session and subsequent main-thread turns don't re-read any of the side-chat exchanges, keeping the running context window lean.
```
/btw # open the overlay, type questions interactively
/btw does PUT replace the whole resource?
```
Inside the overlay: `enter` sends, `esc` cancels an in-flight call (or closes the overlay if idle), `ctrl+c` closes immediately. Side-chat exchanges never touch the transcript and aren't persisted to the session file.
### `/skills`
Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each row shows the skill name, source, and description. `enter` opens the body inline (scrollable with `up`/`down`/`pgup`/`pgdn`); `esc` goes back. Re-runs discovery each time it opens, so edits to a SKILL.md during a session are reflected immediately.
2026-04-17 20:36:38 +02:00
### `/compact`
Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%.
2026-04-17 20:36:38 +02:00
zot also auto-compacts in the background: after any turn that leaves context usage at or above **85%** of the model's window, the agent kicks off a condense pass on its own. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript.
2026-04-18 10:35:54 +02:00
### `/jail`
2026-04-17 20:36:38 +02:00
Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, and `edit` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `bash` refuses obvious escape patterns: `sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar. The status bar shows `jailed, ~/your/cwd` while active.
2026-04-17 20:36:38 +02:00
This is a guardrail against accidents, not a hard security boundary. If you need real isolation, run zot under docker or a proper sandbox.
2026-04-17 20:36:38 +02:00
## Sessions
2026-04-17 20:36:38 +02:00
Every interactive or print/json run (unless `--no-session`) writes a JSONL transcript under `$ZOT_HOME/sessions/<cwd-hash>/`. Resume any of them with `--continue`, `--resume`, `--session <path>`, or interactively via `/sessions` inside the TUI. Empty sessions (the user exited without prompting) are deleted on close so the list stays tidy.
2026-04-17 20:36:38 +02:00
## Models
2026-04-17 20:36:38 +02:00
`--list-models` or the `/model` picker shows the full catalog. Three sources:
2026-04-17 20:36:38 +02:00
- **Catalog**: models baked into zot, always available.
- **Live**: IDs discovered from `GET /v1/models` using your stored API key (cached for 6h in `$ZOT_HOME/models-cache.json`, refreshed in the background on startup).
- **Speculative**: IDs that appear in the upstream generator but aren't live on the public API yet. They'll 404 today and start working the moment the provider ships them.
2026-04-17 20:36:38 +02:00
The context meter in the status line uses the model's advertised context window to show how much of it your last turn consumed.
2026-04-17 20:36:38 +02:00
### Custom models
Place a `models.json` in `$ZOT_HOME` (macOS: `~/Library/Application Support/zot/`, Linux: `~/.local/state/zot/`) to add models that aren't in the baked-in catalog or to override existing entries:
```json
{
"providers": {
"openai": {
"models": [
{
"id": "gpt-5.5",
"name": "GPT-5.5",
"reasoning": true,
"contextWindow": 400000,
"maxTokens": 128000
}
]
}
}
}
```
Supported fields per model: `id` (required), `name`, `reasoning`, `contextWindow`, `maxTokens`, `baseUrl`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`.
Provider keys are normalized: `openai-codex` and `openai-responses` map to `openai`, `anthropic-messages` maps to `anthropic`.
User-defined models show `source: user` in `--list-models` and take precedence over both the baked-in catalog and live-discovered models. Missing or invalid files are silently ignored.
### Local models with ollama
zot works with [ollama](https://ollama.com) out of the box. Ollama serves an OpenAI-compatible API locally, so any model you have pulled works with zot.
Quick start:
```bash
ollama pull qwen3.5:4b
zot --provider ollama --model qwen3.5:4b
```
That's it. No API key needed for local models. zot defaults to `http://localhost:11434`.
For a remote ollama instance or one behind auth:
```bash
zot --provider ollama --model llama3 --base-url https://my-server.com/v1 --api-key my-token
```
You can also add models to your `models.json` so you don't need flags every time:
```json
{
"providers": {
"ollama": {
"models": [
{
"id": "qwen3.5:4b",
"name": "Qwen 3.5 4B",
"contextWindow": 32768,
"maxTokens": 8192
}
]
}
}
}
```
The `ollama` provider uses the OpenAI chat completions protocol internally, so it also works with any OpenAI-compatible server (vLLM, LM Studio, LocalAI, etc.).
## Inline images
2026-04-17 20:36:38 +02:00
When a tool returns an image (for example `read` on a PNG), zot renders it inline on terminals that support it: **Ghostty**, **Kitty**, **iTerm2**, **WezTerm**. On other terminals you see a text placeholder with MIME type, pixel dimensions, and byte size. Control with the `ZOT_INLINE_IMAGES` env var:
2026-04-17 20:36:38 +02:00
| Value | Effect |
2026-04-17 20:36:38 +02:00
|---|---|
| unset (default) | Auto-detect based on `TERM_PROGRAM`. |
| `iterm`, `iterm2` | Force the iTerm2 OSC 1337 protocol. |
| `kitty` | Force the Kitty graphics protocol. |
| `off`, `none` | Always use the text placeholder. |
2026-04-17 20:36:38 +02:00
Frames containing images are full-repainted (no differential diff) to prevent stale image pixels from lingering through scroll. That costs one terminal flash per image-containing frame; set `ZOT_INLINE_IMAGES=off` if that bothers you.
2026-04-17 20:36:38 +02:00
## Queued messages
2026-04-18 10:35:54 +02:00
You can keep typing while the agent is working. Pressing `enter` during a turn queues the message instead of interrupting: it shows up above the status bar as `sliding in: <text>` and is delivered as the next user turn the moment the current one finishes. Queue as many as you want; they run in order. `esc` cancels the active turn and drops the queue so a runaway turn doesn't flood you with stale follow-ups; `ctrl+c` while busy arms the exit hint instead of interrupting, a second `ctrl+c` within two seconds exits zot.
2026-04-18 10:35:54 +02:00
Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run.
2026-04-18 10:35:54 +02:00
## Keys (interactive mode)
2026-04-17 20:36:38 +02:00
### Input
2026-04-17 20:36:38 +02:00
| Key | Action |
2026-04-17 20:36:38 +02:00
|---|---|
| `enter` | Submit (queued if the agent is busy). |
| `alt+enter` | Newline. |
| `tab` | Complete the selected slash command. |
| `esc` | Cancel the current turn (while busy); clear input (while idle). |
| `ctrl+c` | Clear the input and queue (while idle) or arm the exit hint (while busy). Press again within 2s to exit. Use `esc` to cancel a running turn. |
| `ctrl+d` | Exit on empty input. |
| `ctrl+l` | Redraw the screen. |
| `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). |
| `@` | Open the file picker. Browse files and directories in the working directory. |
### File picker (`@`)
| Key | Action |
|---|---|
| `@` | Open the file picker (type after a space or at the start of input). |
| `up`, `down` | Navigate the file list. |
| `right` | Open the selected directory. |
| `left` | Go back to the parent directory. |
| `enter` | Select the file or directory and insert it as a chip (`[file:name]` or `[dir:name/]`). |
| `esc` | Close the file picker. |
Type `@` followed by a filter string to narrow the list (e.g. `@read` shows only entries containing "read"). Selected files are inserted as compact chips that expand to the full path on submit. Dragged-and-dropped files and directories also collapse to chips automatically.
### Editor line navigation
| Key | Action |
2026-04-17 20:36:38 +02:00
|---|---|
| `ctrl+a`, `ctrl+e` | Jump to start or end of line. |
| `alt+left`, `alt+right` | Jump one word back or forward. |
| `ctrl+u`, `ctrl+k` | Delete to start or end of line. |
| `ctrl+w`, `alt+backspace` | Delete the previous word. |
| `up`, `down` (editor non-empty) | Cycle through prompt history. |
2026-04-17 20:36:38 +02:00
### Chat scroll
2026-04-17 20:36:38 +02:00
| Key | Action |
2026-04-17 20:36:38 +02:00
|---|---|
| `pgup`, `pgdn` | Scroll one page up or down. |
| `up`, `down` (editor empty) | Scroll three lines up or down. This is how the mouse wheel reaches the scroll logic on most terminals. |
## Extensions
feat(ext): phase 4 - full-event interception, arg rewrites, /reload-ext Clears every deferred extension todo in one push: 1) Interception expands to three events: tool_call (already shipped), turn_start (gate the turn before the model call, e.g. rate-limit / business-hour), and assistant_message (suppress or rewrite the user-visible text while keeping the model's original output in the transcript). 2) Tool-call args can now be rewritten mid-flight. An interceptor returning modified_args replaces the JSON the tool actually receives, without the model seeing the rewrite. Chains: each subscriber sees the previous one's output, letting guards successively redact / patch / augment. Invalid JSON is dropped safely. 3) /reload-ext hot-reloads every extension without restarting zot. The manager gracefully shuts down all running subprocesses, re-reads extension.json from disk, respawns (including --ext paths remembered from startup), and the host rebuilds the agent's tool registry in-place so freshly-registered tools are callable immediately. Wire-format changes (extproto): - EventInterceptResponseFromExt gains modified_args and replace_text fields (both optional, ignored when block=true). - EventInterceptFromHost gains Step (for turn_start) and Text (for assistant_message) alongside the existing tool_call payload. Core agent changes: - BeforeToolExecute signature now returns (allowed, reason, modifiedArgs json.RawMessage). Non-nil+valid JSON args replace tc.Arguments before Tool.Execute runs. - New BeforeTurn hook, invoked in runLoop before oneTurn. Blocking cancels the turn with an EvTurnEnd{StopError} carrying the reason. - New BeforeAssistantMessage hook, invoked after finalMsg is assembled but before the EvAssistantMessage emit. Supports suppress (block=true) and text rewrite (replace_text). Transcript always gets the original; UI gets the rewritten text. - New SetTools(reg) so /reload-ext can swap the registry on the live agent under the agent mutex. Manager changes: - InterceptToolCall now returns InterceptResult (Block, Reason, ModifiedArgs, ReplaceText), with a chain that folds rewrites. - New InterceptTurnStart and InterceptAssistantMessage. - New Reload(ctx, grace) tears down and respawns everything, returning ReloadStats{Stopped, Loaded, Ready, Errors}. - New SetOnReload(fn) callback the host uses to rebuild the agent tool registry after a reload. - LoadExplicit remembers --ext paths so Reload respawns them. - subscribe accepts "tool_call", "turn_start", "assistant_message" under "intercept". SDK (pkg/zotext): - New handler types: ToolCallHandler, TurnStartHandler, AssistantMessageHandler, and their decision structs (ToolCallDecision with ModifiedArgs, AssistantMessageDecision with ReplaceText). - New registration methods: InterceptToolCallX (rich variant of the existing InterceptToolCall), InterceptTurnStart, InterceptAssistantMessage. - dispatchIntercept routes per-event with panic recovery and always emits exactly one event_intercept_response. TUI: - /reload-ext slash command registered in slashCatalog and runSlash. Added to slashCancelsTurn so it waits for idle like /compact does. - runReloadExt shows a "reloading extensions..." status, runs the Manager.Reload on a goroutine, and reports the resulting stats. Tests: - internal/core/intercept_test.go: verifies args are actually rewritten on the way to Tool.Execute, malformed JSON is ignored, and block surfaces the reason as an error ToolResult. - internal/agent/extensions/intercept_test.go: end-to-end with a bash extension subprocess that blocks rm -rf, rewrites other bash args to "echo GUARDED:", passes through read calls, allows turn_start, and redacts SECRET in assistant messages. Second test verifies Reload respawns the subprocess, re-registers its command, and fires the onReload callback. Docs: - docs/extensions.md: rewrote the intercept section to cover all three events, added a table of event_intercept_response fields, documented the /reload-ext hot-reload command, expanded the SDK section with examples of every handler, moved the old "future" items into a shipped Phase 4. - README.md: extensions summary mentions intercept beyond tool_call, /reload-ext added to the slash-commands table and to the turn-cancel list in "Queued messages".
2026-04-19 17:02:04 +02:00
zot can be extended in any language via a subprocess + JSON-RPC protocol. Extensions can register slash commands, expose tools to the model, intercept tool calls (block or rewrite args), gate whole turns before the model is called, and rewrite the assistant's visible text before it reaches the user. None are installed by default; opt in explicitly. Hot-reload any time with `/reload-ext`.
### Install and manage
```bash
zot ext install <path|git-url> # copy / clone into $ZOT_HOME/extensions/
zot ext list # show installed extensions
zot ext logs <name> [-f] # cat or tail the extension's stderr log
zot ext enable <name> # re-enable a disabled extension
zot ext disable <name> # disable without removing
zot ext remove <name> # delete an extension directory
```
For development, point `zot --ext <path>` at a working directory and skip the install step entirely. Repeatable; takes precedence over installed extensions of the same name.
### Reference
`examples/extensions/` ships reference implementations in Go, TypeScript, Node, and shell. See [docs/extensions.md](docs/extensions.md) for the full protocol, the SDK API (`pkg/zotext`), and the phase roadmap.
## Skills
A skill is a per-folder `SKILL.md` file with a YAML frontmatter header. zot discovers skills at startup, surfaces their names in the system prompt, and exposes a built-in `skill` tool the model uses to load the body on demand.
By default only the built-in skills shipped with the zot binary are loaded. Pass `--with-skills` to also load user-installed skills from:
- `./.zot/skills/<name>/SKILL.md` (project)
- `$ZOT_HOME/skills/<name>/SKILL.md` (global)
- `./.claude/skills/<name>/SKILL.md`, `~/.claude/skills/<name>/SKILL.md` (Claude-compatible layout)
- `./.agents/skills/<name>/SKILL.md`, `~/.agents/skills/<name>/SKILL.md` (agent-compatible layout)
See [docs/skills.md](docs/skills.md) for the frontmatter fields, authoring tips, and example skills under `examples/skills/`.
2026-04-17 20:36:38 +02:00
## Telegram bot (bridge)
2026-04-18 09:15:46 +02:00
zot can run as a telegram bot so you can DM it from your phone. Two ways to run it: **from inside the TUI** (the running session mirrors into Telegram) or **as a standalone background daemon** (a headless bot with its own independent agent).
### From inside the TUI
Type `/telegram` in the running TUI to open a picker with **connect**, **disconnect**, and **status**. When connected:
- DMs from the paired user become prompts in the **same** session you're typing in, so you can continue a conversation from the terminal on your phone and back again.
- Messages you type in the TUI are mirrored into the Telegram thread prefixed `you: …` and the assistant's replies come back prefixed `zot: …`, so the Telegram chat stays a complete record of both sides of the conversation.
- Messages sent from Telegram show up as your own bubble in Telegram (no mirror) and the assistant's reply to them comes back bare (no prefix).
- The status bar shows a `- tg -` tag while the bridge is active.
- `/telegram connect` / `/telegram disconnect` / `/telegram status` (or `/tg`) also work as direct commands without the picker.
The in-TUI bridge refuses to start while the standalone daemon (below) is running, since two concurrent long-poll consumers of the same bot race on every update and silently drop messages.
### Standalone daemon
For headless servers or long-running bots unattached to a TUI:
2026-04-18 09:15:46 +02:00
```bash
zot telegram-bot setup # paste a BotFather token, verify, save
zot telegram-bot run # foreground: long-poll in this terminal (ctrl+c to stop)
zot telegram-bot start # background: detach and return immediately
zot telegram-bot stop # SIGTERM the background bot (SIGKILL after 5s)
2026-04-18 09:15:46 +02:00
zot telegram-bot logs -f # tail $ZOT_HOME/logs/bot.log (omit -f to just cat)
zot telegram-bot status # config (token masked) + running/stopped
zot telegram-bot reset # forget the token and paired user
2026-04-18 09:15:46 +02:00
# short alias: `zot tg ...` is accepted for every subcommand
```
The background flavor writes the child's PID to `$ZOT_HOME/bot.pid` and redirects stdout and stderr to `$ZOT_HOME/logs/bot.log`. `zot telegram-bot stop` reads that PID, sends SIGTERM, waits up to five seconds, then escalates to SIGKILL if the child is still alive. Running two instances at once is refused at startup.
2026-04-18 09:15:46 +02:00
> **Use the installed binary for `start`.** `go run ./cmd/zot telegram-bot start` won't work. `go run` builds a binary in a temp directory and deletes it when it exits, which kills the detached child. Run `make install` (or `go build`) first and invoke the installed binary.
2026-04-18 09:15:46 +02:00
Setup flow:
2026-04-18 09:15:46 +02:00
1. Talk to [@BotFather](https://t.me/BotFather) on telegram, run `/newbot`, copy the token it gives you.
2. Run `zot telegram-bot setup` and paste the token when prompted.
3. Run `zot telegram-bot run` in the directory you want the agent to operate in.
4. Open your bot on telegram, send `/start`. The first user to do this claims the bridge (stored as `allowed_user_id`); every other user is rejected.
2026-04-18 09:15:46 +02:00
From then on, any DM you send is forwarded to the agent as a user prompt. Attached photos or `image/*` documents are downloaded and passed to vision-capable models. In-bot telegram commands: `/help`, `/status`, `/stop` (cancel the current turn). Config lives in `$ZOT_HOME/bot.json` (mode 0600).
2026-04-18 09:15:46 +02:00
Bot mode respects the usual zot flags: `--provider`, `--model`, `--cwd`, `--reasoning`, `--continue`, `--no-session`, `--no-tools`, and so on. Run `zot tg run -c --model claude-opus-4-1` to resume the latest session on Opus, for example.
2026-04-18 09:15:46 +02:00
## Development
2026-04-17 20:36:38 +02:00
```bash
make build # build ./bin/zot
make test # go test -race ./...
make lint # go vet + gofmt check
make fmt # gofmt -w .
make release # cross-compile linux/darwin/windows on amd64 and arm64
2026-04-17 20:36:38 +02:00
```
Source layout:
2026-04-17 20:36:38 +02:00
```
cmd/zot/ main()
internal/agent/ cli wiring, arg parsing, system prompt, config
internal/agent/extensions/ extension subprocess manager
2026-04-17 20:36:38 +02:00
internal/agent/modes/ interactive tui, print, json, dialogs
internal/agent/tools/ read, write, edit, bash, sandbox
internal/auth/ credential store, api-key probe, oauth, login server
internal/core/ agent loop, sessions, cost tracking
internal/extproto/ extension wire-format types
2026-04-17 20:36:38 +02:00
internal/provider/ anthropic + openai streaming clients, model catalog
internal/skills/ skill discovery, frontmatter parser, skill tool
2026-04-17 20:36:38 +02:00
internal/tui/ terminal raw-mode, input parser, editor, renderer, markdown, view
pkg/zotcore/ public Go SDK for embedding zot in-process
pkg/zotext/ public Go SDK for writing extensions
2026-04-17 20:36:38 +02:00
```
## License
2026-04-17 20:36:38 +02:00
MIT