Agent
viite agent watch # read-only: the agent plans, nothing is writtenviite agent watch --allow-write # let the agent edit files in the working treeviite agent watch --local # against a local dev APIviite agent watch --api-url https://...Long-polls the platform board for claimed cards and runs them on this machine, streaming the transcript back to the card. It serves two kinds:
codingcards — driven by a coding-agent binary (default:claude), in a repo fromrepos.localcards — a CLI you already have installed, from a recipe inrecipes(see Local Command).
The daemon advertises which kinds it can serve, derived from what the config grants: with no
recipes it never claims a local card, and with no repos it never claims a coding one. Either
list on its own is enough to be a useful runner.
At startup the banner states what this machine will take, one line per lane the board can dispatch:
Takes coding cards → platform local cards → mov-to-mp4, compress-video scheduled work → any hour, as soon as a card is taggedA lane you have not configured says so, and says which key to add. The third lane is covered in Scheduled work below.
What the board knows about this machine
Section titled “What the board knows about this machine”Every claim tells the platform this runner is alive and what it declares — the names in repos
and recipes, never their paths or commands. That is what lets a card be refused the moment you
press Run, with a reason, instead of sitting on “Running…” until you go looking. The board will tell
you it has no machine watching, that the one watching runs the other kind of card, or that your
recipe is not among the ones it declares — and name what it does have.
Presence is refreshed by every call the runner makes, not just the claim, so a machine stays visible while it is busy executing a run. It goes stale about 90 seconds after the daemon stops.
Nothing here grants anything. What actually runs is still decided against ~/.platform-cli/agent.json
on this machine; the board only learns enough to stop queueing work that has nowhere to go.
A daemon from before this existed advertises nothing, and is never refused on that basis — the board treats “told us nothing” as unknown rather than as “declares none”. Such a runner is trusted to take any card of a kind it claims, and an unrunnable one fails the old way, on the machine.
Requires a config file at ~/.platform-cli/agent.json:
{ "repos": [{ "name": "platform", "path": "/Users/me/code/platform", "allowPush": false }]}This file is the security boundary: a card names a repo by name and requests allowPush, and the
runner can never grant more access than what’s listed here. Without --allow-write, the agent runs
in read-only plan mode (Read/Glob/Grep only); with it, Edit/Write/NotebookEdit are added
and the agent can modify the working tree. A push only happens if the card, the local config, and
--allow-write all agree — --dangerously-skip-permissions is never used.
A card can ask for less. Its permissions field (plan or write) is clamped against
--allow-write and can only ever narrow it: a card asking to plan on a write-mode runner runs in
plan mode, and a card asking to write on a runner started without the flag runs in plan mode too,
with the transcript saying why. A card that says nothing gets whatever the machine allows, which is
what every card did before the field existed.
Trust tiers — who decides the tools
Section titled “Trust tiers — who decides the tools”By default the runner pins the tool list itself: it passes an explicit --allowedTools — the
read-only set, plus the editing tools with --allow-write, plus anything in the config’s
allowedTools. Nothing the project’s own Claude Code settings say can widen that, which also means
an unattended agent cannot run the tests it would need to check its own work.
trust: "project" hands that decision to the working tree, for write runs only:
{ "repos": [ … ], "trust": "project" }viite agent watch --allow-write --trust project # the flag wins over the fileA write run then gets no --allowedTools at all, so the tree’s .claude/settings.json and
settings.local.json decide — exactly what you get opening the same tree in the app, minus the
prompts. --permission-mode is still acceptEdits and --dangerously-skip-permissions is still
never passed: a tool the settings do not allow is denied (the run is non-interactive, so there is
nobody to prompt), and that boundary is the one the app has. Plan runs stay narrow in either tier —
widening a run that must not write buys nothing — and a teammate that names tools still narrows
against this machine’s list, because --allowedTools grants without prompting and a
server-delivered Bash must never reach it raw. A teammate that only forbids tools becomes
--disallowedTools, which needs no allowlist to narrow.
What a finished run reports
Section titled “What a finished run reports”The outcome on the card is a statement about what happened, not about what was allowed:
| Outcome | Means |
|---|---|
implemented | git saw the workspace change during the run |
planned | the workspace is as the run found it |
needs_input | the agent stopped because it needs something only you have |
pushed | a branch left the machine |
failed | the agent errored, or the run could not start |
implemented is decided by comparing the workspace against a snapshot taken when the run started —
not by whether --allow-write was passed. A write-enabled agent that changes nothing reports
planned, because nothing is what it did. A directory git cannot answer for reports no change for
the same reason: an unverifiable claim of work done is worse than an under-claim, because it sends
you looking for a diff that is not there.
needs_input exists because a batch run is a one-shot with nobody at the keyboard. The agent is told
so, and asked to act on its best reading rather than ask; when it truly cannot, it ends with a
NEEDS_INPUT: line naming what it needs, and the card says so instead of reporting work that does
not exist. A card whose whole description is a link is refused this way before the agent starts,
since the default tool list cannot open one.
A blocked run does not stop at the card. It raises an alert card of its own — Needs your input: <card>, with the agent’s line as the description — that withdraws itself when a later run on that card is pushed, exactly as a failed run’s alert does.
Decisions. A product decision — naming, wording, which of two reasonable designs — is not
missing input, and an unattended agent is told to take the option it would recommend and carry on.
It then lists each one on its own line under a DECISIONS: line in its final message; the runner
reads that block (ten decisions of three hundred characters at most) and sends it with the result.
The email. When a run that was scheduled settles — implemented, planned, pushed, blocked or failed — its owner gets one message saying so: the outcome, the branch and worktree, the pull request if there is one, what the agent needs if it stopped, and every decision it took alone. A run you clicked yourself sends nothing; you are already reading its transcript.
Isolating runs in a worktree
Section titled “Isolating runs in a worktree”By default a run works in the repo directory itself, alongside whatever you are doing in it. Set
worktree on a repo entry and each run instead gets its own git worktree, on its own branch:
{ "repos": [ { "name": "platform", "path": "/Users/me/code/platform", "worktree": true, "postCreate": ["bun", "install"] } ]}The worktree is created as a sibling of the repo (platform-worktrees/<run id>) on a branch named
from the card’s branchPrefix, defaulting to coding/. postCreate runs once inside the new
worktree before the agent starts — a fresh worktree has no node_modules, so without it “isolated”
often means “every test fails”. A postCreate that fails is reported in the transcript and does not
abort the run.
Two behaviours worth knowing:
- A worktree that could not be created FAILS the run. It never falls back to the repo directory, because that is precisely the thing you asked it to stay out of.
- A worktree holding work is KEPT, not removed. Uncommitted changes, new untracked files, or a commit that was never pushed all count; the transcript names the directory. Only a worktree git reports as completely unchanged is removed, along with its branch.
There is no card-side field for this. Isolation decides where a process runs, and only your machine knows whether this repo can be worked on outside its own directory.
"worktree": "helper" is true done the way a person does it. The worktree is created by the
repository’s own bun run worktree create machinery instead of a bare git worktree add: it lands
in platform-worktrees/implement-<sha3>-<words> — the name the /implement skill gives the same
card, computed server-side from the card id and title and frozen onto the run — with the .env
files copied and repointed at a free port offset, the Claude Code settings carried over, git pull --rebase origin main and bun install run, and the checkout’s project memory grafted onto the tree
so the agent starts with what the checkout has learned. postCreate is not needed. This setup is
specific to this repository’s layout, which is why it is a named strategy rather than what true
means; a resumed run reclaims the same tree, and the keep-or-remove rules above are unchanged.
Flags:
--allow-write- allow the agent to edit files (default:false, read-only plan mode)--trust-narrow(default) orproject: who decides a write run’s tools (see above)--api-url- platform API base URL--local- shortcut forhttp://localhost:3000(default:false)--agent-command- the coding-agent binary to spawn (default:claude)--interactive-agent- RPC-mode agent for interactive cards, e.g.pi(see below)--runner-id- stable id for this runner install (defaults to<hostname>-<pid>)--config- runner config to use (default:~/.platform-cli/agent.json)
Interactive sessions — how to use
Section titled “Interactive sessions — how to use”A normal coding card is fire-and-forget: the prompt is frozen when you click Run, the agent runs once, and you watch. Tick Interactive session on the card and it becomes a conversation instead — you type into the transcript, the agent answers, and it can ask you questions mid-run.
The agent still runs on your machine. Nothing happens until a runner is watching:
viite agent watch --interactive-agent piNot installed globally? Run it from a checkout of the platform repo:
bun run cli agent watch --interactive-agent piThree things must be true. The board checks the first two before it queues anything and refuses the card with a reason, so a session that never starts is now usually a card that was never accepted:
-
A runner is watching. The platform never executes a run — it queues one. No runner, no work.
-
The repo is allowlisted in
~/.platform-cli/agent.json(above). If the card’s Repo field is blank, the runner only proceeds when exactly one repo is configured; otherwise it refuses rather than guessing, and the transcript saysrepo_not_allowed. The board catches both cases at the click when the machine has told it which repos it has. -
This machine grants an interactive agent — the
--interactive-agentflag above, or in~/.platform-cli/agent.json:{"repos": [{ "name": "platform", "path": "/Users/me/code/platform" }],"interactiveAgent": {"command": "pi","args": ["--mode", "rpc"],"apiKeyEnv": "ANTHROPIC_API_KEY"}}A card can ask for a conversation; only this file (or the flag) can grant one, the same rule as
allowPush. Without it the card still runs, as a one-shot, and the transcript explains why.The card’s provider and model are passed to the agent, so the model picker on the card is what actually runs.
apiKeyEnvnames an environment variable holding the provider key — never the key itself, since this file is the one most likely to end up in a bug report. It is only needed when the agent won’t authenticate on its own:piprefers its stored OAuth credential and ignoresANTHROPIC_API_KEY, so an org where OAuth is disallowed (oauth_not_allowed_for_organization) can only be worked around with pi’s explicit--api-key, which the runner adds from this variable. Note the key then appears in the child’s argv, readable viaps— leaveapiKeyEnvunset unless you need it.
When the agent will not start. The interactive agent is a program on your machine, and a
program that crashes at startup takes the run with it. The card reports failed, and the
transcript carries the agent’s own stderr — its last 500 characters, which is where a crash says
what went wrong. A command that is not on PATH at all says so by name, so a typo in
interactiveAgent.command diagnoses itself.
Worth knowing because the usual cause is nothing to do with your card: a broken plugin or extension
in the agent’s own install will stop it booting for every run until you fix it there. Reproduce it
by running the exact command from interactiveAgent in a terminal — if it exits non-zero on its
own, the runner had no more to work with than you do.
When the agent answers nothing. A turn that produces no output at all is reported in the
transcript as an error rather than silence, because a provider failure (bad auth, no credit, an
unknown model id) does not reach the agent’s own error channel — pi reports it as a successful
turn with empty content, and states the refusal on the turn instead.
The transcript carries the provider’s own message when the agent gave one, so the card usually
tells you the whole answer. It is not on the runner’s terminal: a refused model call never reaches
pi’s stderr, which is why an earlier version of this page sending you there was wrong. Only when
the agent reports no reason at all does the transcript fall back to naming the usual causes.
The common one for a pi runner is billing rather than auth. pi authenticates with a Claude.ai
plan OAuth credential by default, and third-party apps no longer draw on plan limits — a perfectly
valid, unexpired credential then comes back as a 400 saying so. Either add usage to that account,
or set the variable named by apiKeyEnv so the runner passes --api-key and the agent bills your
API account instead. A card whose apiKeyEnv names a variable that is empty on the runner says so
in its transcript, rather than falling back silently.
Once the session is live the transcript grows a composer:
- Send — a follow-up turn. Stop aborts the current turn without ending the session.
- End session — closes the agent and settles the card. A session also ends itself after 15 minutes of silence, because it holds the runner and other queued cards wait behind it.
- When the agent asks something, the card shows waiting for you and answer buttons; answering unblocks it. Leave it 15 minutes and the runner cancels the question on your behalf.
Both of those timeouts live in the runner process itself, so they only fire while it’s still
running. If the runner is killed outright (a crash, Ctrl-C, closing the laptop lid mid-session)
there’s nothing left to notice the silence or drain an End session click — the card’s lease
still expires, but nothing settles it automatically. Once the transcript shows runner
disconnected, click Settle this session to close the card out yourself; the next
agent watch you start elsewhere is unaffected either way.
Scheduled work (your AI team)
Section titled “Scheduled work (your AI team)”A teammate on the board can queue work for you, and a plain viite agent watch takes it:
viite agent watch # takes tagged cards as they appear, plan onlyviite agent watch --allow-write # …and lets the agent edit files| Flag | Default | What it gates |
|---|---|---|
--autonomous | on | Whether scheduled work is taken at all. --no-autonomous refuses it. |
--window | any hour | Hours it may run, local time, [from, to). 22-7 wraps midnight. |
--idle-minutes | 0 | Minutes away from the keyboard first. 0 is no gate. |
--quiet-minutes | 0 | Minutes since Claude was last used here first. 0 is no gate. |
Every gate you set must pass, every time, before a scheduled card starts — and a machine that cannot
tell whether you are at it refuses rather than guesses. A malformed --window stops the daemon at
startup instead of quietly widening the gate to every hour: being told at 3am by your Claude quota
is the failure that matters.
The overnight machine
Section titled “The overnight machine”The old defaults — autonomy off, 15-minute idle gates — are still exactly right for a machine that should only work while you are asleep. They are now something you ask for rather than something everyone gets:
viite agent watch --allow-write --window 22-7 --idle-minutes 15 --quiet-minutes 15viite agent watch --no-autonomous # only work you click yourselfWhy the default changed
Section titled “Why the default changed”Nothing runs unless you tagged a card agent:autonomous yourself, on a card you wrote, of a kind a
machine can run. That tag is the opt-in, and it is per card. Requiring a CLI flag on top of it meant
the commonest outcome of tagging a card was silence from a machine that was never going to take it —
with no way to tell that apart from a machine that simply had nothing to do.
The safety did not go away, it moved somewhere you can see it. --allow-write is still off by
default, so a scheduled run on a bare viite agent watch produces a plan and writes nothing.
Scheduled work is otherwise an ordinary run. It streams a transcript to the card, respects
permissions and worktree exactly as a clicked run does, and Cancel run stops it the same way.
The one thing it does that a clicked run does not is email you when it settles — see
what a finished run reports.
Nothing about this is decided by the server. The card can ask; only this machine grants — which is why the banner states the hours and the conditions on every start. A machine that will accept work you did not click has to say so, because you never typed a flag to ask for it.
Cancelling a run
Section titled “Cancelling a run”Cancel run on the card stops it — a run still queued, one a machine is working on, and one whose machine has gone quiet. It is the way out of a card that has nothing coming: a run nobody claimed is never retried by anything, because the sweep that retires dead runs is itself driven by a claim.
Nothing is sent to your machine. The card settles server-side, and the runner finds out through the lease it already renews: its next heartbeat is refused, within 30 seconds, and it kills the agent or command it was running. So cancelling stops the work and the billing for it, not just the card’s story about it. A local recipe is killed as a process group, so its children go too.
A run cancelled this way ends as cancelled, deliberately not failed — nothing went wrong, and a
card that says “failed” sends the next reader hunting for an error nobody caused. Press Run again
when you’re ready; a cancelled card is a normal starting point.
One runner handles one run at a time, deliberately — two agents editing one working tree is a corruption bug, not a throughput win. An interactive session therefore holds that machine until it ends.
A note on pi and your working tree. pi keeps its session history in ~/.pi/agent/sessions/,
bucketed by working directory — nothing about a session is written into your repo. Its bundled
todos extension is the exception: it resolves its directory against the cwd (.pi/todos), so a
run that touches todos would leave an untracked .pi/ in your repo that no .gitignore covers.
The runner therefore spawns agents with PI_TODO_PATH defaulted to ~/.pi/todos, keeping the
working tree clean. If you’d rather have todos per-repo, set it yourself — an explicit value is
never overridden:
PI_TODO_PATH=.pi/todos viite agent watch --interactive-agent piAuth: prefers VIITE_API_KEY (an org-scoped API key, sent as X-API-Key — independently
revocable and won’t expire out from under a long-running daemon); falls back to the CLI session file
if unset.
When your Claude login expires
Section titled “When your Claude login expires”Two credentials live on a runner and they are unrelated. The one above lets viite agent watch talk
to the platform. The agent’s own Claude login is the second, and it is the one that lapses on its
own schedule, on a machine nobody is sitting at, at whatever hour it happens to reach.
You’ll see it as a card that failed almost instantly, with:
Failed to authenticate: OAuth session expired and could not be refreshedThat sentence comes from the claude binary, not from us. The fix is to sign in again on that
machine:
claude auth loginNot claude -p /login — /login is a slash command interpreted inside an interactive session, so
-p sends it to the model as a prompt and nothing signs in. auth login is a real subcommand and it
opens the browser itself.
Nothing else is required of you. The runner does not need restarting and the cards you queued do not need re-queuing by hand. Once it sees the failure it stops taking work, prints the command, and rechecks every minute — the moment you are signed in, it starts claiming again on its own.
What the runner and the board do about it
Section titled “What the runner and the board do about it”- At startup it asks
claude auth status --json, which is free and instant, and prints the account on theClaudebanner line — orAPI key from ANTHROPIC_API_KEYwhen an exported key is what the agent will use. If you are signed out it says so there, before a card pays to find out. It warns rather than refusing to start — the probe can be wrong (an olderclaudewith noauthsubcommand, or an exported key it reports as signed in whether or not the key still works), and a daemon that won’t boot on a false negative is worse than the problem. - On the first failure it pauses. This is the part that matters: before it, every scheduled beat claimed a card, watched the agent die in about a tenth of a second, and burned one of that card’s three attempts — nine cards a night on an overnight schedule, all with the same cause and none of them naming it.
- The board refuses the card too. A paused runner tells the server its login has expired, so Business Studio greys the Run button with the reason instead of queueing work that cannot move. A machine that hasn’t said anything is never refused, so nothing changes for a runner too old to report it.
localcards keep running. A recipe runs a local binary and needs no Claude at all.
Opening the sign-in automatically
Section titled “Opening the sign-in automatically”viite agent watch --login-on-expiryWith this the runner opens the sign-in page itself the moment it detects the expiry, once per outage. Off by default, and it should stay off on an unattended machine — a browser window opening at 3am on a headless box helps nobody, and starting a sign-in flow on someone’s behalf is a thing to opt into rather than inherit.
What it cannot do
Section titled “What it cannot do”It cannot warn you before the login expires. There is no expiry date in claude auth status, and
none readable from the keychain item the credential lives in without unlocking the secret — which is
a password prompt we won’t raise on an unattended machine. The moment it breaks is the earliest
anything here can know, so that is when it tells you.
A related failure looks similar and is not the same thing: a valid login refused for billing —
“third-party apps now draw from your extra usage”. The runner classifies that separately and tells
you to add extra usage or set ANTHROPIC_API_KEY, because re-logging-in would not fix it.
When the agent’s API key is rejected
Section titled “When the agent’s API key is rejected”An exported ANTHROPIC_API_KEY is used instead of your Claude login: claude -p inherits the
runner’s environment and prefers the key. That is how runs bill the API account — and it means a
revoked or mistyped key fails every card with
Failed to authenticate. API Error: 401 API key is invalid.while claude auth login changes nothing. To see which credential a runner uses, read the Claude
banner line, or run this in the terminal you start viite agent watch from:
claude auth status --json"authMethod": "api_key" with "apiKeySource": "ANTHROPIC_API_KEY" means the key wins. Note that
this reports loggedIn: true for any exported key, valid or not.
The runner classifies this separately: the card and the ✗ run … FAILED — … line carry the agent’s
own reason, and the runner pauses. Unlike an expired login it does not resume by itself, because a
running process keeps the environment it started with. Replace the key where it is exported (for
example ~/.viite-cli/env, if your shell profile sources it) or unset it to fall back to your login,
then restart viite agent watch from a new terminal.
It is checked at startup, too. For every key an agent on this runner will use (the one claude
inherits and pi’s apiKeyEnv), viite agent watch asks Anthropic’s models list — free, no model
call — and prints a Key line:
● Key ANTHROPIC_API_KEY accepted by Anthropic · used by claude and piA 401 prints REJECTED and the runner starts paused, so the fix happens while you are at the
terminal rather than on the first card after midnight. Anything else — no network, a timeout, a
proxy’s 403 — says not checked and blocks nothing. An accepted key is not proof of credit; a
billing refusal is still reported when a run hits it.
The Key line names which key the runner holds — its prefix and last 4 characters, never the
key — so you can compare it with the one you just created:
▲ Key ANTHROPIC_API_KEY (sk-ant-api03-…Wxyz) REJECTED by Anthropic (401) · used by claude and pi cards will not run: replace or unset it, then restart `viite agent watch` from a NEW terminal ~/.viite-cli/env:7 holds a DIFFERENT ANTHROPIC_API_KEY, but this terminal still has the one it exported before that edit — open a new terminal, then restartUnder a rejection the runner reads ~/.viite-cli/env as plain text (it never runs or exports it) and
says how it relates to the key in use:
| It says | What it means |
|---|---|
holds a DIFFERENT … | You fixed the file, but this terminal exported the old key before the edit. Open a new terminal. |
is not an assignment: "ANTHROPIC*API_KEY" … | The line was pasted through Markdown (_ → *, added \). Your shell skipped it — and zsh printed the whole line, key included, as no matches found. Retype the line; treat that key as exposed. |
holds this same key | The file’s key is the rejected one. Replace it there. |
does not set … / exported by your shell | The key comes from somewhere else. unset ANTHROPIC_API_KEY to use your Claude login. |
A value that cannot be a key at all — a *, \, quote or whitespace in it — is named with its
position before any of that. viite reads no .env file for this key: a key in a project’s .env
(for example apps/api/.env) does not reach the runner.
