Signals feedback loop — plain-language explainer
The Signals feedback loop watches how a person actually reacts to content — what they rate, save, dwell on, dismiss, skip — and quietly makes the next thing they see fit them better.
A People Analytics Toolbox cross-cutting primitive (src/lib/signals/, contract 0.1.0). Not a spoke: it lives in src/lib/ because every property in the portfolio is a candidate emitter, and the loop that turns signals into better-ranked content is toolbox-owned. Every claim below is grounded in the primitive's own code and contracts; anything not yet built is marked (TBD).
Lead
One person, many small reactions, a feed that gets sharper for them with every one.
This is the FULLER, multi-movement treatment, because the Signals loop is one of the surfaces a developer (and, downstream, an end reader) actually meets — the rate-to-personalize loop that the whole cross-property content premise rides on.
Manifesto — why it exists
The worldview: content should learn from the person consuming it, and it should do so from more than a single thumbs-up. Most personalization in this space is either invisible (a black-box "recommended for you" with no contract you can inspect) or starved (one explicit rating, ignored the moment the page reloads). Neither composes across more than one product.
The pain it removes, stated as a shift:
- FROM a feed that shows everyone the same cards in the same order, where the one rating someone gives evaporates on the next visit, and where each property re-invents its own ad-hoc "liked this" table.
- TO a single content-agnostic signal envelope — twelve signal kinds, explicit and implicit — written to one durable store, read back as a per-person affinity that re-ranks the next queue, and shared by every property that vendors the contract.
The premise in one line, lifted from the contract header itself: a feedback loop fed by MANY signals makes content better and better for each user over time. The 1–5 rating is one signal (kind: "rating"); the envelope captures the other eleven — thumb, save, dismiss, act, reviewed (explicit) and view, dwell, expand, complete, share, skip (implicit).
How it differs from the obvious substitutes:
- vs. doing it by hand — there is no "by hand" for a per-reader affinity that updates every interaction; the alternative is no personalization, or a hard-coded editorial order.
- vs. generic BI / a dashboard — BI reports on engagement after the fact; this loop acts on it before the next render, in the request that builds the queue.
- vs. a black-box recommender — the unit of input is a typed, versioned
Signalthe consumer owns a copy of; the scoring math is a pure, deterministic, readable function (affinityScore), not an opaque model. You can audit exactly why a card ranked where it did.
Visual — Tier B (FROM→TO typographic block). The shift above is the visual: same-order feed, evaporating single rating, per-property "liked" tables → content-agnostic 12-kind signal envelope · one durable store · per-person affinity re-ranking the next queue · shared by every property.
Walkthrough — how it actually works
There are two journeys through this primitive: the end reader's (signals captured → feed re-ranked) and the developer's (vendor the contract → POST signals → read affinity). Both run on the same envelope and the same store.
The reader's journey, through the real /feed surface (src/app/(surfaces)/feed/):
- Identity. On first touch the loop mints an opaque per-browser actor key —
anon_<uuid>in anhttpOnlycookie namedpa_actor(src/lib/signals/actor.ts). It is deliberately not a toolbox user id, so an external-site reader can later be mapped to the same key without reshaping the store. - Capture. As the reader moves through paced insight cards, the player engine emits signals — a
ratingwhen they grade a card, plus implicitview/skip/dwell. The<Feed>component'sonSignals(signals[])callback hands the whole batch to a Server Action (recordPlayerSignalsAction,src/surfaces/analytics-player/lib/signal-callback.ts), which is the trust boundary: it resolves the real actor from the cookie server-side and overridesactorIdon every signal before persisting. Each signal carries the card's features (topicTags,cardType,sourceApp) incontextso the personalizer never has to re-fetch the content. - Store. Signals land in the durable store (
getSignalStore()), which selects Postgres (signals.signal_event) wheneverDATABASE_URLis set and an in-memory map otherwise — so serverless cold starts don't lose history. - Personalize. On the next render, the feed page reads the actor's recent history (
historyFor(actorId, { limit: 500 })), builds an affinity function (buildAffinity), and passes it intobuildPlayerQueue(input, { affinity }). Affinity is feature-based — it learns per-topic, per-source, per-card-type sentiment — so every signal informs the ranking even though each one is on a specific card. - Show the work. When personalization is active, the page renders a small badge: "Personalized from N of your interactions" — N is the literal history count, not a vanity number.
The reincarnation-flavored guardrail: affinity boosts what works and dampens what doesn't (WEIGHTS.affinity = 0.5 in buildPlayerQueue), but the sequencer keeps an exploration budget (Vela's "25% exploratory" pattern) so the feed never collapses onto a narrow high-confidence cluster. Focus and freshness still carry weight; affinity reorders without overpowering.
The developer's journey, through POST /api/signals/ingest:
- Vendor
src/lib/signals/contract.tsinto your repo (exactly like the Insight Card contract). - POST one
Signalor aSignal[]batch (capped at 1000) to/api/signals/ingest. The route is service-key-gated (PAT-11) because it is a write. - The caller is trusted to set each signal's
actorId— that is how cross-property identity works. A property with an authenticated user asserts a stable key it controls, so the same human shares affinity across properties. - The loop reads it back wherever the same
actorIdappears next.
Visual — Tier A (live API capture). A real batch POST to the running ingest route:
POST /api/signals/ingest
x-toolbox-service-key: <key>
[
{ "actorId": "anon_8f3c…", "subjectId": "card_attrition_q2",
"subjectType": "metric-spotlight", "property": "toolbox",
"sourceApp": "manager-effectiveness", "kind": "rating", "value": 5,
"context": { "topicTags": ["retention","engagement"], "cardType": "metric-spotlight" },
"occurredAt": "2026-05-29T18:02:11.004Z" },
{ "actorId": "anon_8f3c…", "subjectId": "card_comp_band_drift",
"subjectType": "metric-spotlight", "property": "toolbox",
"sourceApp": "anycomp", "kind": "skip",
"context": { "topicTags": ["compensation"] },
"occurredAt": "2026-05-29T18:02:19.220Z" }
]
→ 202 {
"ok": true,
"accepted": 2,
"contractVersion": "0.1.0",
"receivedAt": "2026-05-29T18:02:19.640Z"
}
(Shape is the route's real 202 response from src/app/api/signals/ingest/route.ts; the contractVersion field is the literal SIGNALS_CONTRACT_VERSION = "0.1.0". actorId truncated for display.)
What it enables
Concrete uses, each grounded in shipped behavior:
- A feed that learns from one reader — the
/feedsurface re-ranks per actor from their own signal history (live today). - Cross-property affinity — a reader who rates compensation cards in one property sees the loop carry that affinity into another property that asserts the same
actorId(the ingest route + caller-asserted identity make this the supported path). - Implicit-signal learning, not just stars — a
dwellof 40 seconds, anexpand, acompleteall move affinity even when the reader never explicitly rates (KIND_SENTIMENTbaselines inpersonalize.ts). - "Less like this" that sticks — a
dismiss(sentiment −1) or repeatedskip(−0.5) durably dampens that topic/source for that actor. - Post-sequence reflection — at the end of a session the loop can summarize what it learned (
POST /api/player/reflection/generate): rated/resonated/passed counts, total dwell, top topics + sources, and a 1–2 sentence LLM-authored read (Claude Haiku, with a deterministic English fallback) — persisted tosignals.sequence_reflection. - Operator liveness check —
/operator/signalsaggregates the durable store (total signals, unique actors, unique properties, breakdown by kind and by property, recent events + reflections) so an operator can answer "is the loop alive in prod?"
How it fits in the toolbox
This is a cross-cutting src/lib/ primitive, not a spoke — so it is not in src/lib/contracts/registry.ts and exposes no MCP tools (both confirmed against the code). It composes with the other cross-cutting primitives by contract, never by reaching into a spoke's internals:
- Insight Card primitive (
src/lib/insight-player/contract.ts) — affinity is computed againstInsightCardfeatures (topic_tags,source_app,card_type). The signal envelope'ssubjectTypeis generic, so the loop is content-agnostic, but the Insight Card is its first and primary subject. - The player sequencer (
src/lib/insight-player/sequence.ts) —buildPlayerQueuetakes the affinity function as an option and blends it with focus + freshness + diversity under a fixed exploration budget. The loop supplies the personalization signal; the sequencer owns the ranking. - Any emitting spoke, by API only — the cards a reader signals on are emitted by spokes (
manager-effectiveness,anycomp,preference-modeler,forecasting, …) through theiremit-insight-cardsendpoints; the loop records signals about those cards. It never imports a spoke'score/.
Contracts in play:
- Owned + vendored:
src/lib/signals/contract.ts—Signal,SignalKind,SignalPolarity, the ingest request/response shapes,SIGNALS_CONTRACT_VERSION = "0.1.0". Consumers vendor a copy; bump-via-toolbox is the only path to evolve it. - Consumed:
src/lib/insight-player/contract.ts(InsightCard). - Derived persistence:
SessionReflectionPayload(schemaVersion: "1.0", versioned separately from the signals contract).
Consumers that meet it: the toolbox /feed surface today; Performix, rankandfile, vela, and the peopleanalyst-site magazine are the named candidate emitters in the contract header (they ingest over the HTTP front door as they wire up).
Visual — Tier B (typographic data-flow). spoke emit-insight-cards → /feed cards → reader reactions → Signal[] → recordPlayerSignalsAction / POST /api/signals/ingest → signals.signal_event → buildAffinity(history) → buildPlayerQueue({ affinity }) → re-ranked /feed. The cross-property branch tees in at the ingest route, asserting actorId.
One load-bearing worked example
A single actor's affinity, computed end-to-end through the real personalize.ts math — no invented model, just the published scoring functions applied to the signals shown above.
Input — the actor's signal history (the two signals from the ingest capture, as historyFor would return them):
ratingvalue5oncard_attrition_q2, sourcemanager-effectiveness, topics["retention","engagement"], typemetric-spotlight.skiponcard_comp_band_drift, sourceanycomp, topics["compensation"].
Step 1 — per-signal sentiment (signalSentiment):
- The rating of
5:clamp((5 − 3) / 2)= +1.0 (a 5/5 centers at 3, scales to [−1,1]). - The skip: no value, no polarity →
KIND_SENTIMENT["skip"]= −0.5.
Step 2 — affinity profile (buildAffinityProfile, each signal weight defaulting to 1):
topics:retention+1.0,engagement+1.0,compensation−0.5.sources:manager-effectiveness+1.0,anycomp−0.5.types:metric-spotlight+1.0 (only the rated card carried acardType).n= 2 contributing signals.
Step 3 — score the next two candidate cards (affinityScore, weights topics 0.6 / source 0.25 / type 0.15):
- A retention card from manager-effectiveness, type metric-spotlight — topics avg
+1.0(w 0.6), source+1.0(w 0.25), type+1.0(w 0.15) → weighted mean +1.0. Ranks high. - A compensation card from anycomp — topic
compensation−0.5(w 0.6), sourceanycomp−0.5(w 0.25); no learned type overlap → weighted mean −0.5. Ranks low. - A card on a brand-new topic the actor has never touched — no feature overlap →
affinityScorereturns 0 (neutral): it rides focus + freshness + the exploration budget, exactly so the feed keeps surfacing the unknown.
That third case is the load-bearing detail: the loop boosts what works and dampens what doesn't, but a zero-overlap card scores neutral, not negative — the exploration budget, not the affinity score, is what keeps the feed from collapsing onto a narrow cluster.
Every number here is the literal output of the functions in src/lib/signals/personalize.ts (signalSentiment, buildAffinityProfile, affinityScore) on the worked input — illustrative card titles, real math.
Commercialization / packaging
The Signals loop is infrastructure, not a sold line item — it is the cross-portfolio premise (every property emits, the toolbox owns the loop), so it sits behind reader-facing surfaces rather than being priced on its own.
- Data-license posture: the loop stores only behavioral signals keyed by an opaque actor id and the consumer-asserted
tenantId; it carries no PII in the envelope. The actor key is deliberately opaque so identity resolution is a downstream concern, not baked into the store. - Tenant scoping:
tenantIdis optional on the envelope (omit for public/external content) and indexed on the table, so org-scoped affinity and public-content affinity coexist in one store. - Pricing tiers / packaged offerings are (TBD) — not earned yet, so not stated.
Visual — (TBD — product-placement diagram once the loop is a named line in a packaged offering).
Current status
Grounded in the real code state (src/lib/signals/, contract 0.1.0):
- Shipped: the signal contract (12 kinds, explicit + implicit,
0.1.0); durable Postgres store onsignals.signal_eventwith the in-memory fallback; the personalization brain (signalSentiment/buildAffinityProfile/affinityScore/buildAffinity); opaque per-browser actor identity; end-to-end capture on/feedvia the player engine and therecordPlayerSignalsActiontrust boundary; affinity re-ranking wired intobuildPlayerQueue; the cross-propertyPOST /api/signals/ingestfront door (service-key-gated, caller-asserted identity, batch ≤ 1000); post-sequence reflections (POST /api/player/reflection/generate, persisted tosignals.sequence_reflection, LLM copy with deterministic fallback); the/operator/signalsliveness aggregate. - In flight / planned: routing signals back to the source spoke for the spoke's own learning (noted as a follow-up in
signal-callback.ts); cross-property identity resolution — merging a pre-login anonymous key into a post-login identity, and linking one reader across sites (the deferred identity-link refinement called out inactor.tsand the ingest route); asequenceIdpredicate onhistoryFor(today the reflection route filters client-side); (TBD) an MCP surface and registry enrolment (this is asrc/lib/primitive, so neither exists today by design).
Visual — Tier A (live readout). /operator/signals reports the real shipped totals at request time — total signals, unique actors, unique properties, breakdown by kind and property, and the reflection rollup — directly from the durable store (getSignalStats). It is the runtime proof the loop is alive.
The vision
Every property in the portfolio emitting many small signals into one loop, so the content each person meets — anywhere — gets quietly, continuously better for them; the toolbox owns the loop, the properties just vendor the contract.
The arc from here: close the loop back to the spokes (so a spoke learns which of its own cards land), resolve identity across properties (one reader, one affinity, many sites), and broaden the subject types beyond insight cards (learning modules, marketing sections, media units — the envelope is already content-agnostic). The math stays readable and the contract stays vendored: this is a loop you can audit, not a black box you have to trust.