Skip to content

Agent

Terminal window
viite agent watch # read-only: the agent plans, nothing is written
viite agent watch --allow-write # let the agent edit files in the working tree
viite agent watch --local # against a local dev API
viite 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:

  • coding cards — driven by a coding-agent binary (default: claude), in a repo from repos.
  • local cards — a CLI you already have installed, from a recipe in recipes (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 tagged

A lane you have not configured says so, and says which key to add. The third lane is covered in Scheduled work below.

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.

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" }
Terminal window
viite agent watch --allow-write --trust project # the flag wins over the file

A 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.

The outcome on the card is a statement about what happened, not about what was allowed:

OutcomeMeans
implementedgit saw the workspace change during the run
plannedthe workspace is as the run found it
needs_inputthe agent stopped because it needs something only you have
pusheda branch left the machine
failedthe 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.

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) or project: who decides a write run’s tools (see above)
  • --api-url - platform API base URL
  • --local - shortcut for http://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)

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:

Terminal window
viite agent watch --interactive-agent pi

Not installed globally? Run it from a checkout of the platform repo:

Terminal window
bun run cli agent watch --interactive-agent pi

Three 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:

  1. A runner is watching. The platform never executes a run — it queues one. No runner, no work.

  2. 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 says repo_not_allowed. The board catches both cases at the click when the machine has told it which repos it has.

  3. This machine grants an interactive agent — the --interactive-agent flag 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. apiKeyEnv names 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: pi prefers its stored OAuth credential and ignores ANTHROPIC_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 via ps — leave apiKeyEnv unset 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.

A teammate on the board can queue work for you, and a plain viite agent watch takes it:

Terminal window
viite agent watch # takes tagged cards as they appear, plan only
viite agent watch --allow-write # …and lets the agent edit files
FlagDefaultWhat it gates
--autonomousonWhether scheduled work is taken at all. --no-autonomous refuses it.
--windowany hourHours it may run, local time, [from, to). 22-7 wraps midnight.
--idle-minutes0Minutes away from the keyboard first. 0 is no gate.
--quiet-minutes0Minutes 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 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:

Terminal window
viite agent watch --allow-write --window 22-7 --idle-minutes 15 --quiet-minutes 15
viite agent watch --no-autonomous # only work you click yourself

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.

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:

Terminal window
PI_TODO_PATH=.pi/todos viite agent watch --interactive-agent pi

Auth: 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.

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 refreshed

That sentence comes from the claude binary, not from us. The fix is to sign in again on that machine:

Terminal window
claude auth login

Not 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.

  • At startup it asks claude auth status --json, which is free and instant, and prints the account on the Claude banner line — or API key from ANTHROPIC_API_KEY when 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 older claude with no auth subcommand, 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.
  • local cards keep running. A recipe runs a local binary and needs no Claude at all.
Terminal window
viite agent watch --login-on-expiry

With 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.

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.

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:

Terminal window
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 pi

A 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 restart

Under 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 saysWhat 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 keyThe file’s key is the rejected one. Replace it there.
does not set … / exported by your shellThe 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.