Sequence/docs

Hosted Strategies

Deploy a Python program that uses the Sequence SDK and let Sequence run it for you. Sequence handles the container, the credentials, the lifecycle, and the log capture; you write the strategy.

Hosted strategies are intentionally separate from WASM algos:

  • WASM stays the small, deterministic hot-path runtime. Sub-millisecond, microbenchmark-friendly, no syscalls.
  • Hosted strategies get normal process flexibility: external APIs, dynamic market selection, SDK calls, background tasks, richer dependencies.

Use a hosted strategy when you want to write code that thinks — discovers markets, talks to other services, makes high-level decisions. Use a WASM algo when you want code that reacts — sub-ms order placement on every tick.

bash
sequence strategy deploy .              # reads ./Sequence.toml
sequence strategy logs my-strategy --follow
sequence strategy stop my-strategy
sequence strategy delete my-strategy

CLI version requirement. The sequence strategy * subcommands ship in CLI 0.4.0+. If your sequence binary is older (sequence --version shows 0.3.x or earlier), you'll get error: unrecognized subcommand 'strategy'. Update first:

bash
sequence update
sequence --version    # should be 0.4.0+

If you can't update the CLI for any reason, every sequence strategy * command has a direct REST equivalent — see the Endpoints + response shape section below for the curl patterns that bypass the CLI entirely.


Quick start

The minimal hosted strategy is two files — your code and a Sequence.toml that tells the CLI how to deploy it. The SDK auto-vendors at deploy time, so you don't have to copy or pip install anything.

text
my-strategy/
├── Sequence.toml
└── main.py

Sequence.toml:

toml
[strategy]
name = "btc-dip-buyer"
runtime = "python"
 
[hosted]
entrypoint = "main.py"
 
[hosted.placement]
latency_target = ["coinbase"]
 
[hosted.resources]
cpu_millis = 250
memory_mb = 512
timeout_secs = 3600           # 0 or absent = no timeout

main.py:

python
import os, time
from sequence_markets import Sequence
 
seq = Sequence(os.environ["SEQUENCE_API_KEY"])
 
while True:
    q = seq.quote("BTC-USD")
    if q["nbbo"]["mid"] < 60_000:
        seq.buy("BTC-USD", 0.001, urgency="medium")
        break
    time.sleep(30)

Deploy:

bash
cd my-strategy
sequence strategy deploy . --start
sequence strategy logs btc-dip-buyer --follow

That's it. The CLI reads Sequence.toml, embeds the SDK into the artifact, uploads, and the runner picks up the strategy within a second or two. CLI flags still work for one-off overrides (--memory-mb 1024 would override the toml value for this single deploy).

Endpoints + response shape

Behind the CLI, the wire calls are:

VerbEndpointPurpose
POST/v1/strategiesCreate + upload (this is the hosted Python path)
GET/v1/strategiesList your strategies
GET/v1/strategies/:strategy_idInspect one
POST/v1/strategies/:strategy_id/startRe-queue a stopped strategy
POST/v1/strategies/:strategy_id/stopGraceful stop (SIGTERM via heartbeat)
DELETE/v1/strategies/:strategy_idPurge artifact + row + logs
GET/v1/strategies/:strategy_id/logs?run_id=...Logs (latest-run by default)

Don't confuse /v1/strategies with /v1/deployments. The latter is the WASM algo path — different artifact format (wasm_base64), different runtime, different lifecycle. Hosted Python is /v1/strategies exclusively.

The create response:

json
{
  "strategy": {
    "strategy_id": "5dfbea6f-3698-4cf4-b493-3a48a69797d4",
    "name": "btc-dip-buyer",
    "runtime": "python",
    "entrypoint": "main.py",
    "artifact_sha256": "8d4c06fd9322df3e280847c8a34dc884e31a6c900f86ec94189d7758bc8233fb",
    "size_bytes": 20334,
    "desired_state": "running",
    "status": "queued",
    "is_sandbox": false,
    "created_at": 1777414125,
    "updated_at": 1777414125
  }
}

Store strategy_id — every subsequent lifecycle call is keyed on it. There is no separate "bundle ID"; the artifact is bundled into the strategy row at create time. artifact_sha256 is useful as a "which version is running" hash but changes on every redeploy.


What you get

When Sequence claims your strategy, the runner injects this environment into your process:

VariableSource
SEQUENCE_API_KEYA seq_live_* key, encrypted at rest in CC, decrypted just before injection. Use this in Sequence(api_key, ...).
SEQUENCE_API_URLThe CC URL the strategy should hit. Loopback in standard prod deployments (CC and runner share the host).
SEQUENCE_STRATEGY_IDUUID of the strategy row — useful for trace correlation.
SEQUENCE_RUN_IDUUID of the current run (changes on restart).
SEQUENCE_CLIENT_IDYour client short code.
SEQUENCE_IS_SANDBOX"true" for seq_test_* keys, "false" for live.

Passing your own config (DB DSNs, webhook URLs, market lists, …)

There are three slots for strategy-specific configuration, each with a different at-rest posture and a different ergonomic shape:

SlotAt-restVisible in GET /v1/strategies/:idBest for
[hosted.env]plain JSONByesnon-secret env vars (URLs, log levels, thresholds)
[hosted.secrets]AEAD-sealed (secrets_ciphertext + secrets_nonce)no — never returnedtokens, webhook URLs containing credentials
manifestplain JSONByesstructured config (lists of markets, nested objects) — not flat strings

The first two land as real environment variables in your Python process. The manifest lands as a JSON file that the runner writes to /workspace/.sequence/manifest.json and points at via SEQUENCE_STRATEGY_MANIFEST. Pick whichever shape matches the data: flat strings are env vars; arrays and nested objects belong in the manifest.

Sequence.toml (recommended)

toml
[strategy]
name = "pred-arb-walker"
runtime = "python"
 
[hosted]
entrypoint = "main.py"
 
[hosted.env]
LOG_LEVEL          = "info"
MIN_NOTIONAL_USD   = "5.0"
GCP_CALLBACK_URL   = "https://your-cf-function.cloudfunctions.net/..."
 
[hosted.secrets]
GCP_CALLBACK_TOKEN = "bearer-..."
SLACK_WEBHOOK      = "https://hooks.slack.com/services/..."

Then:

bash
sequence strategy deploy . --start

The CLI also accepts ad-hoc overrides if you don't want to commit a value to Sequence.toml:

  • --env KEY=VALUE (repeatable) — layered on top of [hosted.env].
  • --secrets-file path.json — JSON object of secret env vars merged on top of [hosted.secrets]. The file never leaves your machine; the CLI sends the secrets in the create-strategy request body, where the coordinator seals them before persistence.
  • --manifest path.json — overrides the auto-synthesized manifest with your own JSON object (use this when you have nested arrays/objects that don't fit cleanly into env vars).

REST equivalent

json
POST /v1/strategies
{
  "name": "pred-arb-walker",
  "runtime": "python",
  "entrypoint": "main.py",
  "artifact_base64": "...",
  "env": {
    "LOG_LEVEL": "info",
    "GCP_CALLBACK_URL": "https://your-cf-function.cloudfunctions.net/..."
  },
  "secrets": {
    "GCP_CALLBACK_TOKEN": "bearer-...",
    "SLACK_WEBHOOK": "https://hooks.slack.com/services/..."
  },
  "manifest": {
    "PRED_ARB_PAIRS_JSON": [
      {"kalshi": "KX...", "polymarket": "0x...", "max_notional": 10},
      {"kalshi": "KY...", "polymarket": "0x...", "max_notional": 5}
    ]
  },
  "placement": {"latency_target": ["polymarket"]},
  "resources": {"cpu_millis": 500, "memory_mb": 1024, "timeout_secs": 0},
  "start_immediately": true,
  "sequence_api_key": "seq_live_..."
}

Reading the values in your strategy

Plain env and decrypted secrets land as regular environment variables — collisions resolve in favor of secrets, so a [hosted.secrets] entry can shadow a same-named [hosted.env] entry. The manifest is a JSON file you load yourself.

python
import json, os
 
# [hosted.env] + [hosted.secrets] — both are just env vars by the time
# your code reads them. The runner won't let user env shadow the
# SEQUENCE_* invariants (those names are reserved).
log_level   = os.environ["LOG_LEVEL"]
gcp_url     = os.environ["GCP_CALLBACK_URL"]
gcp_token   = os.environ["GCP_CALLBACK_TOKEN"]
slack_hook  = os.environ["SLACK_WEBHOOK"]
 
# manifest — for nested config that doesn't squeeze into a string env var.
manifest = json.load(open(os.environ["SEQUENCE_STRATEGY_MANIFEST"]))
pairs    = manifest["PRED_ARB_PAIRS_JSON"]   # already a list, not a string

What's encrypted at rest, and what isn't. [hosted.secrets] and sequence_api_key are AEAD-sealed under the coordinator's CREDENTIAL_ENCRYPTION_KEY — they live as secrets_ciphertext / sequence_api_key_ciphertext columns and are never returned from the public API. [hosted.env] and manifest are stored as plain JSONB and are visible in GET /v1/strategies/:id. Use the right slot for the sensitivity tier — and for venue API keys, don't put them anywhere here: register them once with sequence connect <venue> and the strategy uses them transitively when it places orders.


Restart semantics

Process exits are read like Unix exit codes:

ExitStatusAuto-restart?
0 (clean completion)stoppedNo — Sequence treats this as "I'm done, don't bring me back."
Non-zero / killed / OOMfailedYes — re-claimed and re-spawned automatically.

If you want a daemon that runs forever, write while True: ... and don't exit. Re-running a stopped strategy is opt-in via sequence strategy start <name> — that flips status back to queued and the next runner picks it up.


Your first hosted strategy: end-to-end prediction-market test

This strategy exercises the entire stack — SDK calls, market discovery, WebSocket streams, order book walk, graph submission, status check, and clean cancel — against both Kalshi and Polymarket. Total spend: under one dollar across both venues, with both orders priced deliberately below the bid so they sit harmlessly and never cross.

It's also a working reference for the things that trip up a first hosted strategy: bundling the SDK, hand-rolling a WebSocket without pip install, and falling back from YES to NO when an outcome's book is one-sided.

Layout

code
my_strategy/
├── Sequence.toml          # config: name, placement, resources
└── main.py                # the entrypoint

That's enough. The CLI auto-vendors the Python SDK into the artifact at deploy time — you don't need a sequence_markets/ directory in your project. (You can commit one if you want to pin a specific SDK version; the CLI skips auto-vendoring when it sees one.)

If you're curious why this matters: the runner spawns your strategy in python:3.12-slim, a minimal image with no pip-installed packages, so the SDK has to be in the artifact tarball. The CLI handles that for you.

main.py

python
"""End-to-end hosted-strategy smoke.
 
  1. Boot + log injected env (verifies runner injection works)
  2. Health check the API
  3. Discover an active Kalshi market via /v1/markets
  4. Discover an active Polymarket market via /v1/markets?expand=outcomes
  5. WS-subscribe to `prices:{symbol}` for each (raw stdlib socket — no
     `websockets` dep needed in the slim runner image)
  6. Walk each book via /v1/quotes (top 5 bids + asks per side)
  7. Submit one limit-buy graph per venue, deliberately priced
     below the market so it rests harmlessly and won't fill
  8. Sleep, then check graph status
  9. Cancel both graphs
 10. Exit 0
"""
 
import base64
import json
import os
import socket
import ssl
import struct
import sys
import time
import urllib.parse
from typing import Any, Dict, Iterator, List, Optional
 
from sequence_markets import Sequence, SequenceError
 
 
def log(event: str, **fields: Any) -> None:
    """One JSON line per logical event. Easy to grep in `strategy logs`."""
    rec = {"ts": round(time.time(), 3), "event": event, **fields}
    print(json.dumps(rec, default=str, sort_keys=False), flush=True)

Two things worth pointing out before the rest of the file:

  • flush=True on every print. Python defaults to block buffering on a pipe, so without explicit flush the runner sees nothing for the whole run and a flood at exit. The runner sets PYTHONUNBUFFERED=1 as a belt-and- braces, but your code shouldn't depend on it.
  • One JSON record per log() call. The runner streams stdout line-by-line into the hosted_strategy_logs table, so structured one-line records make sequence strategy logs --follow | jq pleasant.

Raw-stdlib WebSocket

python:3.12-slim doesn't ship the websockets package. Rather than installing into a read-only container, we hand-roll the handshake plus masked-frame protocol — about 80 lines of stdlib socket + ssl + struct:

python
def ws_subscribe(
    base_url: str,
    api_key: str,
    channels: List[str],
    *,
    duration_s: float,
    max_msgs: int,
) -> Iterator[Dict[str, Any]]:
    """Open a WS to /v1/stream, subscribe to `channels`, yield decoded
    JSON messages. Bounded by both `duration_s` and `max_msgs` so the
    strategy never streams indefinitely."""
    url = base_url.replace("http://", "ws://", 1).replace("https://", "wss://", 1) + "/v1/stream"
    parsed = urllib.parse.urlparse(url)
    port = parsed.port or (443 if parsed.scheme == "wss" else 80)
    raw = socket.create_connection((parsed.hostname, port), timeout=15)
    sock = ssl.create_default_context().wrap_socket(raw, server_hostname=parsed.hostname) \
        if parsed.scheme == "wss" else raw
    sock.settimeout(2)
    key = base64.b64encode(os.urandom(16)).decode()
    sock.sendall("\r\n".join([
        f"GET {parsed.path or '/'} HTTP/1.1",
        f"Host: {parsed.hostname}",
        "Upgrade: websocket",
        "Connection: Upgrade",
        f"Sec-WebSocket-Key: {key}",
        "Sec-WebSocket-Version: 13",
        f"Authorization: Bearer {api_key}",
        "", "",
    ]).encode())
    response = b""
    while b"\r\n\r\n" not in response:
        response += sock.recv(4096)
    if b" 101 " not in response.split(b"\r\n", 1)[0]:
        raise RuntimeError("WS handshake failed")
    # ... send masked subscribe frame, then yield messages until deadline.
    # Full source: crates/sdk/sequence-py/examples/hosted_strategy_prediction.py

Discovery — the YES/NO fallback trick

Volume-sorted prediction markets often have one-sided books — only resting bids on one outcome, nothing on the other. A naive "pick top market → submit at half-bid" crashes when there's no bid to halve. Iterate candidates, probe each quote, and fall back from YES to NO:

python
def _probe_two_sided(seq, symbol):
    """Return the quote dict if the book has a real bid > 0, else None."""
    try:
        q = seq.quote(symbol, depth=3)
    except SequenceError:
        return None
    bid = ((q or {}).get("nbbo") or {}).get("bid") or 0
    return q if bid > 0 else None
 
 
def find_kalshi_tradable(seq):
    r = seq.markets(venue="kalshi", limit=30, order="volume", active=True)
    for m in (r or {}).get("markets", []):
        if (m.get("liquidity") or 0) < 100:
            continue
        for side, token_field in (("yes", "yes_token_id"), ("no", "no_token_id")):
            tok = m.get(token_field)
            if tok and _probe_two_sided(seq, tok) is not None:
                return {**m, "_chosen_side": side, "_chosen_symbol": tok}
    return None
 
 
def find_polymarket_tradable(seq):
    # `expand=outcomes` flattens multi-outcome events (e.g. "World Cup
    # Winner") into one row per outcome with `yes_token_id` populated.
    r = seq.markets(
        venue="polymarket", limit=200, order="volume",
        active=True, expand="outcomes",
    )
    for m in (r or {}).get("markets", []):
        if m.get("is_placeholder_outcome") or (m.get("liquidity") or 0) < 1000:
            continue
        tok = m.get("yes_token_id")
        if tok and _probe_two_sided(seq, tok) is not None:
            return {**m, "_chosen_side": "yes", "_chosen_symbol": tok}
    return None

Order submission — half-bid limit, guaranteed not to cross

python
def safe_resting_price(best_bid: float) -> float:
    """Half of best_bid, floored at 1¢, rounded down to whole cents.
    Both venues use 0.01 as their default tick — a 50% discount lands
    well below any cross even if the bid moves while submitting."""
    if best_bid is None or best_bid <= 0:
        return 0.01
    half_cents = int(best_bid * 100 / 2)
    return max(0.01, half_cents / 100.0)
 
 
def submit_test_graph(seq, venue, label, symbol, qty, best_bid):
    price = safe_resting_price(best_bid)
    log("graph_submit_intent", venue=venue, label=label, qty=qty,
        limit_price=price, best_bid=best_bid,
        notional_usd=round(qty * price, 4))
    node = seq.node(
        node_id="leg",
        symbol=symbol,
        side="buy",
        qty=qty,
        venue=venue,
        instrument_type="prediction",  # ← required for Kalshi & Polymarket
        limit_price=price,
    )
    return seq.graph([node]).get("graph_id")

instrument_type="prediction" is the one non-obvious arg — without it the graph engine assumes spot and the routing layer can't find your venue's edge. Limit-price units are the venue's natural cents (0.010.99).

main()

python
def main() -> int:
    base_url = os.environ.get("SEQUENCE_API_URL", "https://api.sequencemkts.com").rstrip("/")
    api_key = os.environ.get("SEQUENCE_API_KEY")
    log("boot", sequence_api_url=base_url, has_api_key=bool(api_key),
        strategy_id=os.environ.get("SEQUENCE_STRATEGY_ID"),
        run_id=os.environ.get("SEQUENCE_RUN_ID"))
    if not api_key:
        log("fatal", reason="SEQUENCE_API_KEY not injected")
        return 1
 
    seq = Sequence(api_key, base_url)
    log("health_check", ok=seq.health())
 
    kalshi = find_kalshi_tradable(seq)
    poly = find_polymarket_tradable(seq)
    if not kalshi or not poly:
        log("fatal", reason="discovery_incomplete")
        return 1
    log("discovery_done",
        kalshi_slug=kalshi["slug"], kalshi_chosen_side=kalshi["_chosen_side"],
        polymarket_slug=poly["slug"], polymarket_outcome=poly.get("outcome_name"))
 
    # WS subscribe — bounded so the strategy stays finite
    for msg in ws_subscribe(base_url, api_key, [
        f"prices:{kalshi['_chosen_symbol']}", f"prices:{poly['_chosen_symbol']}",
    ], duration_s=12.0, max_msgs=8):
        ch, data = msg.get("channel") or "", msg.get("data") or {}
        if ch and data:
            log("ws_msg", channel=ch[:60], bid=data.get("bid"),
                ask=data.get("ask"), mid=data.get("mid"))
 
    # Walk both books (logs top-5 levels per side per venue)
    kalshi_q = walk_book(seq, "kalshi", kalshi["_chosen_symbol"])
    poly_q = walk_book(seq, "polymarket", poly["_chosen_symbol"])
 
    # Submit + cancel both graphs
    submitted = []
    for venue, mkt, q, qty in [
        ("kalshi", kalshi, kalshi_q, 1.0),
        ("polymarket", poly, poly_q, 5.0),
    ]:
        bid = (q.get("nbbo") or {}).get("bid") or 0
        if bid > 0:
            gid = submit_test_graph(seq, venue, mkt["slug"],
                                    mkt["_chosen_symbol"], qty, float(bid))
            if gid:
                submitted.append((venue, gid))
 
    time.sleep(4)
    for venue, gid in submitted:
        log("graph_status", venue=venue, graph_id=gid,
            status=(seq.graph_status(gid) or {}).get("status"))
    for venue, gid in submitted:
        seq.cancel(gid)
        log("graph_cancelled", venue=venue, graph_id=gid)
 
    log("done", graphs_submitted=len(submitted))
    return 0
 
 
if __name__ == "__main__":
    try:
        sys.exit(main())
    except Exception as exc:
        log("crash", error=f"{type(exc).__name__}: {exc}")
        sys.exit(1)

The full source — including walk_book, the WebSocket frame loop, and defensive timeout handling — is in crates/sdk/sequence-py/examples/hosted_strategy_prediction.py (roughly 280 lines including comments). Use it as a starting point for your own strategy.

Deploying

bash
# 1. Stage the strategy (SDK auto-vendors at deploy time)
mkdir -p /tmp/my-strategy
cp crates/sdk/sequence-py/examples/hosted_strategy_prediction.py /tmp/my-strategy/main.py
 
# Optional: drop in a Sequence.toml so subsequent deploys are flagless
cat > /tmp/my-strategy/Sequence.toml <<'EOF'
[strategy]
name = "e2e-pred-test"
runtime = "python"
 
[hosted]
entrypoint = "main.py"
 
[hosted.placement]
latency_target = ["coinbase"]
EOF
 
# 2. Deploy. With Sequence.toml present, no flags needed.
sequence strategy deploy /tmp/my-strategy --start
 
# 3. Tail the JSON log stream as it runs
sequence strategy logs e2e-pred-test --follow

--latency-target is the placement hint — coinbase routes to a us-east-1 runner, kalshi or polymarket route to eu-west-1. Pick the one closest to whatever the strategy talks to most. The CLI bundles the directory into a tar.gz and uploads it; the runner verifies the SHA-256, unpacks into a per-run tempdir, and execs python -u main.py.

What you should see

text
[boot] sequence_api_url=http://172.17.0.1:50052 has_api_key=true ...
[health_check] ok=true
[kalshi_discover_count] n=30
[polymarket_discover_count] n=200
[discovery_done] kalshi_slug=... kalshi_chosen_side=no
                 polymarket_slug=... polymarket_outcome=Spain
[ws_msg] channel=prices:... bid=0.153 ask=0.154 mid=0.1535
[book_summary] label=kalshi  bid=0.99  ask=0      n_bids=10 n_asks=0
[book_summary] label=polymarket bid=0.153 ask=0.154 n_bids=10 n_asks=10
[graph_submit_intent] venue=kalshi  qty=1 limit_price=0.49 notional_usd=0.49
[graph_submitted]     venue=kalshi  graph_id=graph_f5013...
[graph_submit_intent] venue=polymarket qty=5 limit_price=0.07 notional_usd=0.35
[graph_submitted]     venue=polymarket graph_id=graph_ea07c...
[graph_status]        venue=kalshi  status=active
[graph_status]        venue=polymarket status=active
[graph_cancelled]     venue=kalshi  graph_id=graph_f5013...
[graph_cancelled]     venue=polymarket graph_id=graph_ea07c...
[done] graphs_submitted=2

End-to-end runtime: roughly 17–25 seconds depending on how fast the WebSocket gets a price tick. Total dollars committed: $0.84, all of which is returned on cancel.


Submitting orders that actually fill

The example strategy above submits resting orders priced below the bid — deliberately so they don't cross the spread. To force-fill (taker behavior), two things have to be right.

1. Cross by at least one tick, force-aligned to the venue's tick size. Submitting a BUY at exactly best_ask is racy: by the time the order lands the ask may have moved up a tick and your order rests instead of filling. Bump by one tick and align:

python
def _venue_tick(venue: str) -> float:
    # Kalshi is 0.01. Polymarket varies — most markets are 0.01 but
    # some run 0.001, and the displayed quote tick is the source of
    # truth. Use the smaller value to be safe.
    return 0.001 if venue == "polymarket" else 0.01
 
def force_cross_price(side: str, reference: float, tick: float) -> float:
    raw = reference + tick if side == "buy" else reference - tick
    return max(tick, round(raw / tick) * tick)
 
# BUY 1 tick above ask:  force_cross_price("buy",  best_ask, 0.01) → ask + 0.01
# SELL 1 tick below bid: force_cross_price("sell", best_bid, 0.01) → bid - 0.01

Pair this with urgency="high" on the node — the SDK signal for "match now":

python
node = seq.node(
    node_id="leg",
    symbol=symbol,
    side="buy",
    qty=qty,
    venue="polymarket",
    instrument_type="prediction",
    limit_price=force_cross_price("buy", best_ask, 0.001),
    urgency="high",
)

2. Respect the venue's minimum notional. Polymarket has a $1 minimum notional. An order below it is silently accepted — you get back a graph_id, status="active", no error — but the CLOB never crosses it; the order just sits forever. Kalshi has no minimum-notional check (1 contract is fine). At a $0.15 mid, you need at least 7 Polymarket shares to clear $1. Use 8 to leave headroom for quote movement.

3. Multi-leg / parallel-root graphs. A graph with N nodes and zero edges is N parallel orders that fire on submit. Useful for cross-venue arb: hit Kalshi YES and Polymarket NO at the same time without a sequencer in your strategy.

python
seq.graph(
    nodes=[
        seq.node(node_id="kalshi-leg",
                 symbol=kalshi_ticker, side="buy", qty=1.0,
                 venue="kalshi", instrument_type="prediction",
                 limit_price=force_cross_price("buy", k_ask, 0.01),
                 urgency="high"),
        seq.node(node_id="polymarket-leg",
                 symbol=poly_token_id, side="sell", qty=8.0,
                 venue="polymarket", instrument_type="prediction",
                 limit_price=force_cross_price("sell", p_bid, 0.001),
                 urgency="high"),
    ],
    edges=[],            # ← no edges → both nodes are roots → fire in parallel
    sandbox=False,       # ← True for paper-fill against live NBBO
)
# → {"graph_id": "graph_…", "status": "active", "node_count": 2, "edge_count": 0}

The SDK auto-sets activation = "root" on any node not appearing as a target of some edge. So an empty edges list = all-nodes-root. Wire-shape if you're not using the SDK:

json
POST /v1/orders
{
  "nodes": [
    {"node_id": "kalshi-leg",     "instrument": {"symbol": "KX...", "type": "prediction", "venue": "kalshi"},
     "side": "buy",  "order_type": {"limit": {"price_1e9": 180000000}},
     "quantity": {"fixed": {"qty_1e8": 100000000}},
     "execution": {"urgency": "high"}, "activation": "root"},
    {"node_id": "polymarket-leg", "instrument": {"symbol": "4394372...", "type": "prediction", "venue": "polymarket"},
     "side": "sell", "order_type": {"limit": {"price_1e9": 152000000}},
     "quantity": {"fixed": {"qty_1e8": 800000000}},
     "execution": {"urgency": "high"}, "activation": "root"}
  ],
  "edges": [],
  "is_sandbox": true
}

If you want conditional legs (don't hedge until the spot fills, etc.), use edges with triggers — see Execution Graphs.

Sandbox routing duality. Two ways to force sandbox: pass sandbox=True on each call, OR deploy the strategy with a seq_test_* API key — that key prefix forces sandbox on every order regardless of the per-call flag, and SEQUENCE_IS_SANDBOX="true" lands in the strategy's env so it can read its own mode.

4. Detect fills via seq.fills(), not graph_status. graph_status lags fills by several seconds — the actual fill rows can land in the database while the graph is still showing status="active". A strategy that decides "no fill → skip round-trip" based on graph_status will incorrectly skip fills that already happened.

The pattern that works:

python
# Snapshot fills count before submitting
before = seq.fills(symbol=symbol, limit=10)
before_count = len((before if isinstance(before, list) else before.get("fills", [])) or [])
 
resp = seq.graph([buy_node])
graph_id = resp["graph_id"]
 
# Wait for fills delta, not graph_status
deadline = time.time() + 8.0
while time.time() < deadline:
    after = seq.fills(symbol=symbol, limit=10)
    after_list = after if isinstance(after, list) else after.get("fills", [])
    new_fills = [f for f in (after_list or [])
                 if f.get("node_order_id", "").startswith(f"graph:{graph_id}:")]
    if new_fills:
        log("filled", count=len(new_fills),
            qty_1e8=sum(f["qty_1e8"] for f in new_fills))
        break
    time.sleep(0.5)

The node_order_id prefix-match against the graph_id pins fills to the specific submission — so a strategy that's been buying and selling the same symbol in a loop doesn't false-positive on prior runs' fills.


Common pitfalls

SEQUENCE_API_KEY not injected. You forgot the sequence_api_key field on the deploy. The CLI's --start flag automatically injects your current config key, but if you used the API directly you have to pass it explicitly.

No bid and the strategy skips submission. Far-OTM Kalshi outcomes often have one-sided books. Probe before committing to a market — see _probe_two_sided above. The YES↔NO fallback fixes most cases.

Order accepted but never fills (Polymarket). Almost always one of: (a) notional is below $1 — the venue silently rests sub-$1 orders; bump qty to clear $1 of notional, or (b) you submitted at the ask instead of one tick above it; see Submitting orders that actually fill.

graph_status says active but fills already exist. Status reflects the graph's overall state machine, which lags the per-fill events by a few seconds. Don't gate strategy logic on graph_status — query seq.fills(symbol=...) and filter by node_order_id prefix to detect fills against your submission.

Empty WebSocket frames. The first message after subscribe is sometimes a welcome/ack with empty data. Filter on msg.get("channel") and msg.get("data").

websockets ImportError. The python:3.12-slim runner image doesn't have it. Either bundle a wheel into your artifact alongside main.py, or use the raw-stdlib handshake shown above.

runtime must be 'python'. Hosted strategies are Python-only today. A prebuilt-Rust path existed briefly but was removed; bring it back when there's a real story for sysroot detection and target-arch validation.

Strategy gets re-claimed every minute. You're probably hitting the auto-restart-on-exit_code != 0 path — check that your strategy ends with sys.exit(0) (not just falling off the end of main() after an exception). For "process exited 0 = done, don't restart" semantics, see Restart Semantics above.