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 900That 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:
- There is no
--profileflag on this binary. The scope is a boolean —--allow-writes, off — whichcrates/vike-agent-eval/src/unattended.rs'sprofile_forturns into the server'sread-onlyorfullring. 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. - 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. - The roster the server advertised back is cross-checked before the model sees it.
refuse_write_toolsreads each advertised tool's owndestructiveHintannotation and aborts — outcomerefused, 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:
| file | written by | what it holds |
|---|---|---|
mcp-YYYY-MM.jsonl | the MCP server | one 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>.json | vike-agent-run | the 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.err | the server's stderr | that 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-*.jsonwrite_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
| outcome | exit | meaning |
|---|---|---|
ok | 0 | the agent answered within its budget |
failed | 1 | the run could not be stood up, or the driver failed |
| (usage error) | 2 | a bad argument, or a model credential that is unset — never a silent skip |
timeout | 3 | the wall-clock budget was reached; the session was ended and recorded |
refused | 4 | the 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.timerRead 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.
--driver | what it is | what it costs |
|---|---|---|
api | the Anthropic Messages API in an ordinary agent loop — the default, and what the shipped unit names | API credits |
claude-cli | the locally-installed Claude Code CLI in headless mode, driving the same surface | a subscription allowance |
scripted | a canned probe — one node_snapshot, then an answer. No model at all | nothing |
--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-writeson 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 withstatus=203/EXECwithout both, and it findsvike-clibeside its own executable, one directory up, or wherever--vike-cli PATHnames. - 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-clidriver 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.