Live node
8 tools against a running vike-tradehub node — one read, and seven order-write verbs behind a two-call gate that needs both confirm and a single-use preview_token.
Eight tools that need --node <host:port> and a key. One reads the node's live state
(node_snapshot, observe key); the other seven write to it (control key) — six place, change or
cancel orders, and set_trading_state moves the account's trading state. Every write tool previews
by default: a first call resolves the command, shows it, and sends nothing. Executing takes a
second call carrying both confirm: true and the preview_token that first call returned
— anything less is another preview.
Everything on this page travels to the node over a wire that is authenticated but not encrypted, and the HMAC covers the connection rather than each frame — so orders, positions and symbols are readable by anyone who can read the traffic, and a remote node belongs behind an ssh tunnel or a VPN. Client setup states the limit in full; Remote node is the tunnel recipe.
The seven are the full write-verb roster of the human trade REPL, and both surfaces build their
commands at one site — crates/vike-cli/src/cmd/verbs.rs's Verb::to_wire_command, which the MCP
tools reach through wire_command_for: JSON arguments in, the same wire command the REPL would
build out. An agent and an operator are issuing literally the same commands.
Nothing holds the count at seven at build time. What holds it is a unit test over the advertised
tool list — tools_list_has_read_and_write_tools_with_correct_hints in
crates/vike-cli/src/cmd/mcp.rs — which requires every tool outside its seven-name roster to carry
readOnlyHint. An eighth write verb turns that test red until somebody widens the roster
deliberately.
A session can be given none of the seven
The gate below is what a write costs when the tools are served. Whether they are served at all is
the operator's call, at launch: vike-cli mcp --profile read-only serves the ten read tools and
withholds all seven of these, which are then absent from tools/list as well as refused by
tools/call — ToolAccess::new (crates/vike-cli/src/cmd/mcp.rs) derives the advertised roster
and the routing gate from one pass, so they cannot disagree. --deny-tool NAME withholds one more,
repeatably; --profile offline leaves only the four authoring tools and withholds both resources
too, since each routes into a tool that opens a socket. The prompts stay, re-worded: a session with
no admitted write opens each one with a banner saying so instead of teaching a two-call procedure
whose first call would be a refusal. Nothing an agent sends can widen this, and the refusal says so.
Client setup has the config entry.
node_snapshot
The running node's live state: open orders, positions, per-venue equity and recent events. Takes
no arguments; needs --node and VIKE_TRADEHUB_OBSERVE_KEY.
Returns the node's snapshot frame — WireSnapshot, in
crates/vike-tradehub-client/src/wire.rs — serialized as-is, so the response's field set is that
struct's.
The observe connection is opened lazily and kept open — it stays subscribed so the node keeps
pushing frames. A freshly-subscribed connection answers with an empty placeholder (seq 0) until
the first coalesced frame lands, so the tool waits up to about two seconds for a real one before
returning.
This is also the tool that settles an ambiguous write. See Outcomes.
Show me the node snapshot — what is open and where is the equity?
How a write call works
Two calls, deliberately — and the second one carries two things, not one.
Call one — the preview. The server builds the wire command from your arguments, mints a client
order id if one is needed, asks the node what it would do with that command, and returns the answer
having sent nothing. The payload itself is assembled by preview_of in
crates/vike-cli/src/cmd/mcp.rs, which is pure — the node question happens before it, in
Server::node_preview — and it emits exactly these fields:
| Field | What it holds |
|---|---|
will_execute | always false on this path |
tool | the tool name being previewed |
wire_command | the fully resolved command, exactly as it would go on the wire |
guardrail | the client-side advisory check (below) |
node_verdict | the node's own dry-run, or null when there was no node address or no control key to ask with |
verified_by_node | whether the node was asked |
preview_token | the token that confirms this exact command |
reason | the rationale that would be recorded — trimmed, null when blank or absent |
note | what a confirming call needs, the client-order-id rule, and where enforcement actually lives |
Call two — the execution. The same arguments plus both confirm: true and the
preview_token the preview handed back. Either one missing returns another preview and sends
nothing:
{ "venue": "binance", "symbol": "BTCUSDT", "side": 1, "qty": 0.01,
"confirm": true, "preview_token": "pv-1" }verified_by_node means asked, not answered
verified_by_node is true as soon as there was a node address and a control key to ask with — it
does not promise the node replied. A handshake or transport fault is not a verdict, so
node_verdict.checked_by is the field that settles it: "node" means the node answered and
accepted is its own judgement, "none" means it could not be reached and reason names the
fault. An absent verdict must never be read as an approving one, which is also why the preview's
note says in words that the guardrail is an unverified client-side estimate whenever the node was
not asked (a_preview_with_no_node_says_it_was_not_verified pins that wording).
What the token is, and what it is not
The token is pv-<counter>, minted by PendingPreviews::issue and bound to the command it
previewed. It is not a secret and does not need to be — this is a stdio server, and the only
party that can send it a request is the agent already holding both ends of the pipe, so there is no
third party to withhold it from. What it proves is that a preview happened for this exact
command: it encodes a sequence, not an authorization. PendingPreviews' own doc argues that at
length, and a counter is all the property needs.
Four rules follow, and each is enforced rather than advised:
- It fires once.
PendingPreviews::takeremoves the entry before the command is executed, so a duplicated call finds nothing and getsunknown or already used. Pinned bya_preview_token_fires_at_most_once. - It expires after 60 seconds —
PREVIEW_WINDOW, the same window the Telegram surface uses — because a preview priced against one book must not be confirmable against a moved one. An expired token is consumed as it is rejected, so there is no second attempt at it either. - It is bound to its own command.
same_intentcompares the whole wire command and ignores exactly one field, the freshly mintedclient_order_id. Confirming a different command with a live token is refused — and consumes it. Pinned bya_preview_token_is_bound_to_the_command_it_previewed. - It dies with the session. The store is in-process memory, so a token from a previous
vike-cli mcprun confirms nothing.
What actually reaches the venue is the stored command, not the one the confirming call rebuilt. They express the same intent — that was just checked — but only the stored one carries the client order id the preview displayed and the node dry-ran, so what is sent is exactly what was shown.
confirm: true on its own is not an error — it is a preview
Sending confirm: true without a preview_token does not fail. It returns a fresh preview,
will_execute: false, a new token, and nothing on the wire. An agent that treats a confirming call
as evidence it traded — without reading will_execute — will believe it placed an order that does
not exist. confirm_alone_no_longer_executes_it_returns_a_preview is that behaviour under test.
`confirm` must be the JSON boolean true
confirm is read as a JSON boolean and nothing else, so the string "true" — and 1, and "yes"
— fall through to the preview. That failure is safe (nothing is sent), but it is silent: check
will_execute in the response rather than assuming.
An unknown, expired or mismatched token is the one shape that is loud: those three are tool errors carrying the reason, not previews. So the quiet answer always means "nothing happened", and the noisy one always tells you why.
The rationale
Every write tool takes an optional reason string — why the agent is issuing this command. It
rides beside the command on the wire, never inside it: reason_from_tool_args
(crates/vike-cli/src/cmd/verbs.rs) reads it separately from the arguments the command is built
from, so no reason you pass can change what gets sent.
It is echoed in the preview, sanitized on the node by sanitize_reason
(crates/vike-tradehub/src/audit.rs), and lands in the node's audit trail: control characters are
stripped and the text is capped at 512 characters (MAX_REASON_CHARS). It never reaches the order,
the core fold, the journal or a venue.
What the node's trail cannot hold, and where it goes instead
That trail records one line per accepted command, which leaves out the half an operator wants
after a bad night: what the agent tried. A refusal never reaches it at all — it was refused before
a byte left the MCP process. vike-cli mcp --trace is the record that does hold it
(crates/vike-cli/src/cmd/mcp_trace.rs): one appended JSONL entry per tool call, carrying the tool,
the write bit, the profile, the arguments and a verdict of ok, preview, refused or error —
with preview kept apart from ok, because "the agent called submit_order" and "an order left
this process" are different facts. For a gated write it records the preview token's identity in a
field of its own — pv-3, the same per-session counter this page argues is a sequence and not an
authorization — plus whether this call minted or presented it. The preview_token argument is
redacted, along with every credential-shaped key and every credential-shaped SHOUTING_CASE
identifier appearing inside a string value, so a reason quoting an API-key name has the whole
value replaced.
It is off unless the flag is given — no file, no directory — and writes under
<project>/settings/state/agent/, beside the change journal.
What actually enforces
The preview's guardrail is advisory UX computed on your machine — guardrail_check in
crates/vike-cli/src/cmd/verbs.rs. Only a submit has a size to check; every other verb reports
within_limits: true with a note saying there was nothing to size. Its two caps come from
different places: max_qty from VIKE_MAX_ORDER_QTY (a local typo-catcher with no enforcing
counterpart anywhere) and max_notional from this machine's policy.toml
(max_notional_per_order). A market order carries no price, so its notional is null and cannot
be checked client-side at all.
What refuses a command is server-side, on the node, and an agent cannot reach it. The first two are
one function — ControlLimits::vet in crates/vike-tradehub/src/server.rs, which takes a rate
token before it sizes anything:
| Gate | Where | Reach |
|---|---|---|
| Rate limiter | vet, at the node's control edge | a token is consumed on every command, per control connection; the rate comes from VIKE_TRADEHUB_CONTROL_RATE |
| Notional cap | vet's notional half, same edge | only order-increasing verbs carrying a price: submit_order, and modify when it names both a new qty and a new price. The ceiling is the node's max_notional_per_order, unset by default — a node whose policy.toml sets none skips this half entirely |
| Risk gate | RiskGate, in the execution core (crates/vike-exec/src/risk.rs) | beneath both, and the gate that resolves a resting order's terms |
So flatten, mass_cancel, market_exit, cancel_order and set_trading_state are rate-limited
but never size-checked — they only reduce exposure, which is the right way round.
One shape fails closed rather than passing: on a node that sets a notional ceiling, a modify that
names a new quantity but no price is refused, because the control edge holds no order registry
and cannot price the projected order — and "I cannot evaluate this ceiling" must never render as
"the ceiling passed". Re-send the modify with an explicit price. (Before that arm existed, a small
in-cap order could be modified straight past the node's one order-size ceiling.)
Nothing an MCP tool does can arm a venue, either: a venue with no credentials mounts as paper, and
the per-venue ceiling policy.venues.<venue> defaults to paper and can only ever refuse — it
lowers what credentials would otherwise allow and can never raise it. That fold is
vike_config::VenueMode::cap (crates/vike-config/src/venue_mode.rs), a minimum and never a
maximum. The Connect an agent guide states that half of the
model.
Outcomes
A confirmed write reports its own command's fate — the server waits for the acknowledgement of
the exact command it sent, so an answer can never describe some earlier command. It waits two
seconds — ACK_WAIT in crates/vike-cli/src/cmd/mcp.rs — which is a deadline and not a sleep: the
answer returns the instant the node's reply lands.
| What happened | Shape | What to do |
|---|---|---|
| Node accepted | result with sent: true, outcome: "accepted", client_order_id | call node_snapshot to watch the result. An empty client_order_id means an account-wide verb |
| Node refused | isError — node rejected the command: <reason> | read the reason; it is the node's, usually a rate or notional refusal |
| No answer in 2s | result with sent: true, outcome: "unknown" | may still execute. Call node_snapshot before retrying |
| Connection dropped | isError naming the outcome as unknown | may have executed. Call node_snapshot before retrying |
The dropped connection is an error string, not a structured unknown
The two ambiguous cases arrive in two different shapes. A timeout is a successful tool result
carrying outcome: "unknown"; a dropped control connection is an isError result whose text says
the outcome is unknown and that it may have executed. An agent that only inspects outcome will
see the first and miss the second — treat both as "check the node before retrying".
Client order ids
The node refuses a remote submit with an empty client_order_id — proven against a running node by
submit_with_empty_coid_is_rejected in crates/vike-tradehub/tests/control_roundtrip.rs. So when
the agent omits the argument this server mints one, off the same generator and wire form the live
core uses, before the preview renders: fill_client_order_id
(crates/vike-cli/src/cmd/verbs.rs), the one mint on this side of the wire. The preview therefore
shows a real id.
The id the preview showed is the id that gets sent, and you do not have to pass it back. A
confirming call does mint a fresh one while rebuilding the command — fill_client_order_id mints
on every call — but that rebuilt command is only ever compared, never executed: same_intent
excludes the id precisely so a confirm can match, and the server then sends the stored command.
So the id the node dry-ran and the preview displayed is the one that reaches the venue. The
client_order_id in the accepted response is still the authoritative one to hold on to.
Sending the same id twice is not a "did it land" test, and not free: the node acknowledges each
send, and the core registry is coid-keyed, so the snapshot shows exactly one order
(duplicate_coid_is_idempotent, in that same test file — it counts snapshot rows, not venue
orders). The second submit still reaches the client, so on a paper mount two orders rest.
submit_order
Submit an order. Requires --node and the control key.
| Parameter | Type | Required | Notes |
|---|---|---|---|
venue | string | yes | Venue id |
symbol | string | yes | Instrument symbol |
side | integer | yes | 1 buy, -1 sell |
qty | number | yes | Order quantity |
order_type | string | no | market (default), limit, stop, take_profit |
price | number | no | Limit price |
trigger_price | number | no | Stop / take-profit trigger |
reduce_only | boolean | no | Defaults to false |
client_order_id | string | no | Accepted by the builder though not advertised in the tool's schema; minted when absent |
reason | string | no | Recorded in the node's audit trail |
confirm | boolean | no | Must be the JSON boolean true and be accompanied by preview_token — either one missing returns a preview |
preview_token | string | no | The token this tool's preview returned. Fires once, expires after 60s, bound to the command it previewed |
A missing required field is a tool error before anything is built.
Preview a limit buy of 0.5 BTCUSDT at 60,000 on binance.
cancel_order
Cancel one resting order by client order id.
| Parameter | Type | Required | Notes |
|---|---|---|---|
client_order_id | string | yes | The id to cancel |
reason | string | no | Recorded in the node's audit trail |
confirm | boolean | no | Must be the JSON boolean true and be accompanied by preview_token — either one missing returns a preview |
preview_token | string | no | The token this tool's preview returned. Fires once, expires after 60s, bound to the command it previewed |
Cancel order a1b2c3d4-7 — it is stale after the feed gap.
modify
Change one resting order's quantity and/or price. At least one of new_qty / new_price is
required; a modify that changes nothing is an authoring mistake, and the tool says so.
| Parameter | Type | Required | Notes |
|---|---|---|---|
client_order_id | string | yes | The order to modify |
new_qty | number | no | New quantity |
new_price | number | no | New price |
reason | string | no | Recorded in the node's audit trail |
confirm | boolean | no | Must be the JSON boolean true and be accompanied by preview_token — either one missing returns a preview |
preview_token | string | no | The token this tool's preview returned. Fires once, expires after 60s, bound to the command it previewed |
Name a price whenever you name a new quantity — on a node with a notional ceiling, a qty-naming modify with no price is refused, as described above.
Move my resting bid to 59,800 and cut the size to 0.2.
flatten
Close one (venue, symbol) net position with a reduce-only market order.
| Parameter | Type | Required | Notes |
|---|---|---|---|
venue | string | yes | Venue id |
symbol | string | yes | Instrument symbol |
reason | string | no | Recorded in the node's audit trail |
confirm | boolean | no | Must be the JSON boolean true and be accompanied by preview_token — either one missing returns a preview |
preview_token | string | no | The token this tool's preview returned. Fires once, expires after 60s, bound to the command it previewed |
Flatten the BTCUSDT position on binance.
mass_cancel
Cancel every live order, optionally scoped. Omit venue for every venue and symbol for every
symbol — omitting both is the whole account, and the preview shows exactly that (both fields
null in the wire command).
| Parameter | Type | Required | Notes |
|---|---|---|---|
venue | string | no | Omit for every venue |
symbol | string | no | Omit for every symbol |
reason | string | no | Recorded in the node's audit trail |
confirm | boolean | no | Must be the JSON boolean true and be accompanied by preview_token — either one missing returns a preview |
preview_token | string | no | The token this tool's preview returned. Fires once, expires after 60s, bound to the command it previewed |
Cancel all resting orders on okx.
set_trading_state
Set the account trading state — the kill switch. It is not a venue arming control and cannot
move a venue off the paper simulator: that is policy.venues.<venue> under [venues] in
<project>/settings/policy.toml, a file a human edits on the box and that nothing on this server
can change. The tool's own description carries that sentence, because this is the one name on the
roster that reads like "go live" — set_trading_state has
the argument, and Arm one venue is where arming is actually done.
| Parameter | Type | Required | Notes |
|---|---|---|---|
state | string | yes | active, reducing or halted; anything else is a tool error naming the three |
reason | string | no | Recorded in the node's audit trail |
confirm | boolean | no | Must be the JSON boolean true and be accompanied by preview_token — either one missing returns a preview |
preview_token | string | no | The token this tool's preview returned. Fires once, expires after 60s, bound to the command it previewed |
Halt trading — I want to look at the last hour before anything else goes out.
market_exit
Cancel every live order and then flatten every one-way position, optionally scoped to one venue —
hedge-mode LONG/SHORT legs are skipped.
| Parameter | Type | Required | Notes |
|---|---|---|---|
venue | string | no | Omit for every engine |
reason | string | no | Recorded in the node's audit trail |
confirm | boolean | no | Must be the JSON boolean true and be accompanied by preview_token — either one missing returns a preview |
preview_token | string | no | The token this tool's preview returned. Fires once, expires after 60s, bound to the command it previewed |
This is the panic button
market_exit is preview-gated like every other write tool, and its own description calls it the
panic button. Treat it as the incident tool it is: it is the one verb that empties the book and the
one-way positions in a single command.
Market exit everything on binance, right now — reason: feed outage.
Backtesting
5 tools over the datahub — backtest, parameter sweep and anchored walk-forward from a profile TOML, plus the strategy roster and the stored-series inventory.
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.