Skip to main content
An autonomous market maker manages resting quotes and open exposure continuously. Monaco exposes two planes for that job, and a safe market maker uses both:
  • Command / snapshot plane — the MCP server. Placing, replacing, and cancelling orders, plus one-shot reads (get_orders, get_positions, get_balances, get_orderbook, market metadata). Every read is a point-in-time snapshot taken when you call it.
  • State plane — the Core SDK WebSocket. Live streams for orders, orderbook, trades, balances, and movements. This is how you learn about fills, cancels, position and risk changes, and book movement between commands.
The MCP server has no streaming or subscription tools. If you drive a market maker off intermittent MCP snapshots alone, you will miss fills, cancels, and book movement that happen between calls — leaving stale quotes and one-sided exposure. Always pair MCP commands with the Core SDK WebSocket state plane described here.

The two planes

The rule of thumb: command the exchange through MCP, but track its state through the WebSocket. Never poll MCP reads in a tight loop to discover fills — subscribe instead. Authenticated reads draw a per-account read budget (30 requests/s sustained, burst 100 by default), so a polling loop is metered as well as slow; stream events cost no read budget.

Before you start

  • Authenticate with a Monaco session key. Monaco is tokenless: your wallet authorizes an ed25519 session keypair, and every request is signed with the session key. See Wallets & Auth.
  • For the MCP server, run authenticated mode with MONACO_CAPABILITY=market-maker so order placement, replacement, and cancellation tools are registered. See MCP Server.
  • Start on staging (testnet). Fund a fresh wallet through the faucet.
  • Resolve the trading pair ID and market constraints (tick size, order-size limits) up front — see Markets and Orderbooks & Trades.
  • Know your order budgets. Creation and cancels are metered per account in order items — a batch of N costs N, cancels draw a larger budget of their own, and sub-accounts share a family budget with their master — and every gated response returns x-ratelimit-* headers to pace on (readable from a raw HTTP or gRPC client; the TypeScript SDK surfaces only retryAfter on a 429). Quote with batch create and replace, cancel with batch cancel, and back off for retryAfter on a 429. Reads have a separate per-account budget with no headers, another reason to track state over the WebSocket. See Rate Limits.
The runnable examples below use the TypeScript SDK for both planes, because it is directly executable. Each command step notes the equivalent MCP tool an assistant would call.

1. Bootstrap

Authenticate, resolve the market, and take an initial snapshot of your open orders, positions, and balances. This snapshot is your starting truth; the state plane keeps it current from here.

2. Subscribe to the state plane

Construct a WebSocket client with createMonacoWebSocket and subscribe to the channels a market maker needs. Construct it directly rather than using the built-in sdk.ws: the SDK’s default client does not forward the onResync and onStatusChange callbacks you need for safe recovery (see step 4). Pull the session credentials from the auth state returned by login(), and resolve the WebSocket URL from the network preset:
Each subscription method returns an unsubscribe function. ws.orders and ws.userOrders (all pairs), ws.balances, ws.movements, ws.positions, and ws.liquidations require authentication; ws.orderbook and ws.trades are public. ws.positions and ws.liquidations take an optional trailing tradingPairId to filter to one pair. Full event shapes are documented in SDK WebSockets. magnitude (price grouping) must be one of 0.0001, 0.001, 0.01, 0.1, 1, 10, 100, 1000, 10000, and quotationMode is "BASE" or "QUOTE".

3. Quote lifecycle

Send orders through the command plane; learn their outcome through the state plane.
If you quote postOnly: true, the state plane confirms it: data.postOnly is true on that order’s OrderPlaced acknowledgement and stays on its maker fills and on its OrderPartiallyCancelled, OrderCancelled and OrderExpired, so a matching event saves you a get_order round trip. The maker fill is the one that matters: a resting post-only quote only ever fills as a maker, so those are the frames a post-only quote actually produces. Still record the flag as order-level state when the acknowledgement arrives and carry it forward rather than re-deriving it per event — two frames legitimately omit it. The OrderCancelled that reports the remainder of an IOC or market order after a partial fill is a different payload that carries no postOnly at all (a post-only quote can never reach it, since post-only is refused on IOC/FOK and MARKET), and a server predating maker-fill support omits the key on maker fills. Against those, the acknowledgement’s value stays the authority; everywhere else data.postOnly ?? false agrees with get_order. A post-only quote that would have crossed is refused at admission, and that refusal reaches you as an error on the command plane — that is the authoritative signal. A missing acknowledgement is not itself evidence of rejection: silence proves nothing here for the same reason it proves nothing for clientOrderId below, so reconcile against the command result rather than inferring refusal from an absent frame. Do not call get_orders on a timer to discover whether bid filled. The ws.orders handler from step 2 delivers OrderFilled / OrderPartiallyFilled / OrderCancelled as they happen, and ws.balances reports the resulting balance changes. ws.movements is not part of that loop: it carries deposits, withdrawals, funding settlements and the unlock a cancelled or expired order releases, never fills or fees — read a fill’s ledger entries from movement history instead. Reprice off ws.orderbook and ws.trades, and re-evaluate exposure from the live order and balance streams — not from repeated snapshots. For perps, position and risk-bucket changes follow from fills. Track them live off the ws.positions stream from step 2 — each position_update carries the current size, mark price, unrealized PnL, and liquidation price — and treat ws.liquidations alerts as a hard risk signal. Read current risk with a one-shot sdk.positions.getPositionRisk(positionId) (MCP get_position_risk) when you need a full risk breakdown, but drive quote adjustments off the live streams so your view never lags the exchange.

Self-trade prevention

Your own taker flow can cross your own quotes — a hedge leg into your resting bid, or two subaccounts quoting one book. The incoming order’s self-trade-prevention mode decides what happens. Four modes exist (CANCEL_MAKER, CANCEL_TAKER, CANCEL_BOTH, SKIP), and the platform default is CANCEL_MAKER: your resting quote is swept — cancelled, not filled — and your incoming order keeps matching through the level. A maker that wants its quotes to survive its own flow opts the wallet into SKIP (trade around your own orders; they are never touched) or CANCEL_TAKER (give up the incoming remainder instead):
A delegated-agent session cannot change the default (403) — set it from the wallet’s own session. A per-order selfTradePreventionMode on any create, replace, or batch item overrides the wallet default for that order; a replacement is a new order, so restate the field to keep it. STP cancellations reach the state plane as ordinary OrderCancelled events with terminalReason: "SELF_TRADE_PREVENTION" and stpCounterpartyOrderId naming the order on the other side. Handle them in the same terminal path as any other cancel; under CANCEL_MAKER, one means your own flow swept your quote — re-quote, and if it recurs, change the mode rather than fighting it. Two cases where your quote is not swept, both easy to misread from the taker’s side. A postOnly order that would cross is refused at admission by an ownership-blind check that runs before self-trade prevention, so it sweeps nothing. And a zero-fill MARKET taker whose only in-band depth was your own quote is rejected for insufficient liquidity before the sequence is allocated — the cancellation was only simulated, so your quote is still resting and still exposed. In both cases a rejected taker is not evidence that your quote was pulled: reconcile against the orders channel rather than inferring it. Mode-by-mode outcomes, FOK edges, and the liquidation carve-out: Self-Trade Prevention.

4. Reconnect and resync

The WebSocket auto-reconnects with full-jitter exponential backoff (base 1s, capped at 30s) and unlimited retries by default. There is no wire sequence number on the streams, so after any reconnect you must assume you missed events and rebuild from a fresh snapshot. onResync fires after every automatic reconnect, after the client sends authentication/subscription requests. It does not wait for acknowledgements or snapshots, and REST recovery remains application-owned. Refetch your snapshots and reconcile them against your local view.

Reconcile on (id, version)

Every order event and every order REST row carries version: the sequencer step that wrote that state. It is the same counter on both, so a snapshot row and a live event are directly comparable, and you never have to pause the stream to rebuild. Apply events as they arrive. When the snapshot lands, merge per order:
The rule is >=, not > — and that is not a rounding choice.Equal versions do not mean identical state on the stream. One sequencer step emits several events for one order: placing a marketable order emits OrderPlaced (status SUBMITTED) and then its fills, all under the same step. A batch place or replace goes further and shares one step across every item in the batch.A REST row, by contrast, is that step’s final state. So when the versions tie, the snapshot is the more complete view and must win. Using > instead would let an OrderPlaced you happened to apply outrank the snapshot that says the same order already filled.
Nothing here needs buffering, so you keep quoting through a reconnect. That is the point: the snapshot merges into a live view instead of replacing it.

Catch what went terminal during the gap

An order that filled or cancelled while the socket was down is the dangerous case: its event was never delivered, and a snapshot filtered to resting statuses does not contain it either. Nothing corrects your local view, so you keep quoting against a position you no longer have — or keep believing in a quote that already filled. Widening the order query does not reliably fix this. getPaginatedOrders has no time filter and is ordered by createdAt descending, so a quote you placed hours ago that filled ten seconds ago sorts by its old creation time — far down the pages, not near the top. Paginating until you find it means walking your whole order history on every reconnect. Two bounded checks cover it instead: 1. Fills — read the trade tape. sdk.profile.getUserTrades is ordered by execution time, newest first, with cursor pagination. A fill from the gap is at the top of it whatever the order’s age, so you page back only as far as the disconnect.
User trade rows do not carry an order ID. Merge the recovered executions into your trade history by tradeId; the missing-resting-order pass below refreshes the authoritative order state, including orders that filled during the gap. 2. Cancels and expiries — resolve snapshot absence. A locally resting order missing from the persisted snapshot may have left the book, or persistence may lag a newer local event. Absence alone is not a tombstone. Retain the row and resolve it with a targeted getOrder(id), applying the same version rule rather than deleting it or scanning history:
Both are bounded by how much actually happened, not by how long you have been trading. Reconcile positions and balances alongside them, and treat every resync as authoritative: re-derive your intended quotes from the reconciled state rather than assuming your pre-disconnect orders are still resting.
An order rejected before acceptance is never written to history, so it has no row to fetch and no trade to find. It also never rested, so it cannot be in local resting state — nothing to reconcile. A rejection after acceptance is persisted with status REJECTED and is covered by the absence check above.

When version is absent: buffer and replay

An order last written before version existed reports none, and takeSnapshot above deliberately refuses to guess for those — it collects them in needsReplay rather than ranking them. If you need to reconcile those orders — pre-feature rows, or a consumer running against an older deployment — fall back to ordering by arrival instead of by comparison:
  1. Start buffering order events before the fetch begins (the handler is installed at subscribe time, so it is already live).
  2. Fetch the snapshot.
  3. Replace local state for those orders from the snapshot.
  4. Replay the buffered events in arrival order.
  5. Resume applying live events directly.
Order events carry absolute state — filledQuantity is cumulative — so replaying an event the snapshot already reflects rewrites the same value rather than double-counting it. Two costs come with it, and they are why this is the fallback rather than the default: local state is meaningless mid-replay (an event that predates the snapshot moves an order backwards until the rest of its buffer drains, so you must hold quoting until it finishes), and the buffer has to be switched on before the fetch starts or it misses precisely the window it exists to cover.

Comparing two REST reads

The same counter answers a narrower question directly: whether a re-poll actually advanced, or which of getOrder and a list row is fresher. Highest wins, and here equal really does mean identical state — a REST row is only ever a step’s final state, so two equal REST reads describe the same thing. Do not compare updatedAt for this. Order detail (getOrder) and order lists (getPaginatedOrders) report updatedAt from two different clocks — the matching-engine event time and the database commit time — so the two endpoints disagree about the same order by a few hundred milliseconds, the gap widens under write lag rather than closing, and the commit clock can move without any state change. Timestamp precision also varies per call. version exists precisely because updatedAt cannot answer this question. Two rules for version everywhere it appears:
  • Absent means unknown, not zero: an order last written before the field existed reports no version. Fall back to a full field comparison for those rows.
  • Gaps are expected. It is drawn from one global sequence shared by every order, so an order’s successive versions are sparse (918342 → 918511 is normal), consecutive values are never implied, and values from two different orders are not comparable to each other — a batch’s orders all share one value. It is a comparator, not a counter.

5. Mutation safety

Single and batch order creation are replay-safe only when you retain each explicit idempotencyKey and reuse it with the same normalized payload within 24 hours of acceptance. Generate and persist keys before the first send; a new key is a new submission, and an expired key may place a new order. Cancellation converges safely. Replacement is not protected by an idempotency key, although a retry cannot place twice because it names an original order that the sequencer consumes only once. For a keyed create or batch item, replay the exact payload with the same key within the window. Never blind-retry an unkeyed or expired create, or an ambiguous replace. First reconcile. Every surface below is positive-only: it can prove the intent landed, never that it did not. Ranked strongest first:
  1. clientOrderId on the order stream — set it on every create and replace (CancelOrderRequest takes only orderId, so you cannot set a handle on a cancel — but the resulting OrderCancelled event still carries the order’s stored clientOrderId, so cancellation is usable reconciliation evidence). It is echoed on every order WebSocket event, including on orders that never rested, which makes the stream the strongest surface you have. A matching event proves the order was accepted. Silence proves nothing and no condition you can observe changes that: the stream is not replayable and its fan-out is non-persistent, so the service can re-establish an ended upstream subscription without closing your socket. Two further limits: uniqueness is enforced only among your resting orders, so a 409 CLIENT_ORDER_ID_CONFLICT on resubmission proves the first attempt landed and is still resting — its absence proves nothing; and because the value frees on terminal and may be reused, a lookup can match several historical orders.
  2. Exchange orderId — for replace_order you already hold it, so get_order can confirm a positive outcome: terminalReason: "REPLACED" on the original means your replacement landed and you need its new id. A still-resting original is inconclusive, not proof the replace failed — order persistence is asynchronous, so that row can predate a replacement the engine has already applied. Note also that a replace retry cannot double your position: it names the original id, which the sequencer revalidates, so a second attempt is rejected rather than placed. The risk here is acting on stale state, not duplicate size.
  3. strategyKey — also echoed on get_orders rows at the wire layer, so you can match client-side by it (the order-list endpoint does not server-filter by strategyKey). Note the ergonomic TypeScript SDK Order type does not surface strategyKey, so read it from the raw wire row rather than order.strategyKey.
  4. Attributes + a narrow time window — as a last resort, match by pair, side, price, and quantity among orders submitted in the last few seconds. This is the weakest key: identical resting quotes can collide, so treat a match as provisional.
Include already-filled orders in the check, and confirm against live ws.orders before deciding. For submissions without usable idempotency protection, every surface is positive-only, so “reconciliation proves the intent did not land” is a bar you frequently cannot clear — when you cannot, hold and escalate rather than resubmitting. A blind retry on an unkeyed or expired create can double your intended size and leave you over-exposed; the missed quote you get by holding is recoverable, the doubled position may not be.
Do not reconcile a taking order by polling the order list alone. Order history is written asynchronously and served from a read replica, so an order that did execute can read as absent — most likely right after a timeout, since a timeout usually means the system is busy. An order rejected before acceptance is never written to history at all; one rejected after acceptance is persisted with status REJECTED. Use the order stream, which is pushed from the matching engine: sdk.ws.userOrders(handler) covers every pair, sdk.ws.orders(pairId, mode, handler) one market.
MCP and the TypeScript SDK generate create keys when omitted, but each new call generates a new key. Supply and retain an explicit key when recovery must cross calls or process restarts. If the original key is unavailable or expired, follow the same no-blind-retry contract on every surface.

6. Emergency stop

Keep a kill switch that flattens everything in two commands: cancel all resting orders, then close all open positions. These two commands are not atomic: a resting order can fill in the window between cancelling and closing, and a close can be partially filled. Treat the kill switch as a loop, not a one-shot — keep consuming ws.orders and ws.movements, re-check open orders and positions, and repeat until your flatness criteria hold.
Wire this to your risk limits (max drawdown, connectivity loss you cannot recover, or a manual halt). After an emergency stop, confirm the result from the state plane — the ws.orders cancellations and ws.movements / position updates — before deciding the maker is flat.

Putting it together

The loop is: bootstrap a snapshot → subscribe to the state plane → quote through the command plane while consuming fills and book movement from the state plane → resync from a fresh snapshot on every reconnect → emergency stop when risk limits trip. Commands go out through MCP (or the SDK); truth comes back through the WebSocket. When you are done, unsubscribe and disconnect:

MCP Server

The command/snapshot plane: tools, capability profiles, and setup.

SDK WebSockets

The state plane: every channel, event shape, and the resync contract.

Order Management

Spot and perps order entry, batching, and conditional orders.

Positions

Perps positions, margin accounts, and risk buckets.

Rate Limits

Order and movement budgets, budget headers, and how to back off.