vike

Unattended runs

vike-agent-run — one agent session against your own node, on your own box, on your operating system's schedule, read-only unless you say otherwise, leaving a record of every run.

vike-agent-run runs one agent session against a node you are running, and exits. It is the local answer to "look at my node while I sleep": there is no account, no hosted agent, no deployment and no control plane, and nothing runs on a Vike machine — the only thing that leaves your box is the request to whichever model endpoint you chose.

vike-agent-run run \
  --task node-review \
  --node 127.0.0.1:7879 \
  --record-dir /srv/vike-agent-run/settings/state/agent \
  --budget 900

That is the whole product. The runner stands up the same vike-cli mcp server your MCP client would launch, hands one prompt to one model, bounds the session, and writes down what happened (crates/vike-agent-eval/src/bin/vike-agent-run.rs is the binary, crates/vike-agent-eval/src/unattended.rs the session it drives).

It works against your node because it is pointed at it: --node is the address of your running vike-tradehub, and the node's observe and control keys are resolved by vike-cli exactly as they always are — process environment first, then <project>/settings/secrets.env. The runner opens no credential store of its own and holds no venue key. --settings-dir DIR steers the child's project resolution when the working directory would not answer.

A review on a schedule is not an alert on a condition

Whatever cadence you pick, this notices things the morning after. What has to be noticed in seconds — a dropped venue link, a stuck order, a breached ceiling — belongs to the daemon's own kill switches and dead-man's switch, and none of it is delegated here. Read that page before treating a nightly agent review as coverage of anything.

Read-only by default, and the default is hard to lose

A scheduled agent that can place an order is a different product from one that can read. This is the second unless you go out of your way, and three separate mechanisms hold it there rather than one:

  1. There is no --profile flag on this binary. The scope is a boolean — --allow-writes, off — which crates/vike-agent-eval/src/unattended.rs's profile_for turns into the server's read-only or full ring. A flag that parses a name has a wrong-value branch, and a wrong-value branch that falls back instead of refusing is how a ring silently widens. A boolean has no unknown value.
  2. The profile is passed explicitly on every launch. The MCP server's own default is full — deliberately, so that an absent flag there is the surface that shipped before profiles existed — so a runner that merely omitted it would arm every write tool. mcp_argv, in the same file, never omits it.
  3. The roster the server advertised back is cross-checked before the model sees it. refuse_write_tools reads each advertised tool's own destructiveHint annotation and aborts — outcome refused, exit status 4, nothing driven — if a write tool is on it. An empty roster is refused the same way: a session that could do nothing would otherwise "pass" a check about what it was denied. Asking for a ring and having one are different facts, and this is what turns the first into the second.

Widening is one flag and it is loud: --allow-writes prints a banner to stderr naming the node it is about to be able to trade on, and every record stamps the flag and the profile, so a run that could have written stays distinguishable from one that could not.

The preview gate is untouched underneath all of it. Even with --allow-writes, a write executes only against a preview_token the same server minted for the same command — so an unattended agent that decides to trade still makes two calls, and both are in the transcript below.

The record

Two halves in the one directory --record-dir names, and only the second is new:

filewritten bywhat it holds
mcp-YYYY-MM.jsonlthe MCP serverone line per tools/call — the tool, whether it is a write, the redacted arguments, the verdict including every refusal, the preview token's identity. The runner passes --trace-dir, so this is the same writer, caps and redaction table --trace gives an interactive session (crates/vike-cli/src/cmd/mcp_trace.rs)
run-<UTC stamp>-<pid>.jsonvike-agent-runthe per-run header a per-call stream cannot carry: when, which task and prompt, which driver, how many model turns and tool calls, what the agent concluded, and the outcome
run-<UTC stamp>-<pid>.mcp.errthe server's stderrthat run's server diagnostics, named as a sibling so the pairing shows in a directory listing. Not part of the record

Every run leaves one, on every path. run returns a summary and never an error precisely so that the caller has no branch on which a session happened and nothing was written down. A per-run document is its own file rather than a line appended to a shared one: one record is produced at the end of one run, so opening it create_new makes atomicity structural instead of bought with the lock the per-call writer needs. Nothing here ever opens an existing record for writing, rewrites one, or deletes one.

# every run, newest last
jq -r '[.started_utc, .task, .outcome, .conclusion] | @tsv' <record-dir>/run-*.json

# only the nights something went wrong
jq -r 'select(.outcome != "ok") | [.started_utc, .outcome, .detail] | @tsv' <record-dir>/run-*.json

write_tools_offered in a record is the evidence the ring held: it is what the server advertised, not what this process asked for.

The redaction is exactly as wide as it is stated

The writer is handed the model credential values this process holds and walks every string of the document replacing them with <redacted>, so a conclusion in which a model echoed its own token back cannot land on disk. That covers the secrets this process has. A bare high-entropy string a model invented is not detectable here, any more than it is in the per-call transcript — the same residual, in the same terms.

Outcomes, and what a non-zero status means

outcomeexitmeaning
ok0the agent answered within its budget
failed1the run could not be stood up, or the driver failed
(usage error)2a bad argument, or a model credential that is unset — never a silent skip
timeout3the wall-clock budget was reached; the session was ended and recorded
refused4the server advertised a roster the requested ring forbids. Nothing was driven

Every non-zero status puts a Type=oneshot unit into the FAILED state, which is what an OnFailure= notifier keys on.

A run cannot hang. Three bounds compose and none is this binary's invention: every JSON-RPC request is bounded by the MCP client's response timeout, every model turn by its driver, and the whole run by --budget (default 900s), checked at every tool call and re-checked when the driver returns. The overshoot is stated rather than assumed away — the budget is checked between waits, never inside one, so a run can exceed it by at most one model turn already in flight, and by zero under --driver claude-cli, whose own kill the runner sets to the same instant. What cannot happen is an unbounded wait, and what is recorded either way is a timeout.

Scheduling is the operating system's job

There is no cron expression in the binary, no daemon mode, no catch-up and no retry: a scheduler here would be a second place the cadence is written down, and systemd already does missed firings, jitter and failure notification. deploy/vike-agent-run@.service and deploy/vike-agent-run@.timer are templates whose instance name is the task, installed the way the daemon's own unit is:

R=/your/project/root
sed "s,/srv/vike-agent-run,$R,g" deploy/vike-agent-run@.service > /tmp/u.service
grep -n /srv/vike-agent-run /tmp/u.service      # must print NOTHING
$EDITOR /tmp/u.service                          # …then set --node to your daemon's address
sudo install -Dm644 /tmp/u.service /etc/systemd/system/vike-agent-run@.service
sudo install -Dm644 deploy/vike-agent-run@.timer /etc/systemd/system/vike-agent-run@.timer
sudo systemctl daemon-reload
sudo systemctl enable --now vike-agent-run@node-review.timer

Read the unit's header before installing it. Three things there are not boilerplate: the write grant is <project>/settings/state and must stay that narrow — anything writable by the unit must be data, never the settings that configure the trading daemon beside it; ExecStartPre runs vike-cli config check, so a settings tree that resolves to nothing fails the firing instead of recording a night of refusals that look like an outage; and the sandbox was measured against Rust binaries, which is why the shipped ExecStart names --driver api rather than taking the binary's default implicitly.

The timer defaults to 05:40 UTC with a five-minute spread, and omits Persistent= deliberately — the one line where it differs from every other unit in that directory. Replaying a firing missed while the box was down would spend a model to file a record, stamped today, about a moment that has passed. A missed review is a review that did not happen, and the honest record of it is its absence.

cron and Windows Task Scheduler work the same way; the binary does not care what started it. The model credential has to reach the process environment in every one of them.

Tasks and drivers

vike-agent-run tasks prints the built-in prompts — node-review and risk-review today. Deliberately few: a long list of canned prompts rots without anybody noticing, because nothing can gate whether a prompt is still a good one. --prompt TEXT and --prompt-file PATH cover everything else, and the record names whichever you used.

--driverwhat it iswhat it costs
apithe Anthropic Messages API in an ordinary agent loop — the default, and what the shipped unit namesAPI credits
claude-clithe locally-installed Claude Code CLI in headless mode, driving the same surfacea subscription allowance
scripteda canned probe — one node_snapshot, then an answer. No model at allnothing

--driver scripted is a real operator tool rather than only a test fixture: scheduled, it is a daily proof that the agent surface still stands up against your node, for no model spend.

The model credential is read from the process environment by the binary and nowhere else — ANTHROPIC_API_KEY for api, CLAUDE_CODE_OAUTH_TOKEN for claude-cli. It is not a venue credential and nothing looks for it in the credential store; the runner removes both names from the environment of the vike-cli mcp server it spawns, so the token never sits inside the process holding the node connection. An absent one is a refusal naming the variable, not a skip: a scheduled run that quietly measured nothing and exited 0 would go unnoticed until somebody wondered why the record directory was empty.

What it deliberately does not do

  • No cloud, no account, no hosted agent. Nothing here phones anywhere except the model endpoint you chose.
  • No remote control. The runner opens no listening socket. Nothing can ask it to run; it runs when your operating system starts it.
  • No trading by default, and never without --allow-writes on the command line of whatever started it.
  • No grading. There is no expected answer to "is anything wrong with my node this morning", so the record is evidence for you to judge, never a verdict.
  • No default for --record-dir. It is required, because every default available to it would be a second project walk that could answer with a different project than the credentials came from. One line in a unit file, one argument at a prompt.

Residuals

  • It is not a release asset yet. Build and install it beside vike-cli — the unit fails with status=203/EXEC without both, and it finds vike-cli beside its own executable, one directory up, or wherever --vike-cli PATH names.
  • Nothing prunes the per-run records. The per-call transcript prunes its monthly files; the per-run documents accumulate one small file per firing. A daily task is ~365 files a year — fine for years, and still a bound nobody armed.
  • The claude-cli driver has not been measured under the shipped sandbox. That driver spawns a JIT runtime that reads its configuration out of your home directory, and two of the unit's hardening lines are hostile to it. The unit's header says which, and says so rather than shipping a guess.

On this page