vike

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:

FieldWhat it holds
will_executealways false on this path
toolthe tool name being previewed
wire_commandthe fully resolved command, exactly as it would go on the wire
guardrailthe client-side advisory check (below)
node_verdictthe node's own dry-run, or null when there was no node address or no control key to ask with
verified_by_nodewhether the node was asked
preview_tokenthe token that confirms this exact command
reasonthe rationale that would be recorded — trimmed, null when blank or absent
notewhat 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::take removes the entry before the command is executed, so a duplicated call finds nothing and gets unknown or already used. Pinned by a_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_intent compares the whole wire command and ignores exactly one field, the freshly minted client_order_id. Confirming a different command with a live token is refused — and consumes it. Pinned by a_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 mcp run 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:

GateWhereReach
Rate limitervet, at the node's control edgea token is consumed on every command, per control connection; the rate comes from VIKE_TRADEHUB_CONTROL_RATE
Notional capvet's notional half, same edgeonly 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 gateRiskGate, 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 happenedShapeWhat to do
Node acceptedresult with sent: true, outcome: "accepted", client_order_idcall node_snapshot to watch the result. An empty client_order_id means an account-wide verb
Node refusedisError — node rejected the command: <reason>read the reason; it is the node's, usually a rate or notional refusal
No answer in 2sresult with sent: true, outcome: "unknown"may still execute. Call node_snapshot before retrying
Connection droppedisError naming the outcome as unknownmay 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.

ParameterTypeRequiredNotes
venuestringyesVenue id
symbolstringyesInstrument symbol
sideintegeryes1 buy, -1 sell
qtynumberyesOrder quantity
order_typestringnomarket (default), limit, stop, take_profit
pricenumbernoLimit price
trigger_pricenumbernoStop / take-profit trigger
reduce_onlybooleannoDefaults to false
client_order_idstringnoAccepted by the builder though not advertised in the tool's schema; minted when absent
reasonstringnoRecorded in the node's audit trail
confirmbooleannoMust be the JSON boolean true and be accompanied by preview_token — either one missing returns a preview
preview_tokenstringnoThe 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.

ParameterTypeRequiredNotes
client_order_idstringyesThe id to cancel
reasonstringnoRecorded in the node's audit trail
confirmbooleannoMust be the JSON boolean true and be accompanied by preview_token — either one missing returns a preview
preview_tokenstringnoThe 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.

ParameterTypeRequiredNotes
client_order_idstringyesThe order to modify
new_qtynumbernoNew quantity
new_pricenumbernoNew price
reasonstringnoRecorded in the node's audit trail
confirmbooleannoMust be the JSON boolean true and be accompanied by preview_token — either one missing returns a preview
preview_tokenstringnoThe 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.

ParameterTypeRequiredNotes
venuestringyesVenue id
symbolstringyesInstrument symbol
reasonstringnoRecorded in the node's audit trail
confirmbooleannoMust be the JSON boolean true and be accompanied by preview_token — either one missing returns a preview
preview_tokenstringnoThe 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).

ParameterTypeRequiredNotes
venuestringnoOmit for every venue
symbolstringnoOmit for every symbol
reasonstringnoRecorded in the node's audit trail
confirmbooleannoMust be the JSON boolean true and be accompanied by preview_token — either one missing returns a preview
preview_tokenstringnoThe 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.

ParameterTypeRequiredNotes
statestringyesactive, reducing or halted; anything else is a tool error naming the three
reasonstringnoRecorded in the node's audit trail
confirmbooleannoMust be the JSON boolean true and be accompanied by preview_token — either one missing returns a preview
preview_tokenstringnoThe 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.

ParameterTypeRequiredNotes
venuestringnoOmit for every engine
reasonstringnoRecorded in the node's audit trail
confirmbooleannoMust be the JSON boolean true and be accompanied by preview_token — either one missing returns a preview
preview_tokenstringnoThe 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.

On this page