This document is the technical companion to the user-facing README. It explains the repo’s internals: build systems, dependency choices, and the intended C++ ↔ Node communication model.
The repository is split into two projects:
cpp_engine/: a C++20 executable intended to own low-latency / system-level work.ts_agent/: a Node.js + TypeScript process intended to own LLM/tooling orchestration.
The intended integration point between them is ZeroMQ:
- libzmq: the native ZeroMQ implementation (shared library)
- cppzmq: header-only C++ bindings over libzmq
- zeromq (npm): Node.js bindings
That separation keeps each language doing what it’s good at while still allowing a simple local IPC boundary.
cpp_engine/CMakeLists.txtuses CMakeFetchContentto fetch, at configure time:- cppzmq — header-only C++ bindings for ZeroMQ.
- cpp-httplib (
yhirose/cpp-httplib) — header-only HTTP/HTTPS client used by--livemode to poll Finnhub. Configured withHTTPLIB_REQUIRE_OPENSSL ON, which finds OpenSSL, definesCPPHTTPLIB_OPENSSL_SUPPORT, and links it sohttps://works. - nlohmann/json — header-only JSON parser, used to decode Finnhub responses.
- libzmq and OpenSSL are not vendored: install them via your OS package manager (e.g.
brew install zeromq openssl@3). OpenSSL is only required for--live.
Why this approach:
- These libraries are small and header-only, so
FetchContentis straightforward. - libzmq and OpenSSL are native dependencies better managed by the system (security updates, platform builds).
The CMake file links the executable against:
cppzmq(interface target) for#include <zmq.hpp>- libzmq (pulled in transitively by cppzmq’s CMake logic)
If you hit link or discovery issues, the most common cause is that libzmq isn’t installed or isn’t visible to CMake/pkg-config.
The engine is built around a small data-source abstraction so the headline feed can be swapped without touching the publish pipeline:
IMarketDataSource.hpp— abstract interface with one method:std::optional<Headline> nextHeadline(), whereHeadline { std::string text; std::string source; }pairs the headline with its publisher. Returningstd::nulloptmeans "no headline available right now."SimulatedDataSource.hpp— implements the interface with a built-in vector of mockHeadlines (each paired with a synthetic publisher spanning trust tiers, e.g. Reuters / Bloomberg / PRNewswire / a no-name blog, so source-trust scoring is exercisable without a live key), rotated sequentially. It self-paces: it returns a headline at most once every 2 seconds andstd::nulloptin between (via astd::chrono::steady_clocktimestamp).LiveRestDataSource.hpp— implements the interface against the Finnhub REST news API. Its constructor starts a backgroundstd::threadthat polls every 2 seconds (cpp-httplib over HTTPS); anstd::atomic<bool>controls the thread's lifecycle and the destructor joins it. New headlines (de-duplicated by articleidvia anstd::unordered_set<int>) are pushed — together with the article'ssourcefield — onto astd::queue<Headline>guarded by astd::mutex. JSON parsing happens outside the lock; only the push is inside it.nextHeadline()pops the oldest queued headline, or returnsstd::nulloptwhen the queue is empty. WithLiveRestConfig::demo(--live --demo), the first snapshot marks every returnedidas seen, enqueues only items whose Finnhubdatetimefalls withinbacklogHours(newest first, capped atbacklogMax), then subsequent polls enqueue only new ids.PacedDataSource.hppwraps anyIMarketDataSourceand rate-limitsnextHeadline()so demo mode emits a steady trickle instead of draining the queue instantly.MacroFilter.hpp— holds a keyword list and exposesbool matches(std::string_view)using a case-insensitive substring search (C++20std::rangesalgorithms). Substring matching means e.g.Ratesmatches "acceleRATES" andFedmatches "FedEx"; the downstream LLM acts as a second filter.SharedFilter.hpp— a thread-safe holder for the activeMacroFilter. The main loop reads it once per headline; the backgroundFilterSubscriberswaps in a new one when the agent pushes an updated term set. Built-in macro keywords are the constructor default and the fallback used whenever the pushed set is empty. Reads/writes are guarded by astd::mutexaround astd::shared_ptr<const MacroFilter>(the lock is held only to copy/swap the pointer, not during matching).FilterSubscriber.hpp— owns a backgroundstd::threadrunning aSUBsocket onFILTER_ENDPOINT(defaulttcp://127.0.0.1:5556). It parses{"type":"filterset","terms":[...]}frames from the agent and callsSharedFilter::update(), skipping no-op updates (the agent heartbeats the same set).recvuses a 200 msRCVTIMEOso the thread can observe its stop flag.ZmqPublisher.hpp— owns thezmq::context_t+zmq::socket_t(PUB), binds in its constructor, and serializes each headline (text +source, both JSON-escaped) to the JSON wire frame inpublishHeadline().DotEnv.hpp— minimal.envloader (KEY=VALUE,#comments, optional quotes/export). It usessetenv(..., overwrite=0)so a real shell variable always wins over the file.
main.cpp wires these together: it loads .env, parses flags (--simulate/--live + optional endpoint), constructs the chosen source behind a std::unique_ptr<IMarketDataSource>, the SharedFilter (seeded with the macro defaults), the FilterSubscriber, and the ZmqPublisher, then runs the main loop:
- Call
source->nextHeadline(). - If a headline is returned: run it through the active filter (
filter.current()->matches(...)), publish matches, and immediately loop again (draining any queued backlog). - If
std::nullopt: sleep 50 ms to avoid busy-waiting, then loop.
Pacing therefore lives in the source (the simulated source's 2 s cadence, the live source's 2 s poll), not the loop — so live headlines drain as fast as they arrive while idle CPU stays near zero. Startup errors (bad flags, bind failure, missing FINNHUB_API_KEY under --live) are fatal and exit non-zero; per-iteration errors are logged and the loop continues.
Typical next steps:
- Add more sources under
cpp_engine/src/. - Add libraries with
add_library(...)and link them intocpp_engine. - Introduce a small messaging layer (encode/decode + request routing) once you define your message schema.
package.jsonsets"type": "module".tsconfig.jsonusesmodule/moduleResolution=NodeNext.
This matches modern Node.js ESM behavior while keeping the TypeScript compiler in control of output under ts_agent/dist/.
dotenv: loads.envviaimport "dotenv/config"spectrum-ts: Photon Spectrum SDK for unified messaging + iMessage provider- xAI (Grok): called via OpenAI-compatible HTTP endpoints using
fetch zeromq: Node bindings for ZeroMQ
Security note: keep XAI_API_KEY on the agent side. Don’t pass the key over ZeroMQ.
The ts_agent main entrypoint runs a Spectrum server that listens to incoming messages:
- The SDK is
spectrum-ts. - iMessage is enabled via the provider
imessage.config(). - Incoming messages arrive on
app.messagesas[space, message]tuples.
Spectrum (iMessage, required for npm start)
PROJECT_IDPROJECT_SECRET(orSECRET_KEY)
Grok (required for preference extraction and headline analysis)
XAI_API_KEYXAI_BASE_URL(optional, defaulthttps://api.x.ai/v1)GROK_MODEL(optional; auto-fallback if unset)
ZeroMQ (optional)
ZMQ_ENDPOINT(defaulttcp://127.0.0.1:5555; must matchcpp_enginebind address)
The ts_agent main process runs two concurrent loops:
- Reactive (Spectrum):
for awaitonapp.messages— dispatches each inbound text to a per-space handler (non-blocking on the iterator). - Proactive (ZeroMQ): a background
SUBsocket in the same process — analyzes headlines and sends alerts when thresholds match.
For debugging, inbound iMessages are logged with space.id and sender.id; ZMQ headlines are logged as [ZMQ] headline: ....
SpaceOutboundCoordinator prevents races between user-driven replies and proactive alerts:
- Alert hold: while an inbound message is handled for a
space.id, proactive alerts to that space are deferred. - FIFO queue: all outbound work for a space runs through
spaceOutbound.run(spaceId, kind, fn)withkindof"user"or"alert". - Re-check on send: alert tasks re-read
userPreferencesimmediately beforeapp.send, so a settings change that finishes during a hold can still affect whether the alert fires.
Inbound handlers use void spaceOutbound.run(spaceId, "user", …) so one slow Grok call does not block other conversations.
When a user sends a text message, ts_agent extracts macro trading preferences using the configured LLM and stores them in-memory:
userPreferences: Map<string, UserPreferences>- Key:
space.id(conversation id) - Value:
{ trackedKeywords: string[], watchlist: string[], severityThreshold: number, sourceTrustThreshold: number, alertsPaused: boolean } spacesById: Map<string, Space>— caches the Spectrumspacehandle for proactive outbound sends (populated on each inbound message)alertHistoryBySpace: Map<string, AlertContext[]>— ordered newest-first list of up to 10 recent alert contexts per space, each with a 30-minute TTL. Used for conversational follow-ups. EachAlertContextincludes the headline, source, Grok analysis, send timestamp, and theOutboundMessage.idof the alert we sent (used for best-effort thread-reply matching on platforms that expose it).unrespondedAlertCountsBySpace: Map<string, number>— counts consecutive proactive alerts per chat. Any inbound user message resets the count; after 15 proactive alerts without a response, the agent setsalertsPaused = trueand asks the user to textcontinue.
This is intentionally ephemeral (memory-only). Persisting to a DB can be added later once the preference schema stabilizes.
After a proactive alert is sent, the agent appends AlertContext to a per-space history (alertHistoryBySpace). On later inbound text:
- Follow-up detection: if the message looks like a question (ends with
?, starts with why/summarize/explain/what/how, mentions hawkish/dovish, etc.) and at least one unexpired alert is in the history, it is routed as a follow-up rather than a preference update. - Multi-alert resolution: all unexpired alert contexts (up to 10, each within 30 minutes of being sent) are passed to Grok together. The model identifies which alert the question is most relevant to and answers about it. For an unambiguous question it answers directly; for an ambiguous one it briefly notes which alert it chose.
- Thread-reply matching (best-effort): if the platform delivers the message as a Spectrum
content.type === "reply"pointing to one of our sent alert message IDs, only that specific alert context is passed — no multi-alert resolution needed. Note: the iMessage provider does not expose thread-originator GUIDs on plain inbound text messages at the Spectrum level, so long-press → Reply in iMessage arrives as a plain text message and takes the multi-alert resolution path above. The thread-reply path is preserved for platforms that do expose this. - The response is sent with
message.reply()so it threads in iMessage.
Preference-shaped messages (e.g. "alert me on CPI", "threshold 0.5") still go through preference extraction even if alert history exists.
Preferences are extracted by calling an OpenAI-compatible chat completions endpoint.
- Defaults to xAI (Grok):
XAI_BASE_URL(defaulthttps://api.x.ai/v1)XAI_API_KEYGROK_MODEL(optional)
If GROK_MODEL is unset, the agent tries grok-4.3 first (if available on your account) and will auto-fallback by querying /models if the chosen model id is not available.
If the LLM call fails (or no API key is set), the agent falls back to defaults.
- A reply like "Sorry — I couldn't update your preferences right now." indicates the preference extraction step threw an error (network issue, non-OK API response, etc.).
- A "Got it — saved…" reply with
(none)and0.6indicates the agent used defaults (commonly becauseXAI_API_KEYis missing/blank, the LLM returned empty content, or the JSON schema didn’t match expectations). - The agent also dedupes duplicate inbound events when Spectrum delivers the same iMessage more than once, to avoid repeated replies.
To prevent accidentally running two agents at once (which would cause duplicate replies), ts_agent creates a lock file in your OS temp directory (macOS example: /var/folders/...). If a second instance starts and sees the lock held by a live PID, it exits with an error and prints the PID to stop.
The repo uses two ZeroMQ PUB/SUB channels in opposite directions:
- Headlines (C++ → Node): the engine publishes matching headlines; the agent consumes them in-process alongside Spectrum.
- Filter set (Node → C++): the agent publishes the union of all users' interests so the engine knows what to forward (see Filter-set channel below).
cpp_enginebinds aPUBsocket (defaulttcp://127.0.0.1:5555).- Bind endpoint override: positional CLI argument (any non-flag arg), e.g.
./cpp_engine --live tcp://127.0.0.1:5556. - Source selected by flag:
--simulate(default; mock headlines every 2 s),--live(Finnhub REST poll every 2 s; needsFINNHUB_API_KEY), or--live --demo(real Finnhub data with capped recent backlog on first fetch + paced emissions; optional--pace-ms=,--backlog-hours=,--backlog-max=orDEMO_*env vars). - Only headlines matching the active keyword filter are published. The filter defaults to the built-in macro keyword list below, but is replaced at runtime by the agent's filter set when one is connected.
Built-in macro keyword filter (case-insensitive substring; the default/fallback): FOMC, CPI, PCE, inflation, Fed, Powell, Rates, ECB, Treasury, yield, jobs, payroll, unemployment, GDP, recession, tariff, stimulus, central bank, bond.
User preferences (tracked keywords + watchlist tickers) live only in the agent, so the agent pushes them to the engine to control what gets forwarded:
- The agent binds a
PUBsocket onFILTER_ENDPOINT(defaulttcp://127.0.0.1:5556); the engine'sFilterSubscriberconnects aSUB. - Frame:
{"type":"filterset","terms":[...]}, wheretermsis the case-insensitively de-duplicated, sorted union of everyuserPreferencesentry'strackedKeywords+watchlist. - The agent publishes on every preference change and on a ~5 s heartbeat (
FILTER_HEARTBEAT_MS). Because PUB/SUB drops messages to absent subscribers, the heartbeat is what lets a late/reconnecting engine converge; the engine ignores no-op repeats. - An empty term set makes the engine fall back to its built-in macro keywords (e.g. no users yet). This is consistent with the agent's own "empty keyword set ⇒ match all" rule, and since the agent skips analysis entirely when it has no preferences, no Grok calls are wasted.
- This channel only decides what the engine forwards. Final per-user routing (which chat gets an alert) is still done in the agent's
shouldAlertUser, which matches each headline against that user'strackedKeywords∪watchlistand the severity threshold.
index.tsstartsrunZmqHeadlineSubscriber()concurrently with the Spectrum message loop (same Node process).- Connects via
ZMQ_ENDPOINT(defaulttcp://127.0.0.1:5555). - Accepts any
type: "headline"JSON frame (no second hardcoded keyword filter in the main agent).
Headline pipeline
- Parse JSON frame (including the
sourcepublisher); dedupe byts+ headline (or headline alone) for ~2 minutes. - Call Grok (
analyzeHeadlineWithLlm, passing the headline + source) →{ sentiment, severity, summary, sourceTrust }.sourceTrust(0–1) is Grok's credibility rating of the publisher; an absent/garbled rating defaults to ~0.3 (low). - For each
userPreferencesentry, enqueuespaceOutbound.run(spaceId, "alert", …)(waits if that space has an active alert hold). - Inside the alert task, re-check that alerts are not paused, keywords match,
severity >= severityThreshold, andsourceTrust >= sourceTrustThresholdagainst current preferences, thenapp.send(space, alertText)and append the result toalertHistoryBySpace(along with theOutboundMessage.idfor best-effort thread-reply matching). The alert text and stored context include the source name and itslow/medium/hightrust label. (severityThresholdis the user-facing “threshold”; it gates on market impact, not bullish/bearish sentiment.) Each sent proactive alert increments the per-chat unresponded count; the 15th consecutive alert auto-pauses that chat and sends a notice telling the user to textcontinue.
If no user has messaged since startup, preferences are empty and headlines are logged but not alerted. If preferences exist but spacesById lacks that space.id, the agent logs that the user must message first (Spectrum needs a cached conversation handle for outbound iMessage).
npm run subruns a standaloneSUBCLI that only logs headlines matching its own hardcoded macro keyword list insubscriber.ts.- Note: this list is maintained separately from the C++ engine's
MacroFilterand is currently narrower (FOMC,CPI,Rates,Powell), so the debug subscriber may show fewer headlines than the engine publishes. - Useful for verifying ZMQ without Spectrum, Grok, or iMessage.
Message framing
- No topic frames are used right now.
- Each publish is a single frame: UTF-8 JSON string with fields:
type:"headline"ts: UTC ISO8601 timestampheadline: headline textsource: publisher of the headline (e.g."Reuters"); empty string when the upstream feed omits it (the agent treats unknown sources as low trust)
For a simple “agent asks, engine answers” loop:
- REQ/REP sockets
- Engine binds to a local endpoint (e.g.
tcp://127.0.0.1:5555) - Agent connects
When you need more throughput / concurrency, consider:
- ROUTER/DEALER (async request/response)
- PUSH/PULL (work queue)
- PUB/SUB (broadcast events)
ZeroMQ messages are frames (byte arrays). A pragmatic baseline is:
- Frame 0:
request_id(UTF-8) - Frame 1:
content_type(e.g.application/json) - Frame 2: payload bytes
If you pick JSON as a first version, define a minimal envelope like:
type: string (command name)payload: objectts: number (unix millis)
Add versioning early (schema_version) so you can evolve without breaking.
ZeroMQ is not a message broker; you must design for:
- timeouts on the client side
- retries / idempotency if you resend
- a clear contract for “at most once” vs “at least once” behavior
- Ensure libzmq is installed (macOS:
brew install zeromq). - If you have multiple brew prefixes, make sure your shell environment exposes the right
pkg-configpaths.
zeromq may download a prebuilt binary or compile native components depending on platform/version. If compilation is required, make sure you have:
- Xcode Command Line Tools on macOS (
xcode-select --install) - a supported Node.js version
- C++ entrypoint:
cpp_engine/src/main.cpp - C++ build config:
cpp_engine/CMakeLists.txt - TS entrypoint (Spectrum + ZMQ alerts):
ts_agent/src/index.ts - TS per-space outbound ordering:
ts_agent/src/spaceOutbound.ts - TS ZMQ debug CLI:
ts_agent/src/subscriber.ts - TS build config:
ts_agent/tsconfig.json - Node deps:
ts_agent/package.json