On this page
Dashboards assume you are looking. The chain does not care.
Most of the day I am not looking. I am in a meeting, or writing, or at the gym with a playlist and no phone in my hand. That is exactly when a vault starts moving, or gas doubles, or someone you actually listen to posts a drain thread. By the time you sit down again the moment is a recap, or it is gone.
Onchain Air is the thing I wanted in those hours: a radio. Music in the bed, news-broadcast-style reads when something on-chain (or on the security wire) is worth saying out loud, then the record comes back. You can leave it running. Missing a minute is fine. That is how radio works.

The product is the stream
The site is a desk — vitals on the left, a hero in the middle, a live wire on the right, a player along the bottom.
What you hear, in order of how often it happens:
- A royalty-free music bed, always
- A scheduled read: 15-minute gas average, 20-minute stablecoin recap, hourly security headlines, station ID, the odd ad
- An interrupt, when it is actually interrupt-shaped: a vault drain, a gas spike that doubled the average, a watched researcher posting
Quiet is not a bug. If the last twenty minutes were uneventful, the copy says the wire was quiet and Onchain Air is still watching. Then the music continues. I would rather hear that than a bot that treats every transfer like a siren.
There is a longer project write-up at /onchain-air. The rest of this post is how the station is actually wired.
Talk is a bulletin, not a side effect
Every spoken line is a row in bulletins, then (usually) a TTS job, then a Redis announce. The API, the indexer, and the studio never write to Icecast. They enqueue copy. A radio controller turns that into Liquidsoap telnet: duck the bed, push the clip, unduck.
studio / clock / indexer
│
▼
enqueueBulletin() → bulletins (queued)
│
▼
Redis LPUSH queue:tts
│
▼
TTS worker → Piper (or ElevenLabs) → MinIO
│
▼
PUBLISH radio.commands { type: "announce", url, duration_ms }
│
▼
radio-controller → telnet bed.duck → announcements.push → unduck
│
▼
Liquidsoap mix → Icecast /stream
interrupt: true is the same path. Liquidsoap just ducks immediately instead of waiting for a clock hole. Station ID skips TTS and plays an uploaded sting from the station bucket.
Piper gets a pronunciation pass before it speaks. USDC is not "usd-see". Tickers are rewritten into something a newsreader can say (yew ess dee see, ee tee aitch, and so on) so the bulletin sounds like a desk, not a ticker.
Duck depth, a 1s pre/post, and voice gain live in Redis as station.settings. The public player follows radio.ducked so the chrome matches what you hear.
The clock, and the things that ignore it
apps/api ticks every 15 seconds, but it only fires once per UTC minute (a Redis NX lock on clock:YYYY-MM-DDTHH:MM). That tick does two jobs: maybeGasSpike(), then fireDueSlots().
The week grid is a repeating UTC day. Default placements:
| When | What |
|---|---|
:00 :15 :30 :45 |
ETH gas — 15-minute average |
:00 :20 :40 |
Stablecoin recap — last 20 minutes of USDC / USDT |
:55 |
Hourly headlines (security) |
even hours, :00 |
Station ID |
even hours, :10 |
Advertisement |
A topic on the grid decides when. The kind decides what it says. Placeholders look like {gasAvg:-15m} or {transferUsdc:-20m} — slug plus a window — and get interpolated from snapshots / topic_logs when the slot fires.
Gas spikes, vault drains, and security-watch tweets are not calendar slots. They are standing watches. If the 15-minute gas average doubles against the previous window (and the previous window was at least 0.5 gwei, and a 15-minute cooldown is clear), the clock inserts a GAS wire event and an interrupt bulletin. Same idea for a drain off a watched vault.
That split is the whole editorial policy. Scheduled copy is the pulse you can live with in the background. Interrupts are for the thing you would have unlocked your phone for.
Indexer: two log paths, one gas loop
One process (services/indexer). Production points RPC_ETH_HTTP at a live Ethereum node. Locally it is Anvil plus a MockUSDC printer so the wire is not an empty studio.
Stablecoin wire. watchEvent on Transfer for USDC / USDT / DAI. Mints from 0x0 are skipped. Amounts use a static decimals table, not on-chain metadata. If amount ≥ $1M and the sender is a watched vault or protocol. If we detect a DRAIN of a protocol it is an immediate wire, interrupt bulletin. Otherwise the print sits in stablecoin_prints until the current 20-minute UTC window closes, then one FLOW event goes out with payload.txs[] so the desk can link every hash. FLOW windows stay on the wire; they do not auto-TTS. The clock speaks the recap.
The live-wire sidebar shows the aggregate, not each transfer. That is deliberate. A million-dollar print every few seconds is not a radio format.
Topic contracts. Studio can attach an ABI + metrics (event name, sum vs count, value argument) to a category. Those logs land in topic_logs and feed {slug:-window} interpolation. They do not publish the public wire. Inventory for scheduled copy.
Gas. Every 15 seconds on chain id 1: getGasPrice, getBlock, best-effort pending-block size (2.5s timeout so a hung getBlock("pending") cannot stall snapshots). Rows older than 36 hours are pruned. The desk 15-minute average is AVG(gas_gwei) over now() - 15 minutes, compared to the window 30–15 minutes ago.
Cursors live in indexer_cursors. On start the process backfills from the cursor (or INDEXER_START_BLOCK) in 800-block steps, then watches.
Headlines and the security watch
Two different jobs, both using Grok where an API key is present.
Hourly headlines are written continuously and only spoken at :55. RSS from Cointelegraph, CoinDesk, Decrypt, The Block, and Rekt, filtered with a boring relevance regex (ethereum, defi, exploit, drain, bridge, permit2, …). In parallel, a Grok x_search over a fixed researcher list (samczsun, pcaversaccio, PeckShieldAlert, zachxbt, …) asks for posts from the last hour that are actually about incidents, not price. If the hour was empty, the quiet line goes out instead of inventing a story.
Security watch is the interrupt version of the same idea. It polls those handles, keeps the original tweet text, and fills:
We interrupt the current broadcast, because {account} has just tweeted {summary}.
That clip ducks the bed immediately and the card lands on the wire with the X URL. It is not a clock block. If zachxbt posts while you are on the third set, you hear it.
Anniversary slots are the other inbound: on-this-day events from history.harrydenley.com. Same catalog I already maintain, spoken instead of posted.
Studio, ads, the rest of the rack
The listener app and the operator app are separate Next sites so the public bundle never contains studio auth. Studio is a live desk (type a line, push it now, duck sliders), a Monday–Sunday week grid, per-topic dashboards, and ad review.
Ads have a lifecycle: pending_payment → pending_review → active | rejected. Slots are rail, featured, inline, marquee, plus billboard on the clock. Human review is mandatory before anything sponsored touches the wire. /advertise is the public pitch: burn $RADIO to take a slot. The token is not live yet — that page is the offer, not an auction.
The rest of the compose file is the boring rack that makes a 24/7 station possible: Postgres as source of truth, Redis for pub/sub and queues, MinIO for music / TTS / stings, Icecast for /stream, Liquidsoap as the only mixer. Locally there is Anvil and a USDC simulator. Production does not run those.
What I actually use it for
I wanted a pulse I did not have to sit down for.
The gym is the obvious case — headphones, a bed of music, and a read if gas spiked or a watched vault moved. The less obvious case is work. A dashboard in another window is a dare to context-switch. A radio is furniture. You hear the shape of the last twenty minutes without opening Etherscan, and when it is not quiet, it says so in a sentence.
That is Onchain Air. Press play. Leave it on.