Operator CLI (mvctl)¶
mvctl is the human command-line client for millivolt's operator plane. It
calls the same documented /admin/* and /metrics/* routes as the dashboard
and reads them through the shared MCP operator client, so the origin check,
credential handling, redirect refusal, response bound and credential redaction
have one owner. It adds no server route, no server setting and no second
listener.
mvctl is a sibling of cmd/proxy, cmd/stress and cmd/release. It is
source-only: the container image still ships the proxy, and deployment
lifecycle stays with Compose.
Build and run¶
go run ./cmd/mvctl status --url http://127.0.0.1:8081
go build -o mvctl ./cmd/mvctl
./mvctl --url http://127.0.0.1:8081 status
The origin is required: pass --url or set MILLIVOLT_URL. There is no
production default, so a command cannot silently address 127.0.0.1:8080. A
trailing slash is normalized away. Only http and https origins are
accepted; userinfo, a path, a query and a fragment are rejected.
Authentication¶
mvctl reads the operator credential from MILLIVOLT_OPERATOR_TOKEN, the one
variable the proxy's boot gate reads. It never accepts the token on argv,
because a command-line value is visible in ps and shell history. The shared
client validates the token's length band before sending a request.
export MILLIVOLT_OPERATOR_TOKEN='the-value-the-proxy-started-with'
mvctl status
Every request presents Authorization: Bearer. No cookie jar is kept: a
session cookie minted by one call is deliberately not reused, so a rotated
token takes effect on the next command. A missing or wrong credential is
reported with the operator gate's own status. A non-loopback origin should use
https; the credential is otherwise sent in cleartext.
Output and exit codes¶
Human output is the default. --json (or -j) prints one JSON document, and
command output is stable enough for a pipeline. Diagnostics go to stderr as
mvctl: .... After argument parsing, --json turns a command failure into
{"error": "..."} on stdout so a caller can parse one stream. A usage error
detected while parsing the arguments still goes to stderr and exits 2, because
JSON mode is not established yet; a usage error a command reports after a
successful parse follows --json like any other failure.
| Exit | Meaning |
|---|---|
| 0 | success, including a preview that made no change |
| 1 | transport, HTTP or proxy-reported failure |
| 2 | usage error, unknown flag or invalid input |
| 130 | interrupted during a request, stream or watch |
status and overview print an allowlisted state projection with --json:
request rows and the full effective config are omitted, so use requests and
config get for those. Request rows in that projection would carry previews,
response headers and retry metadata that the dedicated commands keep out.
ANSI color is off when stdout is not a terminal. --color, --no-color and
NO_COLOR override the default. Request labels, model ids, error types and
proxy error text are sanitized before a terminal sees them, so a captured value
cannot inject an escape sequence.
Global flags¶
| Flag | Meaning |
|---|---|
--url URL |
proxy origin; required unless MILLIVOLT_URL is set |
--timeout DURATION |
bound one request, default the shipped mcp_timeout; 0 disables the deadline |
--json, -j |
emit one JSON document |
--yes |
confirm a state-changing command |
--color / --no-color |
override terminal color detection |
--help, -h |
command or global help |
--version |
print the mvctl version and source revision |
-- ends flag parsing; when no command has been named yet, the next word may
still name one. Boolean flags accept --flag, --flag=true, --flag=1,
--flag=yes, --flag=on, and the false spellings false, 0, no, off.
Commands¶
View¶
| Command | Purpose |
|---|---|
mvctl status |
health, origin, dashboard build and headline metrics |
mvctl overview |
KPI, storage, pause, throttle and debug state |
mvctl requests [N] |
one page of the request log, default the shipped dash_log_rows; N must be inside that setting's schema band |
mvctl query "SELECT ..." |
bounded SQL through /metrics/query |
mvctl chart |
server-authoritative time series; --window is all history or whole minutes, --filter scopes it |
mvctl prometheus |
the Prometheus text exposition |
mvctl watch |
sample live counters and print a sparkline |
mvctl health |
check /healthz |
mvctl url |
print the normalized origin |
requests accepts --before-ms MS and --before-id ID together to fetch the
next page; the id is an opaque keyset position and may be empty, so it is sent
exactly as given. The command prints the next cursor when the page has more
rows. Its
JSON rows intentionally omit captured content previews, response headers and
retry attempts. Use the dashboard capture drawer when full bodies are needed.
query accepts --query-timeout DURATION; the default is the shipped MCP
query bound, and an explicit value must be greater than zero. The shared HTTP
client carries that shipped bound on its query path too, which is the one
chart takes. The statement must be a single SELECT without a semicolon,
using the same pre-check as the MCP query tool. The SQL text is sent as one q
parameter and is never logged.
chart prints the server-authoritative series: bucket geometry, percentile and
stat arrays, blended cost per million tokens and the buckets themselves. Its
fold runs through the client's query path with --query-timeout as the one
bound, so --timeout (the generic per-request deadline) never cuts it short.
--window takes all or a whole number of minutes; --filter takes
comma-separated dim:id pairs using the same dimensions as the log scope
(provider, model, client, conversation, status, key_hash). A chart
fold covers finalized requests only, so a live in-flight status class is
refused.
watch accepts --samples N, bounded by the command's settings surface, and
--interval DURATION (default the shipped dash_poll_interval). Interrupting
any request or stream exits 130.
Config¶
| Command | Purpose |
|---|---|
mvctl config get [KEY] |
show one setting or every saved setting |
mvctl config keys [SUBSTRING] |
list schema keys |
mvctl config diff |
compare saved file values with built-in defaults |
mvctl config set KEY=VALUE ... [--yes] |
revision-checked save |
mvctl config reload [--yes] |
re-read the config file |
config set reads GET /admin/config for the current revision and the schema.
The schema's kind decides coercion: bool, int, duration, bytes and
string take their natural text form; strings, providers, aliases,
model_rules, request_overrides and sub_conversations take a JSON value.
A numeric-looking string stays a string. An empty value is never a boolean. An
unknown key, an empty boolean or an undecodable composite value is a usage
error before any write.
The server writes the file and then answers 500 when the follow-up reload
fails. mvctl treats that committed document as a warning, not a failure, and
prints the reload error without inviting a retry that would fail with a stale
revision.
Credential-class provider header values are redacted from config get, config
diff, the config set preview and its post-save document, in human output and
--json, using the shared header-name vocabulary. --reveal prints them. A historical
last_reload error is replaced by the redaction marker because a rejected value
can be quoted there. Token counters, token windows and rate-limit values are
measurements, not credentials, and are never redacted.
Pause¶
| Command | Purpose |
|---|---|
mvctl pause [show] |
list holds and queued requests |
mvctl pause on --client X --provider P --yes |
create a scoped hold |
mvctl pause on --model M --conversation C --yes |
narrow a hold to a model or conversation |
mvctl pause on --new --yes |
hold clients first seen after the hold |
mvctl pause on --all --yes |
hold every client and provider |
mvctl pause on --id ID [flags] --yes |
edit one hold in place |
mvctl pause off --id ID --yes |
remove one hold |
mvctl pause off --all --yes |
remove every hold |
pause on requires a scope: --client, --provider, --model,
--key-hash, --conversation, --new or --all, unless --id names an
existing hold to edit. Set dimensions intersect and an unset dimension matches
any value, exactly as they do for a limit. --duration and --max-queued are
optional. pause off deliberately rejects scope flags: the
proxy identifies a hold by id, and a scope-looking command that cleared every
hold would be unsafe. Use exactly one of --id or --all. A resume carries
only the hold id because the proxy has no scope-selective resume.
Throttle and quota¶
| Command | Purpose |
|---|---|
mvctl throttle [show] |
list provider limits |
mvctl throttle set --provider P [flags] [--yes] |
merge limit dimensions |
mvctl throttle clear --provider P [--yes] |
drop a provider limit |
mvctl quota [show] |
show storm and quota state |
mvctl quota resume --provider P [--yes] |
close a retry-mode quota gate |
throttle set flags are --concurrency, --requests, --request-window,
--tokens, --token-window, --anti-burst and --ramp-duration. Windows and
ramps take a duration such as 1m. A count needs its window unless the policy
already stores one for that dimension, and a count of 0 clears the dimension
with its window; a window without a count is refused unless a count is stored.
At least one dimension is required. The set and clear commands are provider-only. Scoped selectors and recurring
schedules are available through the HTTP API (/admin/throttle) and the
dashboard console, not through this CLI.
Queue and capture¶
| Command | Purpose |
|---|---|
mvctl drain --all [--yes] |
release every parked request |
mvctl drain --provider P [--yes] |
release one provider's parked requests |
mvctl drain --model M [--yes] |
release one model's parked requests |
mvctl drain --client C [--yes] |
release one client's parked requests |
mvctl debug [show] |
list capture sessions and their bounds |
mvctl debug on --client C --yes |
start a scoped capture |
mvctl debug off --id ID --yes |
stop one session |
mvctl debug off --all --yes |
stop every session |
Exactly one drain scope is required. debug on requires a scope unless --id
names an existing session to edit, and it has no unrestricted all-traffic form,
matching the MCP tool: a bare enable would capture every request body. Captures
are sensitive even after header redaction, and a capture create needs durable
storage; the proxy answers 503 without it.
debug off identifies a session by id or --all, never by a scope flag,
and the two are mutually exclusive. debug on refuses a scope name the proxy
has not seen, because such a session reports enabled but captures nothing.
History, backup and restart¶
| Command | Purpose |
|---|---|
mvctl purge [filters] [--all] [--yes] |
count first, then delete |
mvctl backup --out FILE |
download an archive |
mvctl restore --file FILE [--yes] |
inspect by default, apply with --yes |
mvctl restart --yes |
request a graceful restart |
purge filters are --client, --provider, --model, --conversation-id,
--error-type, --status, --before-ms, --after-ms, --has-error and
--debug. Every value must parse, and a zero or unparsable numeric filter is a
usage error rather than an omitted constraint. --all cannot be combined with
a filter. The command always calls /admin/purge/count first and prints the
count; only --yes calls /admin/purge, and an unfiltered purge also needs
--all. Before deleting, the command re-counts and refuses when the total
changed since the preview, then sends that count with the delete so the proxy
checks it once more inside the delete transaction. That compares counts, not
row identities, so the guarantee is a refused moved population rather than a
row-identity binding. A missing, fractional, negative or non-finite count is a
failure, never zero. A genuinely bodyless purge is the proxy's
delete-everything form.
backup --out FILE is required; there is no default path. --config and
--database default to true (the command's settings surface) and are always
sent explicitly, so "both off" cannot silently become the server's both-on
default. The download streams to a private temporary file, checks the MVB1
magic, syncs the file, renames, and syncs the directory entry only on success.
An existing file is refused unless --overwrite is passed. A short HTTP body
is a failed transfer, not a published archive. Backup and restore are bounded
by cancellation, not by --timeout.
restore defaults to inspect=1, which validates the archive without applying
it; --inspect forces that mode even with --yes. --yes applies it. The
preview decodes and preflights the whole archived database member on the server,
so it costs about as much as the apply's decode; --yes alone skips the
preview and applies directly. The
member selection (--config, --database) and the mode (--config-mode,
--database-mode; merge or replace) are always sent explicitly, and both
the inspect and apply output name the selected members and modes. The default mode is replace, the proxy's documented default, so the
preview names the broader action instead of hiding it. An inspect document's
archived provider headers are redacted; --reveal prints them. The apply
response's restart_required keys are called out in the output.
Scope and non-goals¶
mvctl does not install, start, stop or update containers. It does not read or
write proxy.yaml directly, does not add a listener, and does not keep a second
copy of the proxy's routes, config schema, credential rules or backup grammar.
Those facts stay with internal/mcp, internal/config and
internal/backup. The command uses only the Go standard library and those
repository packages. Because the shared operator client lives in
internal/mcp, the resulting binary links the MCP Go SDK and its OAuth2 and
jsonschema dependencies. The backup grammar and archive magic live in the
dependency-free internal/backup/params leaf, so mvctl does not link SQLite
or zstd and never opens a database.