Data refresh job
.github/workflows/refresh.yml ("refresh board") fetches Kalshi, Polymarket and Polygon on a GitHub-hosted runner, writes the JSON files the pages read, checks them for fabricated content and commits them back to your repository.
Why it runs on GitHub, not on your computer#
- Kalshi refuses browser requests. Any request with an
Originheader gets HTTP 403, so the fetch must happen server-side. A scheduled job that writes static JSON is the cheapest server-side fetch there is. - Your network may block a venue. Some internet providers block
*.polymarket.comby law. GitHub's runners are not behind your provider. Do not try to get around a block on your own network; let the runner fetch the data. - Nothing to host. No server, no database, no keys. Latency equals the schedule interval, which suits this product: the spreads it looks at last hours to days, not milliseconds.
Run it by hand#
The workflow ships with the schedule off. Only a manual start (workflow_dispatch) runs it.
- Open your repository on GitHub and click Actions. If GitHub asks, enable workflows for the repository.
- Pick refresh board in the list on the left.
- Click Run workflow, keep the branch (
main) and confirm. - When the run is green, pull the new commit (
git pull). It is titledboard: refresh YYYY-MM-DDTHH:MMZand authored bymikodes-bot.
With the GitHub CLI the same start is gh workflow run "refresh board" and gh run list shows the result (the list command was run for this guide; the start command was not).
What each step does#
| Step | What it does | Fails the run? |
|---|---|---|
| Probe venue reachability | Prints the HTTP status of Kalshi, Gamma and the CLOB host from the runner. | No |
| Build board | node src/run.js in scanner/: both venues, then the category filter (markets in categories.hidden of config/public.json are dropped right after the fetch, so they are never scored, stored or written), matcher, spreads. Writes public/board.json, public/crossvenue-diag.json, data/pairs.jsonl. | Yes, on a crash |
| Fail if the run fabricated anything | Stops the run if an unreachable venue reports markets, or if cross-venue is not ok but carries rows. | Yes |
| Read the Terminal tape from Polygon | node src/onchain.js 40: the last 40 blocks of the exchange contract's logs, from your POLYGON_RPC_URL first if set (below), else the public RPCs. Writes public/data/tape.json with every token and up to 5,000 fills of the window; the next step cuts it to size. | Yes, on a crash |
| Attach market titles | node src/titles.js: resolves token ids to questions through Gamma, then applies the category filter: tokens and fills of hidden markets are dropped, the file keeps the top 60 tokens and the latest 300 fills, and category_filter records how many were left out. If Gamma is unreachable it writes titles.status = "unavailable" and no titles (nothing can be classified, so the lists are only cut to size); it never fails the job. | No |
| Fail if the tape carries titles it could not have fetched | Checks the titles block is consistent with its own status and counts. | Yes |
| Commit the new board | Commits the four data files if they changed and pushes. | Yes, if push is refused |
Use your own Polygon RPC#
The Terminal tape is read from Polygon. Out of the box the job uses three public, keyless RPCs and falls through them in order. They are fine for a 40-block window; a paid or private endpoint gives you higher rate limits and one provider you chose.
- Get an HTTPS Polygon mainnet (chain 137) endpoint from any RPC provider. It must allow
eth_getLogsover at least 40 blocks. - In your GitHub repository open Settings → Secrets and variables → Actions and add a repository secret named
POLYGON_RPC_URLwith the full URL. Several URLs may be given, separated by commas. - Run the workflow. The tape step passes the secret to
node src/onchain.js.
- Your endpoint is tried first; the public RPCs stay as the fallback. Set
POLYGON_RPC_ONLY=1as well to use only yours. - The URL is never written anywhere. Many providers put the key in the URL, so
tape.jsonand the Terminal footer name it only asown RPC (POLYGON_RPC_URL), and error messages carry that label instead of the URL. - A value that is not an
https://URL stops the job with an error that does not print the value. - Locally:
POLYGON_RPC_URL=https://… node src/onchain.js 40fromterminal/. - The Polygon RPC URL in the admin's Integrations section is separate: it feeds the admin's Status check only. Use the same URL in both places if you like.
Turn on the schedule#
Edit .github/workflows/refresh.yml and remove the # in front of the two schedule lines:
on:
schedule:
- cron: '*/15 * * * *' # every 15 minutes (UTC)
workflow_dispatch:Commit and push. Choose the interval with the cost below in mind. GitHub may delay scheduled runs at busy times, and it pauses schedules in repositories with no activity for a long period (GitHub's documented behaviour; re-enable in the Actions tab if it happens).
What it costs in Actions minutes#
The workflow's own comment says every 15 minutes is "~2,900 Actions minutes/month". That figure is really the number of runs (96 a day × 30 days = 2,880) at one billed minute each. The real runs are longer:
Recent runs (from gh run list) | Wall time |
|---|---|
| 2026-09-23 11:01 UTC, success | 2 min 33 s |
| 2026-09-22 20:27 UTC, success | 2 min 33 s |
| 2026-09-22 20:22 UTC, success | 3 min 51 s |
GitHub bills each job rounded up to the whole minute, so a run like these costs 3 to 4 billed minutes. With 3 minutes per run:
| Schedule | cron | Runs / 30 days | Billed minutes / 30 days (3 per run) |
|---|---|---|---|
| Every 15 minutes | */15 * * * * | 2,880 | about 8,640 |
| Every 30 minutes | */30 * * * * | 1,440 | about 4,320 |
| Every hour | 0 * * * * | 720 | about 2,160 |
| Every 2 hours | 0 */2 * * * | 360 | about 1,080 |
| Every 6 hours | 0 */6 * * * | 120 | about 360 |
The workflow comment puts the free allowance for a private repository at 2,000 minutes a month; on that allowance every 15 minutes is roughly four times over and hourly is just over. Check the allowance and overage price for your own GitHub plan on GitHub's billing page before you turn the schedule on; plans and prices change. Public repositories are billed differently (see GitHub's Actions billing documentation).
Running the jobs locally#
cd terminal node src/onchain.js 40 # Polygon only; about 0.5 s; argument = blocks to read
cd scanner node src/run.js # options: --contracts 1000 --max-events 1200 --no-depth --out public/board.json cd terminal node src/titles.js # needs Polymarket's Gamma API
Keep the block window small. The exchange contract emits tens of megabytes of logs over a few hundred blocks; a 500-block request once returned a 51 MB response that was cut off. 40 blocks is about one minute of chain time.