Company Signals Monitoring
A push-based layer on the data graph. It watches facts change, decides which changes are hot, matches each one against every agency's ICP rules, and fans the survivors out to the right inbox.
Why push
problemStatic company data goes stale fast (B2B decay ~2.1% / month). The moment a brand starts running Meta ads, posts a Head-of-Growth role, or closes a Series A is a finite window to win the conversation. Today we only surface this when a user runs a Sonar / Headhunter search - pull-based and rare.
A push layer that detects signal transitions and routes them to the matching agency inbox with no human in the loop. Sibling of the data graph - facts live in the intelligence databases, this watches them change.
System shape
two planes A data plane (collectors writing append-only events) and a match plane (each agency's ICP rule set, evaluated per event). They meet at signal_events; survivors land in a per-org signal_inbox.
company_id · kind · payload · severity · observed_atorg_id · event_id · score · statussignal_events table. Match plane = each agency's ICP rule set evaluated per event. One event fans out to N inbox rows, one per matching subscription. System architecture
C4 · containersThe same two planes as concrete, deployable containers - one colour per repo. The data path runs top to bottom; each edge is labeled with what crosses it and how. Solid edges are request-path / synchronous; dashed edges are scheduled or async. Hover any container for what it owns, or trace one event down the happy path.
ac-monitor collects, ac-backend owns the signal tables and the intel_* write-back, the Claude managed agent scores on Anthropic infra, ac-python-api runs the enrichment fan-out + relay + delivery, ac-frontend renders the inbox. The new middle tiers are the heart: on a survivor the agent completes the pair (enrich_company → Headhunter DM → enrich_person), each resolved fact is written back to intel_* with provenance, and the fills stream to the open tab as agent.progress / agent.done frames - see §03. Email and Slack are views over signal_inbox; the inbox row is the single source of truth. Stream contract
two SSE legs Two SSE legs meet at the ac-python-api relay. Upstream we subscribe to the managed agent's hosted session stream; downstream we emit our own SSE to the browser - the same frame contract the agent-run inbox already speaks. The relay is the only place the two touch.
Claude managed agent (hosted session)SSE ↓ac-python-api · event relayassistant turn · tool_use · tool_result · message_stopThe hosted loop runs on Anthropic infra. We never see the loop - only the events it streams. The relay answers tool_use by running the enrichment fan-out (enrich_company / enrich_person / Headhunter) and posting tool_result back into the same session, and mirrors the user-visible subset into Supabase for audit.
ac-python-api · SSE fan-outSSE ↓ac-frontend · EventSourceinbox.insert · inbox.update · agent.progress · agent.doneOne LISTEN consumer + the relay's in-process agent.progress feed merge into a pub/sub fanned to N browser tabs. The frontend keys every frame on inbox.id and upserts - inserts prepend a card, updates patch it in place. Same frame contract the agent-run inbox already speaks.
tool_use by running the enrichment fan-out and posting tool_result back into the live session, mirrors the user-visible subset into Supabase, and writes the signal_inbox row. Everything downstream reads from that row - so a managed-agent run and a plain deterministic insert reach the tab through the identical frame schema. Downstream frame schema what each frame does to the Pinia store
inbox.insert{ id, company, kind, score, status:"new" }Prepend a new card (or no-op if id already present - replay-safe).agent.progress{ id, phase:"enriching", note }Patch the card to a live "enriching…" state while the agent works.agent.done{ id, rationale, draft }Fill in the rationale + drafted outreach on the existing card.inbox.update{ id, status:"read"|"dismissed"|"added" }Patch status in place - a write from any surface or tab lands here.Lifecycle invariants the rules the frontend leans on
Signal taxonomy
agency lensStart narrow - ship a handful of high-signal kinds before adding more. Each kind has its own collector and dedup window. Click a kind to inspect why it is hot for agencies.
started_meta_adsStarted Meta ads
Why hot: New advertiser = budget appeared, no agency yet locked in.
Source: Meta Ad Library diff
ICP rule matcher
the agent's gate tool The rules stay declarative - a JSON AST, no code per subscription. With the scoring agent in front (§02), this AST is no longer the whole engine: it is the deterministic gate tool the agent calls to bound its reasoning. Configure a sample company and its live signals; the AST evaluates leaf-by-leaf into a weighted score, normalized 0-1. Cross the threshold (default 0.6) and the agent proceeds to draft; below, it drops.
High score and a hot signal. Surfaces in every configured channel.
industry 1.00 · country 0.80 · size 0.80 · any-signal 1.40. Per-org tuning ships later. Schema additions
tables Three new tables, all global-read / service-role write like intel_*. signal_events is global and append-only; signal_inbox is org-scoped (RLS by org_id).
signal_events
intel_company_id FKkind textseverity intpayload jsonbsource textobserved_at tsdedup_key UNIQUE
signal_subscriptions
org_id FKname texticp_rules jsonbmin_severity intchannels text[]paused bool
signal_inbox
org_id FKsignal_event_id FKsubscription_id FKmatch_score numericrationale textdraft textagent_session_id textstatus textdelivered_at tsUNIQUE(org,event)
signal_inbox carries UNIQUE(org_id, signal_event_id) and signal_events a dedup_key of company|kind|event_key, where event_key is the type-specific identity (funding → stage, exec change → role + person, hiring → role-family) - not a coarse week bucket that would collapse two distinct same-week events. This is the same shared event-key the Sonar pull pipeline dedups on, so both converge on one canonical row. Many sources for one event become corroboration on that row, not duplicate inserts. One event fans out to N inbox rows, one per matching subscription - but never twice to the same org.
Collector cadence
celery beatEach source polls on its own beat and diffs against the last snapshot. Per-host token bucket; failure → backoff + dead-letter. A stale collector pages oncall - missed signals are the failure mode that hurts most.
Delivery fan-out
inbox is truth The inbox is the source of truth. Email and Slack are views - a dismiss in any surface writes back to signal_inbox.status.
Realtime open Sonar tab
On insert + agent progress, push inbox.insert / agent.progress / agent.done / inbox.update frames to any open tab. The frontend upserts by inbox.id. Full leg + frame schema in §03.
Daily digest sendgrid
SELECT ... WHERE status='new' AND delivered_at IS NULL at 07:00 org-local, grouped by subscription into one templated email.
Slack / webhook per subscription.channels
Optional fan-out for orgs that wire it. Same inbox row, additional view.
Build order
ship to validateStop after step 3 and ship to staging with one design-partner agency. Validate signal quality before adding sources - more collectors on a noisy matcher just amplifies noise.
Schema + RLS
signal_events + signal_inbox (incl. rationale / draft / agent_session_id) + signal_subscriptions, pgTAP RLS, dedup_key unique constraint.
One collector + scoring agent end-to-end
meta_ad_library_poll → signal_events → managed-agent session → relay persists score + rationale + draft → signal_inbox row → email digest, for one hard-coded org.
Subscription CRUD UI ship here ✓
Sonar settings page: JSON AST rule builder (the agent's gate tool), severity threshold, channel toggles.
Second collector
greenhouse_lever_ashby - proves the collector abstraction generalizes.
SSE realtime push (both legs)
Relay re-streams agent.progress / agent.done plus LISTEN inbox.insert / inbox.update into the open Sonar tab.
Slack / webhook fan-out
Per-subscription channel delivery beyond inbox + email.
Agent + score tuning
Per-org analytics on open / dismiss / added-to-CRM, fed back into the scoring prompt and the AST gate.
Open questions
undecidedSubscription quota
Cap per-org subscriptions (~25) to bound matcher work? Probably yes; revisit on real usage.
Backfill window
Replay last N days of events into a new subscription's inbox. Default 7 days, configurable - useful first impression, noisy long-term.
Cold-start coverage
Ad-library polls surface companies never ingested. Auto-upsert into intel_companies from the collector so events always have a parent row.
Decay vs persistence
Keep signal_events forever (audit, ML) but expire signal_inbox rows after 90 days to bound UI noise. Confirm with product.
Every source is public business data - no personal scraping. A payload naming a hire falls under GDPR Article 14: store the source URL, expose an opt-out, honor takedowns within 30 days. Filter by redistribution_allowed before composing any digest body. Each org's signal_inbox is RLS-isolated - no cross-org leak of dismissals or notes.