Dev.to AI 🤖 Ai 👁 0 📖 9 min read

Our Grok Kalshi paper bot ignored Retry-After and treated a login page as JSON

Originally published on BotCookery, free build recipes for AI-built trading and prediction-market bots. Written with AI assistance. Every log line and count below is from the Nov 5, 2026 rebuild against Kalshi's live pub

Originally published on BotCookery, free build recipes for AI-built trading and prediction-market bots. Written with AI assistance. Every log line and count below is from the Nov 5, 2026 rebuild against Kalshi's live public demo data in paper mode: no demo keys, and no order was sent. Nothing here is financial advice. Paper and demo only.

People keep asking whether Grok can build a trading bot. The useful answer is narrower: an AI coding agent can scaffold a Kalshi demo bot whose spending limits live in code, whose research step only sees public market text, and that you can stop with one command. The recipe on BotCookery is that prompt. On the morning of Nov 5, 2026 (ET) we pasted the published one-shot into a clean folder, built it by hand, and ran it in paper mode against Kalshi's live public demo. The Oct 25 bookkeeping held. The HTTP edge did not.

The first fault we drilled was a 429 with Retry-After: 3. The bot retried after about half a second of homemade backoff. That is the finding this post leads with, because it is the kind of bug that looks fine in a happy-path run and then burns you the first time the venue asks you to wait.

What the recipe is

The page title on the site is a how-to for building a Kalshi paper bot with Grok and spending limits. Under the hood it is the same shape as our Claude and ChatGPT starters: the agent writes the code; you set the rules and the dollar caps. Paper mode is the default. Public market reads hit https://external-api.demo.kalshi.co/trade-api/v2 with no key. Demo orders need keys, PAPER_MODE=0, --live, and a typed confirmation; we had none of that, so nothing was signed.

Limits sit in limits.json and risk.py, not in the agent's instructions. An agent can misread a prompt; it cannot skip a check it does not control. Create Order V2 is POST /portfolio/events/orders with side, count, price, time_in_force (fill_or_kill, good_till_canceled, or immediate_or_cancel) and self_trade_prevention_type. Cancel-all is DELETE /portfolio/events/orders. Order groups cap matched contracts over a rolling 15 seconds, so the bot must create one group and reuse it. The optional research step posts only public title, rules text, and prices to xAI's Chat Completions endpoint; its output is logged and never changes the bid. We left research_enabled false and never called it.

What happened when we rebuilt it from this prompt

On Nov 5, 2026 we pasted this page's one-shot prompt into a clean folder, built it by hand and ran it in paper mode against Kalshi's live public demo data that morning (ET). No demo keys, so nothing was signed or sent. The Oct 25 bookkeeping fixes (one order group, paper IOC fill, held-ticker skip, settle-first daily loss, cancel_all leaves filled exposure alone) held up. What broke was the HTTP edge:

  • Retry-After was ignored. A 429 with Retry-After: 3 retried after ~0.5 s of homemade backoff. Reads now sleep the header value (cap 8 s) when it is present.
  • HTML 200 printed a JSON decode error. A gateway login page as a 200 became Expecting value: line 1 column 1. Now: fetch failed: response was not JSON: gateway login.
  • Hung host chewed the clock. Default timeouts were generous and retries stacked; against a hung proxy the process sat until an outer 55 s kill with no useful line. Now: timeout=(5, 10), at most 2 retries, then fetch failed: timed out (~16 s in our drill).
  • 503 HTML leaked tags into the error. Stripped now (503 Service Unavailable).
  • Demo books are thinner than the Oct 25 sample suggested. Around 8:57 AM ET we scanned 5,000 open markets (mve_filter=exclude) and found 56 with a real YES ask, 18 of them ≤ $0.15. Around 9:04 AM the first 1,000 had 13 real asks and zero ≤ $0.15. Pick the ticker right before you run.

Oct 25 condensed (still true): a new order group every run meant the server-side 15 s cap never bit; cancel_all had been zeroing exposure for IOC fills; paper reserved the limit price instead of filling at the book; the bot re-bought the same ticker every run; daily_loss_usd was never written. Those are all in the one-shot now.

From the fresh rebuild this morning (16 tests pass), 8:58–9:03 AM ET:

08:58:39 KXNFLRECYDS-26NOV08CINATL-ATLAHOOPER81-40 (Austin Hooper: 40+ receiving yards) ask 0.0600 x50.00, limit 0.1500: PAPER IOC bid 1 @ 0.1500 -> filled 1.00 @ 0.0600 fee 0.01; exposure $0.070000, group paper-6cfc6744
08:58:39 KXNFLRECYDS-26NOV08CINATL-ATLAHOOPER81-40 … already hold 1.00, skip
KILL is on. No order, no order group.
{'paper_cancelled_resting': 0}
positions kept: {'KXNFLRECYDS-26NOV08CINATL-ATLAHOOPER81-40': {…}} exposure $0.070000
08:58:50 settled KXLIGAPROMATCH-26NOV050700LOBALO-LOB: no, pnl -0.13
08:58:51 KXNFLTD-26NOV08DENCAR-CARJCOKER18-3 (Jalen Coker: 3+ touchdowns) ask 0.0100 x25.00, limit 0.1500: PAPER IOC bid 1 @ 0.1500 -> filled 1.00 @ 0.0100 fee 0.01; exposure $0.090000, group paper-6cfc6744

The settlement line is a drill: we injected a test position on a demo market Kalshi had already finalized as "no", and the bot booked a $0.13 loss toward the daily limit. The second buy reused the first run's order group. After the HTTP fixes, fault drills against a local proxy: 429+Retry-After slept ~3 s; HTML 200 and 503 printed one clean line; hang ended with timed out in 16.5 s.

What we still can't test without demo keys: real demo fills, order-group rejection, signed cancels. Without an xAI key: the Grok research reply. kalshi.com/fee-schedule returned 429 during this check; the taker fee formula in the prompt (ceil to the cent of 0.07 * count * price * (1 - price)) still matches what we charge in paper. Docs pages we rely on were 200; demo and elections hosts both answered. Changelog Nov 5: market_closed → market_inactive on order errors; KXRFQ series excluded from unscoped lists.

Why Retry-After matters more than the happy path

Before the fix, a single 429 with Retry-After: 3 still retried after ~0.5 s. A drill that always returned 429 took about 22.6 s across five long retries and then printed a noisy client error. After the fix: when the header is present, sleep that many seconds (cap 8); at most two retries; timeout (5, 10). The same 429-once drill slept about 3 s and finished in 3.84 s. An always-429 drill now fails in 1.51 s with one clear line: fetch failed: 429 {"error":"too many requests"}.

That is the difference between "we rate-limit politely" and "we look like a broken client." Homemade exponential backoff is fine when the server is silent. When the server tells you when to come back, ignore that number and you are guessing.

HTML 200 and the hung host

A gateway login page served as HTTP 200 is not a JSON body. The pre-fix client raised Expecting value: line 1 column 1 — the classic JSONDecodeError surface. After the fix the same drill prints fetch failed: response was not JSON: gateway login. A 503 that returned HTML used to leak tags into the error string; it now collapses to 503 Service Unavailable.

The hung-host drill is the one that wastes a whole morning if you leave generous defaults in place. Against a proxy that never answered, the old client sat until an outer 55 s kill with empty stdout. The fixed client uses timeout=(5, 10), at most two retries, then fetch failed: timed out in about 16.5 s in our drill. You still will not get an instant Ctrl-C mid-request — that remains a noted limit — but you get a useful line and a bounded wait.

Thin demo books are a beginner gotcha

yes_ask_dollars of "0.0000" or "1.0000" means nobody is offering YES. Treat both as no ask. Never compute "100 − X" on dollar prices; the book returns bids only, and the implied YES ask is $1.00 minus the highest NO bid when you have to derive it from the book.

On Nov 5 around 8:57 AM ET, a scan of 5,000 open markets with mve_filter=exclude found 56 with a real YES ask, 18 of them ≤ $0.15. Around 9:04 AM the first 1,000 open markets had 13 real asks and zero ≤ $0.15. The ticker we used at 8:58 (KXNFLRECYDS-26NOV08CINATL-ATLAHOOPER81-40, Austin Hooper 40+ receiving yards) had ask 0.0600 x50 against a 0.1500 limit. A few minutes later the cheap end of the book had moved. Pick the ticker right before you run.

Unscoped GET /markets also skips on-demand KXRFQ series as of Kalshi's Nov 5, 2026 changelog. If your scan looks emptier than you expect, check the filter and the series, not only your pagination loop.

market_inactive (was market_closed)

As of that same changelog, orders on a halted or closed market return market_inactive (it used to say market_closed). Market lifecycle status values like active / finalized are unchanged; this bot still only bids when status is active. If your error handler still keys on the old string, a halt looks like an unknown failure instead of a clean skip.

Paper run numbers, verbatim from the rebuild

The 8:58 ET paper pass filled one contract on the Hooper ticker at 0.0600 with a $0.01 fee, booked $0.07 exposure under group paper-6cfc6744, skipped the held ticker on the next run, refused under KILL without creating a group, left exposure alone after cancel_all (paper_cancelled_resting: 0), settled the injected Ligamento position as no with pnl -0.13, then filled a second ticker (KXNFLTD-26NOV08DENCAR-CARJCOKER18-3 at ask 0.0100) reusing the same order group and ending at $0.09 exposure. Sixteen offline tests passed after the HTTP hardening. A second paper pass on the fixed build around 9:02 ET repeated the Hooper fill under a new paper group id, held skip, kill, and cancel-keeps-exposure.

None of those fills left the box. Paper mode reads the live public demo book and simulates an immediate-or-cancel fill locally. Real demo fills, order-group rejection, and signed cancels remain untested without keys.

Built this before Nov 5?

Paste this follow-up into the same project:

Update the Kalshi demo bot with these HTTP and docs fixes. Stay in
paper mode; send nothing to the network except public market reads.

1. Public GETs: timeout=(5, 10), at most 2 retries. On 429/5xx use
   bounded exponential backoff. If the response has Retry-After, sleep
   that many seconds instead (cap 8).
2. Never print a traceback for HTTP or JSON failures. One line only:
   "fetch failed: …". HTML or non-JSON 200 -> "response was not JSON: …"
   (strip tags, truncate). 503 HTML -> "503 Service Unavailable".
3. Hang/timeout -> "fetch failed: timed out" after the retries, then exit.
4. Catch BrokenPipeError around main (exit 0) so `| head` is quiet.
   KeyboardInterrupt -> one line, exit 130.
5. Docs: orders on a halted/closed market return market_inactive (Kalshi
   changelog Nov 5, 2026; was market_closed). Unscoped GET /markets skips
   on-demand KXRFQ series. Fee formula unchanged
   (ceil-cent 0.07 * count * price * (1 - price)); kalshi.com/fee-schedule
   may 429 — use the formula in the prompt.
6. Beginner gotcha: demo books are thin. When picking a ticker, scan
   status=open&mve_filter=exclude until yes_ask_dollars is strictly
   between 0 and 1 ("0.0000" and "1.0000" mean no ask).
7. Keep Oct 25 behaviour: one reused order group, paper IOC fill at the
   ask with fee, held-ticker skip, settle-first daily loss, cancel_all
   does not clear filled exposure.

Starting fresh? The full one-shot, the Oct 25 follow-up, the safety checklist, and every Kalshi doc we checked are on the recipe page. Run paper first with no keys. Touch KILL, confirm it refuses, then run cancel_all and confirm filled exposure stays put.

What this is and isn't

The page's verified line, word for word: Verified working on live data in paper mode, Nov 5, 2026. Ran live in paper mode against Kalshi's public demo market data on the morning of Nov 5 (ET), for several runs plus kill-switch, cancel-all, settlement and HTTP fault drills. We had no demo keys, so no signed request was sent and no demo order was placed; real demo fills and order-group rejections are untested. The optional research step was not called (research_enabled false; no xAI API call). Settlement was drilled with a test position on a demo market that had already been finalized.

These runs prove plumbing: retries, timeouts, paper IOC math, held-ticker skip, settle-first daily loss, kill, cancel-all. They do not prove a strategy. Demo books are thin and change by the minute; Kalshi notes that demo prices and behavior may differ from production. If you turn the research step on later, treat the model's probabilities as advisory. Kalshi's Developer Agreement allows API use for your own trading, not for running a bot for other people. Be wary of any account offering to "run your bot" or sell "Grok signals."

The full recipe — prompt, steps, FAQs on thin books and market_inactive, and the dated rebuild write-up — is at botcookery.com/recipes/grok-trading-bot.

API changes and new field notes: @botcookery on X (https://x.com/botcookery)

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.