Ovvi
Agent control

CLI

The ovvi command: every subcommand with examples, its JSON output and errors, and the script itself

ovvi is a POSIX sh script of about 70 lines (Agent control). It carries its arguments to Ovvi and prints the answer (Protocol). Ovvi parses every command except status, which the script answers from herdr alone.

Where it is

  • ~/.cache/ovvi/bin/ovvi on each machine where Ovvi opened a session, This Mac included. Ovvi writes it when it opens a session on the machine and the file there is missing or older (How the command gets there).
  • It is not on PATH. Agents use the full path (the ovvi skill names it: The ovvi skill). A user who wants the short name adds export PATH="$HOME/.cache/ovvi/bin:$PATH" to their shell.
  • No file means that no Ovvi ever opened a session on this machine.
  • It needs sh, sed, date, mv, rm, cat, mkdir, head, tail, tr, a sleep that takes fractions of a second, uuidgen or /proc/sys/kernel/random/uuid, and herdr at $HERDR_BIN_PATH (herdr sets it in each pane) or on PATH.

Usage

ovvi [--pane <pane>] [--timeout <seconds>] <command> [<args>] [--json]
  • --pane rings another pane of the session than $HERDR_PANE_ID. Use it for a caller outside a pane, or after the pane moved to another space: herdr then gives it a new id, and the environment keeps the old one.
  • --timeout is how long the command waits for Ovvi's answer once Ovvi took the request, in whole seconds. The default is 10. Give plugins new, reload and install a longer one (they install and build), and chat 40 s.
  • --pane and --timeout go before the command. After the command, an option belongs to that command (notify --pane).
  • --json prints one JSON value on standard output, also for an error. It counts anywhere in the arguments. The keys are sorted.
  • Without --json, a command prints its text. A command with no text of its own prints its JSON fields as name: value lines, sorted by name.
  • An error goes to standard error as ovvi: ‹message› (Error codes). With --json, an error also prints {"error":{"code":"…","message":"…"}} on standard output, the script's own errors too (usage, no_ovvi, timeout and no_herdr).
  • A <pane> is a pane id of the asking session (w1:p3) or ‹session›/‹pane id› of another session on the same machine (pi-herd/w1:p1). It is always on the asking pane's machine. A session that herdr does not run there, or a pane that is not in it, is pane_not_found. Text that is not a pane is bad_argument.
  • Exit codes: 0 done, 1 failed, 2 usage, 3 no Ovvi, 4 no answer, 5 not in herdr (Exit codes).

status

Whether Ovvi shows the asking pane, from herdr's tokens alone. It never asks Ovvi, so it answers fast, also when no Ovvi runs. It exits 0 when an Ovvi follows the session, 3 when none does, and 5 when herdr has no such pane (Is my pane in Ovvi?).

$ ovvi status
Shown in Ovvi
$ ovvi status --json
{"session":"default","pane":"w1:p2","open":true,"shown":true,"remote":false,"protocol":1}
$ ovvi status --watch         # the JSON line again each time it changes; reads every 2 s

The text is "Shown in Ovvi", "Open in Ovvi, not shown" or "Not in Ovvi".

help

ovvi help                     # every command with its synopsis, status too
ovvi help open                # the synopsis and the details of one

Ovvi writes the help from its commands, so it lists what that Ovvi has. ovvi with no command is ovvi help. With --json, help answers {"commands":[{"name","usage","summary"}…]}, and help <command> answers its name, usage, summary and help.

info

What the Ovvi that takes the request offers.

$ ovvi info --json
{"agents":["claude","codex","pi","opencode"],"app":"0.1","appearance":"dark",
 "commands":["help","info","float","unfloat","copy","plugins","open","reveal","card","shown","notify","agent","chat","view","balance"],
 "machine":"This Mac","protocol":1}
FieldMeaning
appOvvi's version
protocolthe protocol version it speaks
machineOvvi's name for the asking pane's machine: This Mac, or the name in the machine list
appearancelight or dark, as Ovvi draws now (for an HTML report that matches)
commandsthe commands this Ovvi parses (status is the script's)
agentsthe agents ovvi agent starts with the chat view, by command

plugins

The kinds an agent can open: the browser and every plugin, from the app's plugin registry (Where the registry looks, The manifest). Terminal and agent panes are herdr's and not in the list.

ovvi plugins [--for <path or URL>]
ovvi plugins new <name>
ovvi plugins reload [<id>]
ovvi plugins install <source>
$ ovvi plugins
browser    Browser        urls                   app
explorer   File Explorer  files,folders          app
git        Git            git                    app
csv-table  CSV Table      files                  ~/.config/ovvi/plugins/csv-table
broken     not loaded                            ~/.config/ovvi/plugins/broken   package.json: ovvi.entry is missing
$ ovvi plugins --for data.csv       # the plugins that open this file, folder or URL, the default first
csv-table  CSV Table      files                  ~/.config/ovvi/plugins/csv-table   default
explorer   File Explorer  files,folders          app
csv-chart  CSV Chart      files                  ~/.config/ovvi/plugins/csv-chart
[
  { "id": "csv-table", "name": "CSV Table", "icon": "tablecells",
    "description": "A CSV or TSV file as a table you can sort.", "version": "0.1.0",
    "opens": { "files": ["*.csv", "*.tsv"] }, "chooser": null,
    "source": "~/.config/ovvi/plugins/csv-table",
    "log": "/Users/me/Library/Logs/Ovvi/plugins/csv-table.log" },
  { "id": "broken", "source": "~/.config/ovvi/plugins/broken",
    "error": "package.json: ovvi.entry is missing" }
]
  • id, name, icon, opens and chooser are the manifest's fields, as written: opens has only the keys the manifest gives (files, folders, git, urls, priority). description and version are the package's.
  • source is the Plugins page's row text: app for the browser and the default plugins (the row says "Comes with Ovvi"), the npm: or git: source, the local path, or the folder (The Plugins page).
  • log is the full path of the plugin's log (Errors and logs). It shows only for a pane on This Mac, where the file is, and not for the browser.
  • A package that did not load shows only id, source (its folder) and error. In the text, its row says not loaded.
  • With --for, the list holds only the plugins that open the argument, in the order of Which plugin opens a file, each with "default": true or false. For a file, the plugins that open it only as a choice in Open With ("priority": "option") come after them: ovvi open <id> <file> opens the file with any of them. A relative path is read from the command's folder, and a folder is one on the disk of the pane's machine (a missing path counts by its name, or as a folder when it ends in /). open picks its default plugin by the same order.

plugins new

$ ovvi --timeout 120 plugins new "CSV Table"
folder: /Users/me/.config/ovvi/plugins/csv-table
id: csv-table
log: /Users/me/Library/Logs/Ovvi/plugins/csv-table.log

New Plugin… with the name as an argument: Ovvi copies the template into the plugins folder, installs its dependencies, builds it and confirms it, as the Plugins page does (Start from the template). It answers id, folder and log. A name with no letter or digit, or a name that is taken, is failed with the page's words. The Plugins page shows Creating… meanwhile.

plugins reload

$ ovvi plugins reload csv-table --json
{"built":true,"cards":1,"id":"csv-table","ms":840}
$ ovvi plugins reload                 # every plugin with an open card
csv-table built in 840 ms, 1 card(s)

Reload Plugin for one id, or Reload Plugins with none (Reload). Ovvi reads the registry again first, so a folder made by hand loads.

  • One id answers id, built, ms (the time of the build and the reload) and cards (the plugin's cards in every window: a pane shown in two windows counts twice). No id answers {"plugins":[…]}, one entry per plugin.
  • built is false for a plugin that the user did not confirm yet: it builds nothing, and its cards still ask the user.
  • A card with unsaved edits stops it with dirty, which names the pane. Ovvi never discards edits for an agent. It looks before anything builds and again before each plugin, and the message names the plugins reloaded before it. A card that gets edits during its plugin's build keeps its page while the plugin's other cards load the new build, and the answer is dirty too.
  • A failed build is build_failed. Its message is the card's toast title and the build's last 20 lines.
  • An unknown id is plugin_not_found, with the reason when its manifest did not load. The browser is bad_argument: it has nothing to build. A plugin that the user did not confirm yet is failed: its card asks the user first.

plugins install

$ ovvi --timeout 300 plugins install npm:ovvi-csv-table
csv-table  CSV Table  files  npm:ovvi-csv-table

Install from the Plugins page, for an agent (Install). The source is npm:<package>, git:<repository> or a folder (a relative one from the command's folder).

  • Ovvi shows the confirmation on the window of the asking session, with "‹agent› in pane ‹pane› asks to install it." first (The confirmation). Ovvi does not come to the front: it asks for attention in the Dock. Return does not press Install, so keys typed elsewhere cannot install it.
  • It answers after the install and the build, with the plugin's entry as in the list. Cancel is declined. With no window of the session left to show the confirmation, the install is failed.
  • A source that is not one, or one already listed, is bad_argument. Update stays on the Plugins page, where the user chooses it.
  • If the command's --timeout ends first, the confirmation stays, and the install goes on after the user's answer.
  • The Plugins page shows Installing… and any failure, as for its own install.

new, reload and install from a pane on a remote machine are other_machine: the plugins are on the Mac.

open

Makes a browser or plugin card: a herdr pane with the label ovvi:<kind>#<iid> <arg> and its placeholder, as the app's own commands do (Labels and instance ids).

ovvi open <kind> [<arg>...] [<place>] [--new]
ovvi open <path-or-url> [<place>] [--new]

<place>:  --beside <pane> [--down]     a split of that pane, right (or below). The default: beside the asking pane
          --down                       below the asking pane
          --tab                        a new tab right after the asking pane's tab, not selected
          --float                      as --tab, then floats it
          --replace <pane>             that browser or plugin card shows the new kind and argument
ovvi open git . uncommitted                       # the Uncommitted diff of this repository, beside me
ovvi open git . 4f2a91c --tab                     # a commit, in a new tab
ovvi open browser http://localhost:3000 --down    # a browser card below me
ovvi open ./docs/report.md                        # the default plugin: File Explorer, its Markdown page
ovvi open https://github.com/o/r/pull/7           # a plugin for that URL, else the browser
ovvi open browser ./report.html                   # a file in the browser, This Mac only
ovvi open browser http://localhost:3000/settings --replace w1:p7    # navigate, Back returns
ovvi open explorer . --float                      # a file tree in a floating window
{"arg":"/repo/ uncommitted","kind":"git","label":"ovvi:git#k3x9q2 /repo/ uncommitted",
 "pane":"w1:p7","reused":false,"shown":true}
  • Kind or path. A first word that is a kind's id (from ovvi plugins) is the kind. A path with the same name as a kind is written ./name. A word that is not a kind, with more words after it, is plugin_not_found.
  • A path or URL alone opens with its default plugin, by the order of ovvi plugins --for. An http or https URL opens with a plugin whose urls match it, else in the browser. A file:// URL opens in the browser. A path must exist on the asking machine. A missing one is bad_argument. Typed in a shell (a pane with no agent), it also makes the windows that show the session say "File not found: ‹path›" (Opening links and files). A file that no plugin opens is bad_argument too.
  • Paths are read from the command's folder, and the card gets the absolute path.
  • The argument follows the kind's opens: a URL, a file's path or a folder. Git takes a folder of a repository (the card gets its root) and then uncommitted or a commit: its hash, short or whole, or a ref (main, HEAD~2). The card gets the commit's whole hash; one that names no commit is bad_argument. File Explorer takes a folder as Open File Explorer Here does: the git root when there is one. browser ./page.html is a file:// URL, and a path's ?query and #fragment stay the URL's (browser "./page.html?v=3"). A file whose name has ? or # opens as a file:// URL with them encoded (%3F, %23). A file:// URL, for the browser or any plugin, opens only for a pane on This Mac. A missing or wrong argument, or one that makes no label (a control character, over 1024 characters), is bad_argument.
  • Reuse. Without --new, a card of the same kind and argument already in the session where the card would open is the answer ("reused": true), and nothing new opens. When it is not in view, Ovvi shows the notice "‹agent› points to ‹title›" with Show. A reused card stays where it is, also with --float: ovvi float floats it.
  • The view. A split of the asking pane shows where the agent is. A new tab is not selected. A new card out of view gets the notice "‹agent› opened ‹Kind› ‹title›" with Show, in each window that shows the session (Notice). A float opens its window without activating Ovvi.
  • One place at most. Two places are usage.
  • Replace takes only a browser or plugin card. Ovvi renames its label with the card's own iid, so the card follows it as a label from elsewhere: a browser card as a new history entry, a plugin card with Back. A new kind makes the card again. A terminal or agent pane is bad_argument, so an agent never ends a shell through Ovvi. A card with unsaved edits is dirty. --replace with --new is usage.
  • A failed float of --float closes the new pane.
  • shown says whether the card is in view in a window of this Ovvi. Ovvi answers once a snapshot has the new pane in its place.

‹agent› is the asking pane's agent name ("Claude Code"), else its program ("zsh"), else its card's title. ‹title› is the card's title as its window shows it (a browser card's page title).

float and unfloat

ovvi float w1:p3               # over the window where the user last worked in this session
ovvi float pi-herd/w1:p1 --on-top
ovvi unfloat w1:p3             # Detach: back into its tab
{"existing":false,"floating":true,"onTop":false,"pane":"w1:p3"}
  • The float goes over the window of this Ovvi where the user last typed or clicked in a card of the asking session (Floating pane). A pane of a session with no page gets a float-only page. A session that no window shows is failed. A float that fails says so only in the answer: the window shows no warning.
  • The window comes to the front without activating Ovvi, and the keyboard stays in the window that had it.
  • A pane that floats already comes to the front ("existing": true). --on-top turns on Stay on Top.
  • unfloat answers {"floating":false,"pane":"w1:p3"}. Of a pane that does not float, it is not_floating.

reveal

Points the user at a pane, without moving their view.

$ ovvi reveal w1:p3 --json
{"pane":"w1:p3","shown":false}
  • In view: the card's border flashes in the accent color once (0.6 s). A floating card flashes in its own window.
  • Not in view: a notice in the information tone, "‹agent› points to ‹title›", with Show, in each window that shows the session. Show selects the pane's space, tab and card in that window.
  • A pane of a session that Ovvi does not show is failed.

card

What a card shows.

$ ovvi card w1:p3 --json
{"arg":"/repo/Sources/main.swift","dirty":true,"floating":false,"kind":"explorer",
 "pane":"w1:p3","shown":true,"title":"main.swift"}
  • kind is a browser or plugin id, terminal or agent. An agent card adds "agent" (for example "claude"). An agent that has the chat view also has "view": "chat" or "tui", the card's Chat | TUI switch.
  • arg is the label's argument. For a terminal or an agent, it is the folder.
  • title is the card's title (How it shows), dirty is the unsaved-edits dot. A plugin's own state is not in the answer.
  • error comes only when there is one: why a plugin is missing or did not load, or a plugin page's failure.

shown

The panes of the asking session that this Ovvi shows now.

$ ovvi shown --json
{"floating":["w3:p1"],"shown":["w1:p2","w1:p3"],"windows":1}

shown lists the panes in a tab in view, floating the panes in windows of their own, and windows counts the visible windows with the session's page in view.

notify

A notification on the Mac, through the app's notifications (Notifications).

$ ovvi notify "Tests pass" "412 passed in 38 s"
notified: true
pane: w1:p2
$ ovvi notify "Review ready" --pane w2:p1
  • The title is the agent's title, the body its body, and the subtitle "‹agent› · ‹session›" (on a remote machine "‹agent› · ‹machine›", or "‹agent› · ‹machine› · ‹session›" for a session other than default).
  • A click brings Ovvi to the front and shows the pane: the asking pane, or --pane's. Showing it there is the user's own action.
  • One per asking pane every 10 s, whatever --pane names: a second is rate_limited. A newer one from the same pane replaces the older in Notification Center.
  • With Settings ▸ Agents ▸ When an agent sends one off, or while Show Only Pinned Spaces hides the pane's space, it posts nothing and answers "notified": false with exit 0. That does not count toward the limit.
  • If macOS does not let Ovvi post notifications, it is failed with macOS's words.

agent, chat and view

$ ovvi agent claude --json             # beside me, with the chat view
{"agent":"claude","pane":"w1:p9"}
$ ovvi agent pi --tab
$ ovvi --timeout 40 chat w1:p4         # Relaunch with Chat, for an idle agent
$ ovvi view w1:p4 tui                  # the card's Chat | TUI switch
  • agent <name> takes claude, codex, pi or opencode. Another name is bad_argument. It makes a shell pane at the place, as open does (the same <place> options, but not --replace, which is usage), shows the agent loader on it and types the agent's command line with the chat view's launch flags, as Start Agent Here does (New pane). It answers pane and agent when the line is typed, not when the agent is ready. A pane that closes first is failed. The card opens on the chat, as after Relaunch with Chat.
  • chat <pane> runs Relaunch with Chat and answers pane and agent when the new line is typed, in about 3 s. It never asks the user, so it refuses instead, in this order:
    • The asking pane itself is self. Its agent would end before the answer.
    • A pane with no chat agent, one that shows the chat already, or one whose agent session herdr does not know is bad_argument.
    • A relaunch that already runs, or an agent that works or needs you, is busy.
  • An agent that does not quit fails only after Relaunch with Chat's 30 s wait, so pass --timeout 40 to chat. With the default, the script exits 4 first.
  • chat clears a draft that the user types into the agent (⌃C, then the quit command), and the loader takes the keys until the agent is back.
  • view <pane> chat|tui flips the card's Chat | TUI switch in every window of the session, as a click on it does, and answers pane and view. It changes only what the card draws. A pane whose agent has no chat view is bad_argument; another word than chat or tui is usage.

balance

Evens out the splits of a pane's tab, as Balance Splits does: each run of panes side by side or stacked gets equal shares. Without a pane, it is the asking pane's tab.

$ ovvi balance
pane: w1:p2
tab: w1:t1
$ ovvi balance w2:p1 --json
{"pane":"w2:p1","tab":"w2:t3"}

copy

Puts its standard input on the Mac's clipboard (at most 1 MiB, else bad_argument). It prints nothing and answers {"bytes": n} with --json. It never reads the clipboard.

git diff | ovvi copy

From a program

A program runs the script like any command:

import { execFile } from "node:child_process";
import { homedir } from "node:os";
import { join } from "node:path";

const OVVI = join(homedir(), ".cache", "ovvi", "bin", "ovvi");

/** Runs `ovvi <args> --json`; code 5 also when the script is missing. */
export const ovvi = (args: string[]) =>
  new Promise<{ code: number; json: any; err: string }>((ok) =>
    execFile(OVVI, [...args, "--json"], { timeout: 15_000 }, (e, out, err) =>
      ok({
        code: e ? (typeof e.code === "number" ? e.code : 5) : 0,
        json: out ? JSON.parse(out) : undefined,
        err,
      }),
    ),
  );

The script

The whole client, as Ovvi carries it (Ovvi/Agent/ovvi.sh). Line 2 is its version, # ovvi-cli ‹protocol›.‹revision› (Versions).

#!/bin/sh
# ovvi-cli 1.2
# Written by Ovvi: asks the Ovvi that follows this herdr session. Ovvi parses the arguments.
# https://ovvi.dev/docs/agent-control/cli
herdr=${HERDR_BIN_PATH:-herdr} session=${HERDR_SESSION:-default} pane=${HERDR_PANE_ID:-} timeout=10 json= j=
dir=$HOME/.cache/ovvi/rpc
for a; do [ "$a" = --json ] && json=1; done
# Exits with $1, saying $3 on stderr and, with --json, {"error":…} with the code $2 on stdout.
fail() {
    echo "ovvi: $3" >&2
    [ -z "$json" ] || printf '{"error":{"code":"%s","message":"%s"}}\n' "$2" \
        "$(printf %s "$3" | tr -d '\000-\037' | sed 's/[\\"]/\\&/g')"
    exit "$1"
}
while :; do
    case ${1:-} in
    --pane) [ $# -ge 2 ] || fail 2 usage "--pane needs a pane"; pane=$2 && shift 2 ;;
    --timeout) [ $# -ge 2 ] || fail 2 usage "--timeout needs whole seconds"; timeout=$2 && shift 2 ;;
    --json) j=--json && shift ;;
    *) break ;;
    esac
done
case $timeout in '' | *[!0-9]*) fail 2 usage "--timeout takes whole seconds" ;; esac
# A --json before the command goes after it, so $1 is the command.
set -- "$@" $j
[ -n "$pane" ] || fail 5 no_herdr "run me in a herdr pane, or pass --pane"
h() { "$herdr" --session "$session" "$@" 2>/dev/null; }

# status: herdr's tokens only, so it answers with no Ovvi.
token() { printf %s "$1" | sed -n "s/.*\"$2\":\"\\([^\"]*\\)\".*/\\1/p"; }
status() {
    p=$(h pane get "$pane") || fail 5 no_herdr "herdr has no pane $pane in session $session; pass --pane"
    w=$(h workspace get "$(token "$p" workspace_id)")
    shown=$(token "$p" ovvi-shown) open=$(token "$w" ovvi-open)
    printf '{"session":"%s","pane":"%s","open":%s,"shown":%s,"remote":%s,"protocol":%s}\n' "$session" "$pane" \
        "$([ -n "$open" ] && echo true || echo false)" "$([ -n "$shown" ] && echo true || echo false)" \
        "$([ "$shown" = remote ] && echo true || echo false)" "${open:-null}"
    [ -n "$open" ] || return 3
}
if [ "${1:-}" = status ]; then
    case ${2:-} in
    --json) status ;;
    --watch) last= && while s=$(status); c=$?; [ $c -le 3 ]; do
        [ "$s" = "$last" ] || echo "$s"; last=$s; sleep 2; done ;;
    *) s=$(status); c=$?; case $s in
        *'"shown":true'*) echo "Shown in Ovvi" ;; *'"open":true'*) echo "Open in Ovvi, not shown" ;;
        ?*) echo "Not in Ovvi" ;; esac; exit $c ;;
    esac
    exit
fi

umask 077 && mkdir -p "$dir" || fail 5 no_herdr "cannot write $dir"
id=$( (cat /proc/sys/kernel/random/uuid || uuidgen) 2>/dev/null | tr A-F a-f) f=$dir/$id
[ -n "$id" ] || fail 5 no_herdr "no uuidgen"
# One byte over the 1 MiB limit, so Ovvi sees standard input that is too large.
[ "${1:-}" = copy ] && head -c 1048577 >"$f.in"
printf '%s\000' ovvi-req 1 "$PWD" "$@" >"$f.tmp" && mv "$f.tmp" "$f.req" || fail 5 no_herdr "cannot write $dir"
h pane report-metadata "$pane" --source ovvi-cli --token "ovvi-req=$id" --ttl-ms 3000 >/dev/null ||
    { rm -f "$f".* && fail 5 no_herdr "herdr refused the request on pane $pane; pass --pane"; }
# Waits until $1 holds, for $2 seconds by the clock (1 s less at worst).
until_() { end=$(($(date +%s) + $2)) && until eval "$1"; do [ "$(date +%s)" -lt $end ] || return 1; sleep 0.05; done; }
if ! until_ '[ ! -e "$f.req" ]' 3 && mv "$f.req" "$f.dead" 2>/dev/null; then
    rm -f "$f".* && fail 3 no_ovvi "no Ovvi shows herdr session $session"
fi
until_ '[ -e "$f.exit" ]' "$timeout" ||
    { rm -f "$f".* && fail 4 timeout "Ovvi did not answer within $timeout s"; }
cat "$f.out" 2>/dev/null
code=$(head -n 1 "$f.exit") && tail -n +2 "$f.exit" >&2
rm -f "$f".*
exit "${code:-1}"

On this page