Dual-language starter repo with:
cpp_engine/: a C++20 engine built with CMake and linked to ZeroMQ (libzmq + cppzmq)ts_agent/: a Node.js + TypeScript agent usingzeromq, xAI (Grok), anddotenv
For implementation details (build/linking choices, intended ZeroMQ protocol), see TECHNICAL.md. For what end users should text the agent (preferences, watchlist, follow-ups), see MESSAGING.md. For registration, hosting, multi-user vs persistent storage, and landing-page flow, see ONBOARDING.md.
cpp_engine/— C++20 CMake projectts_agent/— Node.js TypeScript project
- CMake 3.22+
- A C++20 compiler (Apple Clang / LLVM / GCC)
- ZeroMQ core library (
libzmq) - OpenSSL (only for
--livemode; used by cpp-httplib for HTTPS)
On macOS (Homebrew):
brew install cmake zeromq openssl@3cppzmq, cpp-httplib, and nlohmann/json are fetched automatically by CMake FetchContent; you only install the native libraries above.
cmake -S cpp_engine -B cpp_engine/build -DCMAKE_BUILD_TYPE=Release
cmake --build cpp_engine/build
./cpp_engine/build/cpp_enginecpp_engine reads headlines from a data source, keeps only those matching its macro keyword filter, and publishes the matches over ZeroMQ PUB (default tcp://127.0.0.1:5555) as JSON frames (each carries the headline text plus its source publisher, which the agent uses for trust scoring). The filter starts from a built-in macro keyword list but is overridden at runtime by the agent, which pushes the union of all users' tracked keywords + watchlist tickers (see Dynamic filter sync); with no agent connected it keeps the built-in list.
It supports two data sources, selected by flag:
--simulate(default): rotates through a built-in list of mock headlines, emitting one every 2 seconds. No API key needed.--live: polls the Finnhub REST news API (/api/v1/news?category=general) every 2 seconds on a background thread, de-duplicating by articleid. Requires a Finnhub API key (see below).--live --demo: demo-friendly live mode — on the first fetch, queues only recent headlines (newest first, capped) instead of the full snapshot; emits at a steady pace (default one every 6s). After the backlog, only genuinely new articles are added (still paced). Use this when demoing with real Finnhub data.
./cpp_engine/build/cpp_engine # simulate (default)
./cpp_engine/build/cpp_engine --simulate
./cpp_engine/build/cpp_engine --live
./cpp_engine/build/cpp_engine --live --demo
./cpp_engine/build/cpp_engine --live --demo --pace-ms=8000 --backlog-hours=6 --backlog-max=12Macro keywords (case-insensitive substring match): FOMC, CPI, PCE, inflation, Fed, Powell, Rates, ECB, Treasury, yield, jobs, payroll, unemployment, GDP, recession, tariff, stimulus, central bank, bond.
You can override the bind endpoint with a positional argument (works with either mode):
./cpp_engine/build/cpp_engine --live tcp://127.0.0.1:5555--live needs FINNHUB_API_KEY. Put it in cpp_engine/.env (copy from cpp_engine/.env.example):
cp cpp_engine/.env.example cpp_engine/.env
# edit cpp_engine/.env and set FINNHUB_API_KEY=...The engine loads cpp_engine/.env at startup (a real shell env var of the same name takes precedence). --simulate ignores the key.
Note: the Finnhub general-news feed is a ~100-item snapshot that changes only when new articles are posted (not every 2 seconds). With de-duplication, a plain --live run publishes the current matches once, then stays quiet until genuinely new matching headlines appear; a fresh run re-publishes the current set. --live --demo avoids that startup burst by seeding a small recent backlog and pacing emissions (see flags below).
Demo tuning (optional env overrides: DEMO_PACE_MS, DEMO_BACKLOG_HOURS, DEMO_BACKLOG_MAX):
| Flag | Default | Meaning |
|---|---|---|
--pace-ms= |
6000 |
Minimum milliseconds between published headlines |
--backlog-hours= |
24 |
On first fetch, prefer macro-matching items from the snapshot (see demo backlog note above) |
--backlog-max= |
10 |
Cap how many backlog headlines are queued on first fetch |
Notes:
cpp_engine/CMakeLists.txtfetchescppzmq,cpp-httplib, andnlohmann/jsonautomatically via CMakeFetchContent.- You still need
libzmqinstalled system-wide (e.g. via Homebrew), plus OpenSSL for--liveHTTPS.
- Node.js 18+ (20+ recommended)
- npm
cd ts_agent
npm installCreate an .env file (see .env.example):
cp .env.example .env.env is intentionally ignored by git (see .gitignore).
Set:
XAI_API_KEY=...(required for preference extraction and headline analysis)
For Photon Spectrum (iMessage):
PROJECT_ID=...PROJECT_SECRET=...
Optional:
ZMQ_ENDPOINT=...(defaulttcp://127.0.0.1:5555; must matchcpp_enginebind address)FILTER_ENDPOINT=...(defaulttcp://127.0.0.1:5556; must matchcpp_engine'sFILTER_ENDPOINT). The agent binds this and pushes the union of all users' tracked keywords + watchlist tickers so the engine forwards headlines for watched tickers — see Dynamic filter sync.
npm run build
npm startnpm start runs one process that:
- Reactive (iMessage): listens on Spectrum
app.messages, extracts preferences with Grok, and replies. - Follow-ups: after one or more proactive alerts, answer questions about any recent alert (up to 10, within 30 minutes each). The agent uses the LLM to infer which alert the question is about from the wording, so users just ask naturally — no thread-reply gymnastics required.
- Proactive (ZeroMQ): subscribes to
cpp_engineheadlines in the background, analyzes each with Grok, and pushes iMessage alerts when a user’s preferences match.
Outbound messages to the same chat are ordered so preference confirmations are not interrupted by a concurrent alert (see TECHNICAL.md).
Start cpp_engine in another terminal so headlines are published:
./cpp_engine/build/cpp_engineThe repo includes a root Dockerfile for deploying both long-running processes
in one Railway service:
cpp_enginepublishes matching headlines on ZeroMQ.ts_agenthandles Spectrum/iMessage, Grok analysis, user routing, and the reverse filter-set channel.
Keeping both processes in one container lets them communicate over
127.0.0.1, matching the local development architecture.
- Create a Railway service from this GitHub repo.
- Leave the service root at the repository root so Railway sees
Dockerfile. - Do not set a custom start command unless you need to override the Docker
CMD; the image already starts./scripts/start-railway.sh. - Add environment variables in Railway:
XAI_API_KEY=...
PROJECT_ID=...
PROJECT_SECRET=...
ZMQ_ENDPOINT=tcp://127.0.0.1:5555
FILTER_ENDPOINT=tcp://127.0.0.1:5556By default, the startup script runs simulated data:
cpp_engine --simulate tcp://127.0.0.1:5555To use live Finnhub data later, add FINNHUB_API_KEY and override the engine
flags explicitly with ENGINE_ARGS, for example:
ENGINE_ARGS=--live --demo --pace-ms=8000 tcp://127.0.0.1:5555Do not commit .env files; Railway environment variables replace them in
deployment.
Use Docker when you want to test the same shape Railway will run: one container
that builds and starts both cpp_engine and ts_agent.
docker build -t macro-alert-ai-agent:local .
docker run --rm --env-file ts_agent/.env macro-alert-ai-agent:localThis passes your local ts_agent/.env into the container at runtime without
copying secrets into the image. The startup script supplies local ZeroMQ
defaults (tcp://127.0.0.1:5555 and tcp://127.0.0.1:5556) and uses simulated
headlines by default. A healthy run starts with logs like:
Starting cpp_engine with args: --simulate tcp://127.0.0.1:5555
Starting ts_agent
Rebuild the image after Dockerfile, C++, TypeScript, or script changes. Use Ctrl+C to stop the container.
The engine can't know about users on its own, so the agent tells it what to watch. This is a second ZeroMQ channel, in the reverse direction from headlines:
-
The agent binds a PUB socket on
FILTER_ENDPOINT(defaulttcp://127.0.0.1:5556) and publishes a filter set — the de-duplicated union of every user'strackedKeywords+watchlisttickers:{ "type": "filterset", "terms": ["CPI", "FOMC", "TSLA", "NVDA"] } -
The engine subscribes and rebuilds its keyword filter at runtime, so it forwards exactly what at least one user cares about (and nothing else, which keeps Grok analysis volume down). An empty set falls back to the engine's built-in macro keyword list.
The agent republishes whenever preferences change and on a ~5s heartbeat, so
an engine that starts late or reconnects converges to the current set within one
interval (ZeroMQ PUB/SUB does not replay missed messages). Per-user routing is
still done by the agent in shouldAlertUser; the filter set only controls which
headlines the engine bothers to forward.
This is what makes the watchlist work end-to-end: a ticker you mention is pushed to the engine, which then forwards headlines mentioning it.
Checklist:
- Start
cpp_engine(see above). - Start the agent:
cd ts_agent && npm run build && npm start - In the Photon dashboard, find the iMessage “line” / phone number for your project.
- From your iPhone, send an iMessage (blue bubble) to that number (e.g. “Alert me on CPI and FOMC, threshold 0.5”).
- You should see a console log like:
[iMessage] space=... sender=...: <your text> - You should receive a reply confirming saved preferences.
Troubleshooting (preferences):
- If you get
Got it — saved...butTracked keywords: (none)andSeverity threshold: 0.6, the agent fell back to defaults (most commonly becauseXAI_API_KEYis missing/blank, or the LLM output wasn't parseable). - If you get
Sorry — I couldn't update your preferences right now., the LLM call failed (network/API/model error). Check the agent console logs for the error. - If an alert arrives before your “Got it — saved…” reply, restart the agent after
npm run build— the agent holds alerts while your settings message is being processed so confirmations should arrive first. - If you get multiple identical replies for a single text, you likely have multiple
npm startprocesses running at once. Stop extras (Ctrl+C) so only one agent instance is connected. - The agent also enforces a single-instance lock; if you try to start a second copy, it will exit and tell you which PID is already running.
After saving preferences (above), leave npm start running with cpp_engine publishing:
- Watch the agent console for
[ZMQ] headline: ... [source: ...]when a headline is received. - If the headline matches your keywords or watchlist tickers, Grok’s severity score is at or above your severity threshold (what you set with
threshold 0.5, etc.), and the source trust Grok assigns the publisher is at or above your source-trust threshold (default 0 = any source), you should get a proactive iMessage (no new inbound message required). The alert names the source and its trust level, e.g.Source: Reuters · trust high (0.95). - Lower severity threshold → more alerts; higher → fewer. This filters on how market-moving the headline is — not bullish/bearish direction (direction is shown on the alert but not filtered). Raise the source-trust threshold (e.g. "only reputable sources") to suppress low-credibility publishers — see
MESSAGING.md.
With both cpp_engine and the agent running, the agent pushes your watchlist
tickers to the engine (see Dynamic filter sync), so the
engine forwards headlines that mention them:
- Text the agent
watch TSLA, threshold 0.3(low threshold so severity passes). The agent console logs[filter] pushed N term(s) to engine: ..., TSLA. - In
--simulatemode the engine cycles through a built-inTSLA deliveries miss estimates...headline; once your watchlist reaches the engine it starts forwarding it, and you should get an alert. (Before you set a watchlist, that ticker-only headline is dropped by the macro filter.)
To exercise the agent's matching in isolation (no engine, any headline),
stop cpp_engine and use the debug publisher to inject one directly, bypassing
the filter entirely:
cd ts_agent && npm run build
npm run pub -- "TSLA tumbles 8% on weak deliveries"Troubleshooting (alerts):
- No
[ZMQ]logs: checkcpp_engineis running andZMQ_ENDPOINTmatches its bind address. - Headlines logged but no alert: preferences may not match (keywords, severity below threshold, or the source's trust below your source-trust threshold), or
XAI_API_KEYis missing (analysis is skipped). - Console says
no user preferences yet: send at least one iMessage to configure preferences first. - Console says
no cached conversation: you must message the agent at least once per run so it can resolve the Spectrumspacefor outbound sends.
After you receive one or more proactive macro alerts:
- Reply in the same chat with a question, e.g.
Why is this hawkish?orWhat does the Waller news mean for rate cuts? - You should get a threaded analysis reply (not a "Got it — saved preferences" message).
- If several alerts are active, the agent passes all of them to the AI which picks the most relevant one based on your question — you don't need to do anything special to reference a specific alert.
- Each alert stays in context for 30 minutes after it was sent; up to 10 alerts are tracked per chat. Send a new preference message (e.g. "alert me on CPI") if you want to change settings instead.
Troubleshooting (follow-ups):
- Got a preferences reply instead of analysis: phrase your message as a question, or include words like "why", "summarize", or "hawkish". Messages like "alert me on CPI" are treated as preference updates.
- No alerts active yet: wait for an alert to arrive (or inject one with
npm run pub), then ask your question within the 30-minute window.
npm run sub runs subscriber.ts alone — it only logs headlines that pass the hardcoded C++ macro keyword filter. Use it to verify ZMQ wiring without Spectrum or Grok:
cd ts_agent
npm run build
npm run subIf cpp_engine uses a non-default port:
npm run sub -- tcp://127.0.0.1:5555npm run pub runs testPublisher.ts — it binds a PUB socket and emits a
headline frame in the same JSON shape as cpp_engine, but without the macro
keyword filter. Use it to feed the agent arbitrary headlines (e.g. ticker-only
news for watchlist testing):
cd ts_agent
npm run build
npm run pub -- "TSLA tumbles 8% on weak deliveries"
npm run pub -- "Fed holds; NVDA pops" tcp://127.0.0.1:5555 # optional endpointBecause it binds the endpoint, stop cpp_engine first (only one process can
bind). Start the agent (npm start) before publishing so its subscriber is
connected.