submit_order
Place one order on a running vike-tradehub node — preview first, execute only on a second call carrying both confirm true and the single-use preview_token that preview returned.
submit_order is the write tool that opens risk. Everything else in the roster cancels, reduces
or reports, with one exception — modify can raise a resting order's
quantity, which is why the node sizes it against the same notional ceiling; this one puts a
brand-new order on a venue. It is annotated destructiveHint: true and idempotentHint: false,
and it needs vike-cli mcp --node <host:port> plus VIKE_TRADEHUB_CONTROL_KEY.
Arguments
| 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 | Read by the builder, though the advertised schema does not list it |
reason | string | no | Rationale for 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 raised in verbs::verb_from_tool_args before any command
exists — submit_order_missing_required_field_is_a_tool_error pins it with a call that omits
symbol. The order kinds and what each one means have their own
pages.
Two calls, and the second one carries two things
Call one previews. Server::call_tool builds the wire command, mints a client order id, asks
the node for its own dry-run, and returns preview_of(...) — will_execute: false, the fully
resolved wire_command, the advisory guardrail, the node_verdict and whether it was obtained,
the reason that would be recorded, a note, and the preview_token that confirms this exact
command. With no node configured it opens no socket at all:
submit_order_without_confirm_only_previews_and_touches_no_network proves a preview succeeds in
that state, and checks the guardrail's notional is 50.0 for a limit buy of 0.5 at 100.0.
Call two is the one that sends — and it needs both confirm: true and that
preview_token:
{ "venue": "binance", "symbol": "BTCUSDT", "side": 1, "qty": 0.001,
"confirm": true, "preview_token": "pv-1" }The token fires once, expires after 60 seconds and is bound to the command it previewed; Live node sets out all four rules and why the token is deliberately not a secret.
confirm: true without a token is a preview, not an error
Both conditions are read together — a token that arrives without confirm: true, and a confirm
that arrives without a token, are the same case: the call returns a fresh preview,
will_execute: false, a new token, and nothing on the wire.
confirm_alone_no_longer_executes_it_returns_a_preview pins exactly that. So an agent that treats
its second call as proof it traded, without reading will_execute, believes it holds an order it
never placed. confirm is also read as a JSON boolean and nothing else, so "true", 1 and
"yes" fall through the same way.
A bad token is the loud case: a tool error carrying the reason. There are three messages, not
four — the store cannot tell an unknown token from an already-used one, because take removed
the used one, so both come back as unknown or already used. Read that one as possibly-already-sent
and settle it with node_snapshot rather than re-confirming. Expired and mismatched each have
their own message. Confirming with a valid token and no node reaches the connection and fails
there — a clean error, not a panic, and the fact that it got that far is what
a_preview_token_fires_at_most_once uses to prove the token passed the gate.
The client order id
The node refuses a remote submit whose client_order_id is empty. lower_command says why: an
empty one would let the runtime mint, which is fine in-process, but a remote peer then has no
stable handle to cancel or dedup by. So verbs::fill_client_order_id mints one before the
preview branch, and the preview shows a real id.
The mint is verbs::coid_minter — the live core's own generator and wire form,
<8-hex-session><seq>. Its doc argues four properties: unique per order (the sequence
increments), no collision across sessions (the prefix is drawn from OS entropy per process),
typeable at 9–11 characters, and venue-safe against ^[A-Za-z0-9]{1,32}$, the strictest common
denominator across the big crypto venues.
The id in the preview is the id that gets sent. A confirming call does mint a fresh one while
rebuilding the command — the mint runs on every call — but the rebuilt command is only ever
compared: same_intent excludes the client_order_id for exactly this reason, and the server then
executes the stored command. That is a stronger property than comparing the ids would have
been, because what reaches the venue is byte-for-byte what the preview displayed and the node
dry-ran. The client_order_id in the accepted response is still the authoritative one to keep.
(Passing the previewed id back explicitly is harmless and changes nothing — same_intent ignores
the field either way.)
A supplied id is not charset-checked on this surface
The human trade REPL refuses a --coid outside that charset. This tool does not:
verbs::verb_from_tool_args passes an agent-supplied client_order_id through unvalidated, and
the node's lower_command requires only that it be non-empty. Re-sending the same id proves
nothing and is not free: the core registry is coid-keyed, so a repeat leaves one row — but
gate_and_register inserts over it rather than rejecting the duplicate, and submit_order calls
client.submit both times, so on the default paper mount two orders rest.
What checks the size
The preview's guardrail is advisory and computed on your machine
(verbs::guardrail_check). max_qty comes from VIKE_MAX_ORDER_QTY, a local typo-catcher with no
enforcing counterpart anywhere; 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 is server-side. ControlLimits::vet consumes a rate token on every command and then
applies the notional ceiling to a submit that carries a price — and to a modify naming both a new
qty and a new price — against the node's own max_notional_per_order, which is unset by
default: notional_reason opens on that Option, so a node whose policy.toml names no ceiling
skips this half entirely. Notional there is a magnitude — the check takes .abs() of every factor,
because a site that absed the quantity only let a negative price produce a negative notional that
could never trip the ceiling. Beneath it sits the core RiskGate. Neither is reachable from an
agent, and the ceiling is a settings key: see
policy.toml and
Risk and arming.
Arming is upstream of all of it. A venue with no credentials mounts as paper, and
policy.venues.<venue> defaults to paper and can only ever refuse.
Outcomes
Server::execute awaits the ticket that send returned and waits ACK_WAIT, two seconds — a
deadline, not a sleep. Accepted returns sent: true, outcome: "accepted" and the authoritative
client_order_id. A refusal is an error result carrying the node's reason. A timeout returns
outcome: "unknown"; a dropped control connection is an error result saying the outcome is unknown
and it may have executed. For both ambiguous cases, call
node_snapshot before retrying.
Then watch it: order lifecycle describes the states the order
now moves through, and cancel_order,
modify and flatten are what act on it.
Preview a limit buy of 0.5 BTCUSDT at 60,000 on binance, then place it if the guardrail is clean.
run_walk_forward
Walk a profile forward over its anchored out-of-sample windows on the datahub and get the stitched OOS report back.
cancel_order
Cancel one resting order by client order id on a running vike-tradehub node — previewed by default, sent only on a second call carrying both confirm and the preview_token.