your radio signal, seen.
Type your callsign and watch a live map of everywhere on Earth your radio signal is reaching right now — then ask when you'll reach Japan, answered from what actually happened on the air.
Skywave pours the world's public reception-report firehose (WSPR, PSKReporter, DX-cluster) into Elasticsearch and draws it: a dark Earth with the computed day/night line, every fresh reception blooming as an arc of light between transmitter and listener, decaying like phosphor. Type a callsign and the planet redraws to that one signal's actual reach. Ask the operator a plain-words question and it answers from the index — painted on the map first, two cited sentences second.
WSPR (wspr.live poll) ┐
PSKReporter (MQTT) ├─► ingest workers ─► Elasticsearch Serverless
DX-cluster (telnet) ┘ │ ├─ skywave-spots (data stream, 7-day retention)
│ ├─ skywave-chatter (semantic_text → hybrid search)
▼ └─ skywave-insights (the agent's growing memory)
SSE to every ▲ ▲
open browser │ │ Elastic Agent Builder
│ │ custom ES|QL + search tools,
browser (canvas planet) ─► /api/ask ─► Gemini (Vertex AI) ──┴────┘ exposed over Elastic's MCP server
the operator's ONLY senses are those MCP tools
One Node process (server/) does everything: serves the static web app,
ingests the three public feeds, streams fresh reports to every open page
over SSE, runs the 10-minute openings sweep, and hosts the /api/ask
endpoint where a Gemini agent (Vertex AI) answers questions exclusively
through Elastic MCP tools.
skywave-spots— append-only time-series data stream of every reception report (who heard whom, band, SNR, grids → lat/lon, regions, distance). The map is a rolling-window query against it — never a canned animation.skywave-chatter— DX-cluster free-text comments with asemantic_textfield: hybrid semantic + keyword search over messy radio slang ("loud into EU, big QSB").skywave-insights— every band opening the agent detects, written back in plain words. The ticker and future answers read it: the planet gets smarter in prose.- Agent Builder tools (created idempotently at boot by
server/agent/estools.js, callable over Elastic's built-in MCP server):skywave_paths_by_hour— ES|QL hour-of-day path counts between two sides of the worldskywave_callsign_reach— ES|QL per-band reach of one callsignskywave_band_activity— ES|QL band × region-pair activityskywave_chatter_search— hybrid search over cluster chatterskywave_insights_recall— search over the agent's own past findings
server/agent/operator.js runs Gemini on Vertex AI (auth via
Application Default Credentials) in a manual function-calling loop. Its
only tools are the five skywave_* tools fetched live from
{KIBANA_URL}/api/agent_builder/mcp (streamable HTTP MCP). It cannot see
the world except through the index. Answers come back as map paint (a
24-cell hour axis along the great-circle path, sized by measured counts)
plus two sentences in operator voice and a citation line listing exactly
what was queried — always ending observation, not prophecy.
server/agent/sweep.js is the agent acting on its own: every 10 minutes
it compares each band × region-pair's last hour against its trailing-7-day
same-hour baseline, phrases detected openings in plain words (Gemini), and
writes them to skywave-insights — every visitor's ticker updates at the
same moment over SSE.
server/
index.js HTTP + SSE + static hosting + REST API
config.js all configuration (env vars)
es.js minimal Elasticsearch/Kibana REST client
setup.js idempotent index + Agent Builder tool setup (runs at boot)
hub.js SSE fan-out to every open page
lib/geo.js maidenhead grids, bands, regions, great circles
ingest/ the three feed workers + normalization
agent/ MCP client, the operator, ES|QL tool defs, openings sweep
web/
index.html the planet + the bench (one page, no framework, no build)
app.js canvas renderer, SSE client, focus/scrub/ask/replay
land.js Natural Earth coastlines (vendored)
manifest.webmanifest, sw.js, icon.svg (PWA shell)
Dockerfile single container for Cloud Run
Prereqs: Node 20+, an Elastic Cloud Serverless
project (Elasticsearch type, Agent Builder available on trial), and a
Google Cloud project with Vertex AI enabled plus ADC
(gcloud auth application-default login).
npm install
cp .env.example .env # fill in your values
npm start # http://localhost:4200First boot creates the indices and the Agent Builder tools automatically. Within a minute the planet starts breathing with real reports.
| var | default | meaning |
|---|---|---|
ES_URL |
— | Elasticsearch Serverless endpoint (required) |
KIBANA_URL |
— | Kibana endpoint of the same project (required — hosts the MCP server) |
ES_API_KEY |
— | encoded Elasticsearch API key (required) |
GOOGLE_CLOUD_PROJECT |
— | GCP project for Vertex AI (required for the operator) |
GOOGLE_CLOUD_LOCATION |
global |
Vertex location (gemini-3-flash-preview lives on global) |
GEMINI_MODEL |
gemini-3-flash-preview |
the operator's model (fallback: gemini-2.5-flash on us-central1) |
GOOGLE_GENAI_USE_VERTEXAI |
true |
keep true: Gemini via Vertex AI with ADC |
PORT |
4200 |
HTTP port (Cloud Run injects its own) |
WSPR_ENABLED / WSPR_POLL_SECONDS |
true / 60 |
wspr.live poller |
PSK_ENABLED / PSK_TOPICS |
true / pskr/filter/v2/+/FT8/# |
PSKReporter MQTT (one connection, filtered per their considerate-use guidance) |
PSK_MAX_PER_SEC |
25 |
politeness/volume cap; focused callsigns are exempt and never dropped |
CLUSTER_ENABLED / CLUSTER_HOST / CLUSTER_PORT |
true / dxc.ve7cc.net / 23 |
DX-cluster telnet node (auto-rotates to alternates) |
CLUSTER_LOGIN |
SKYWAVE |
set a real licensed callsign — public nodes reject anything else; until then the feed shows honestly cold |
SWEEP_MINUTES / SWEEP_FACTOR / SWEEP_MIN_PATHS |
10 / 2.0 / 8 |
openings sweep cadence and thresholds |
Secrets live only in .env (gitignored) or your deploy platform's env
config. Nothing is hardcoded.
gcloud run deploy skywave --source . \
--region us-central1 --allow-unauthenticated \
--min-instances 1 --max-instances 1 \
--set-env-vars ES_URL=...,KIBANA_URL=...,GOOGLE_CLOUD_PROJECT=...,GOOGLE_CLOUD_LOCATION=global \
--set-secrets ES_API_KEY=skywave-es-api-key:latestNotes:
--min-instances 1 --max-instances 1: the ingest workers and the sweep run inside the serving process; one always-on instance keeps the planet breathing and is the polite number of connections to the feeds.- Vertex AI auth is automatic on Cloud Run (the service account's ADC);
grant it
roles/aiplatform.user. - The browser app uses only relative URLs, SSE, and localStorage (bench settings) — nothing else to configure.
Everything on screen is measured from the index — report rates, feed ages,
receiver counts, dxcc counts, best-dx distances — and labeled — measured
where it stands alone. The feeds are the live public WSPR / PSKReporter /
DX-cluster firehoses. An inactive callsign gets a dark planet, because
that is the truth.
Explicitly not real or limited:
- REPLAY mode (the bench) replays the last full UTC day from the index through the same pipeline, with the word REPLAY printed on the planet the entire time. Never silently substituted.
- The planet shows what the monitoring networks hear (digital weak-signal modes), not all radio activity.
- DX-cluster spots carry no location grids, so they feed the chatter index (and the agent's reading of it), not arcs on the map.
- "dxcc" counts are distinct coarse callsign-prefix entities, not the official DXCC entity list.
- Answers are observations over accumulated reports, never ionosphere prediction — the citation line says so every time.
MIT — see LICENSE.