Docs
Early Access hello@polariapi.com
Intelligence Layer · echo.api.polariapi.com

What the public
says about the story.

Echo enriches Polari story clusters with public discourse signal — controversy scores, dominant themes, counter-narratives, and sentiment from Reddit, Bluesky, and X/Twitter. It tells you what editorial coverage missed.

Reddit Bluesky X / Twitter
Overview

Echo sits above the pipeline

The four pipeline layers process editorial content — articles, their entities, their clusters, their relationships. Echo is different. It listens to what happens in public discourse after a story is established, and attaches that signal back to the cluster.

L0 L1 L2 L3
Echo public reaction to what L3 already knows
Reddit comments, Bluesky posts, and Twitter/X posts are not editorial — they don't feed the pipeline from the bottom. They are processed separately and their derived signal is attached to the clusters they respond to.

This means Echo enrichment is always cluster-scoped. You need a cluster_id from Layer 2 to query Echo. The enrichment you get back tells you how the public received that story — which entities they introduced that editorial coverage didn't mention, whether they're pushing back on the dominant narrative, and how much controversy the story generated across platforms.

contested A new lifecycle state, driven by Echo

Polari story clusters move through lifecycle states as editorial coverage evolves: breakingdevelopingmatureconcluded. Echo introduces a fifth state: contested.

A story becomes contested when editorial coverage has gone quiet — the story is concluded from a journalism standpoint — but public discourse is still active and generating high controversy (≥ 0.6) or a high-confidence counter-narrative (≥ 0.7), with enrichment freshness within 48 hours. The story isn't over. The public just hasn't caught up, or disagrees with how it was covered.

contested surfaces on Layer 2's story listGET /v1/stories — computed live per-story on every read when Echo enrichment is present. It is also computed and persisted whenever POST /v1/story/{id}/analyze runs. The single-story read, GET /v1/story/{id}, does not currently compute or return lifecycle_state at all. It is not a field on the Echo endpoints documented below either way.

Currently considers Reddit and Bluesky signal only. Twitter/X enrichment does not yet feed into the contested calculation — a story can have high Twitter controversy or a confident Twitter counter-narrative and still not trigger the state. This is expected to be resolved as Twitter's dataset matures; the Echo endpoints themselves (search, trending, story) already return Twitter data in full regardless.


API Reference

Endpoints Professional+

Base URL: https://echo.api.polariapi.com · All endpoints require Authorization: Bearer {key} except /health. Starter tier keys receive 403 tier_restricted.

GET /v1/echo/story/{story_id}

The primary Echo endpoint. Returns the full public discourse enrichment for a story cluster — sentiment, controversy, dominant themes, entities, and counter-narrative analysis, broken down by platform.

GET https://echo.api.polariapi.com/v1/echo/story/{story_id}
BASH
curl https://echo.api.polariapi.com/v1/echo/story/clus_9x3k2m8f \ -H "Authorization: Bearer pk_live_your_key"
RESPONSE
{ "story_id": "clus_9x3k2m8f", "enrichments": [ { "platform": "reddit", "thread_urls": ["https://reddit.com/r/worldnews/comments/..."], "fetched_at": "2026-06-04T17:18:44Z", "comment_count_analyzed": 10, "sentiment_score": -0.35, // –1 negative → +1 positive "sentiment_label": "negative", "controversy_score": 0.80, // 0–1; above 0.6 can trigger `contested` "dominant_themes": [ "Israel-Lebanon ceasefire", "AI disinformation", "Iran conflict" ], "editorial_entities": ["Iran", "Israel", "Benjamin Netanyahu"], // baseline: the story's own editorial entity set "public_entities": ["Iran", "Israel", "Tucker Carlson", "Marge Greene"], // everything extracted from public discourse "novel_entities": ["Tucker Carlson", "Marge Greene"], // ← public_entities minus editorial_entities "counter_narrative_detected": true, "counter_narrative_summary": "Public discourse introduced AI disinformation framing and domestic US political figures absent from editorial coverage, suggesting the story is being received through a different lens than reported.", "counter_narrative_confidence": 0.85, // above 0.7 can trigger `contested` "updated_at": "2026-06-04T17:18:44Z" }, { "platform": "twitter", "thread_urls": ["https://x.com/..."], "fetched_at": "2026-08-06T09:12:00Z", "comment_count_analyzed": 6, "sentiment_score": -0.41, "sentiment_label": "negative", "controversy_score": 0.72, "dominant_themes": ["Iran nuclear", "US foreign policy"], "editorial_entities": ["Iran", "Israel"], "public_entities": ["Iran", "Israel", "AIPAC", "Axios"], "novel_entities": ["AIPAC", "Axios"], "counter_narrative_detected": false, "counter_narrative_summary": null, "counter_narrative_confidence": 0.0, "updated_at": "2026-08-06T09:12:00Z" } ], "count": 2 }
Field Type Description
platform string reddit, bluesky, or twitter. One object per platform with enrichment data.
thread_urls string[] Source thread/post URLs the enrichment was derived from.
fetched_at / updated_at string (ISO 8601) When the underlying content was fetched, and when this row was last written. Usually equal; updated_at advances on re-enrichment even if fetched_at logic doesn't change.
sentiment_score float Aggregate sentiment across analyzed posts/comments. –1 (negative) to +1 (positive).
controversy_score float 0–1. Scores ≥ 0.6 contribute to contested lifecycle state (see callout above).
dominant_themes string[] LLM-synthesized themes from public discourse — often diverge from editorial framing.
editorial_entities string[] The story's own editorial entity set — the baseline novel_entities is computed against. Not "entities in both."
public_entities string[] All named entities extracted from public discourse for this platform, including ones that overlap with editorial coverage.
novel_entities string[] public_entities minus editorial_entities — entities the public is discussing that editorial coverage never mentioned. The primary counter-narrative signal.
counter_narrative_detected bool Whether a counter-narrative was identified.
counter_narrative_summary string | null Natural language description of the counter-narrative, synthesized by LLM from public posts. Null if not detected.
counter_narrative_confidence float 0–1. Confidence ≥ 0.7 contributes to contested state.
GET /v1/echo/stories

Batch retrieval of Echo enrichments for up to 50 story clusters in a single request.

GET https://echo.api.polariapi.com/v1/echo/stories?ids=clus_aaa,clus_bbb,clus_ccc
BASH
curl "https://echo.api.polariapi.com/v1/echo/stories?ids=clus_9x3k2m8f,clus_4k2m9x7p" \ -H "Authorization: Bearer pk_live_your_key"
Parameter Type Description
ids required string Comma-separated cluster IDs. Max 50.
platform string Optional single-platform filter: reddit, bluesky, or twitter.

Response is an object keyed by story ID. Clusters with no Echo enrichment are omitted rather than returning null entries — check the missing array in the response to see which requested IDs had no data.

GET /v1/echo/trending

Stories with the highest public controversy or active counter-narratives right now — ordered by controversy score descending. Useful for surfacing what the editorial layer hasn't caught up with yet.

GET https://echo.api.polariapi.com/v1/echo/trending
BASH
curl "https://echo.api.polariapi.com/v1/echo/trending?min_controversy=0.6&counter_narrative_only=false&limit=10" \ -H "Authorization: Bearer pk_live_your_key"
Parameter Type Description
min_controversy float Minimum controversy score threshold. Default 0.6.
counter_narrative_only bool If true, only returns stories where a counter-narrative was detected. Default false.
platform string Filter by source platform: reddit, bluesky, or twitter. Omit for all platforms.
limit integer Max results. Default 20, max 50.
GET /v1/echo/search NEW

Modular search across all Echo enrichments — combine entity, topic, counter-narrative flag, controversy threshold, and platform in any combination. Use this when you don't already have a specific cluster_id and want to find stories matching a pattern, rather than looking up one you already know.

GET https://echo.api.polariapi.com/v1/echo/search
BASH
curl -G "https://echo.api.polariapi.com/v1/echo/search" \ --data-urlencode "entity=Iran" \ --data-urlencode "entity_scope=novel" \ --data-urlencode "counter_narrative=true" \ --data-urlencode "platform=reddit,twitter" \ --data-urlencode "limit=20" \ -H "Authorization: Bearer pk_live_your_key"
Parameter Type Description
entity string Entity name or partial name. Matching is substring (ILIKE), not exact — deliberately loose to catch entity-extraction noise (handle-like strings, split variants).
entity_scope string editorial | public | novel | any. Default any. novel is the counter-narrative signal specifically — entities the public is discussing that never appeared in editorial coverage.
topic string Substring match against dominant_themes.
counter_narrative bool Filter to true/false counter-narrative detection.
min_controversy float Minimum controversy score, 0–1.
platform string Comma-separated list, e.g. reddit,bluesky. Omit for all platforms. An unrecognized or currently-inactive platform returns an empty result, not an error.
limit integer Max results. Default 20.
offset integer Pagination offset. Default 0.
RESPONSE
{ "results": [ { "story_id": "clus_6736858d7ddd", "story_title": "North Korean Missile Unit Moves Into Russia", "platform": "twitter", "sentiment_score": -0.74, "sentiment_label": "negative", "controversy_score": 0.68, "dominant_themes": ["military escalation", "combat experience"], "editorial_entities": ["Ukraine", "North Korean"], "public_entities": ["North Korea", "Russia", "Vladimir Putin", "..."], "novel_entities": ["North Korea", "Russia", "Vladimir Putin", "..."], "counter_narrative_detected": true, "counter_narrative_summary": "...", "counter_narrative_confidence": 0.85, "article_velocity": 0.081, "lifecycle_state": "developing", "comment_count_analyzed": 10, "updated_at": "2026-08-06T12:11:15Z" } ], "count": 1, "filters": { "entity": "Iran", "entity_scope": "novel", "topic": null, "counter_narrative": true, "min_controversy": null, "platform": "reddit,twitter", "limit": 20, "offset": 0 } }

Rows are never merged across platforms — you'll get one row per (story_id, platform). If Reddit reads negative/high-controversy and Bluesky reads neutral on the same story, that disagreement is itself a signal, so results keep it visible rather than averaging it away.


SDK

Using Echo from the Python SDK

As of polari-sdk v0.3.0, Echo has a typed client.echo layer — no need to drop into raw httpx alongside the SDK.

PYTHON
import asyncio from polari import PolariClient, ArticleInput async def main(): async with PolariClient.from_env() as client: # Run an article through the pipeline article = ArticleInput( title="US evacuates personnel from Beirut", content="...", url="https://reuters.com/beirut-evacuation-2026", source="Reuters", ) l0 = await client.layer0.analyze(article) l1 = await client.layer1.process(l0.article_id, article.title, article.content) l2 = await client.layer2.cluster(l0.article_id) # Fetch Echo enrichment for the cluster — typed, no raw HTTP needed echo = await client.echo.get_story_enrichment(l2.cluster_id) for e in echo["enrichments"]: print(f"[{e['platform']}] controversy={e['controversy_score']:.2f} sentiment={e['sentiment_label']}") if e["counter_narrative_detected"]: print(f" ↳ counter-narrative: {e['counter_narrative_summary']}") if e["novel_entities"]: print(f" ↳ novel entities: {e['novel_entities']}") # Modular search — no cluster_id needed hits = await client.echo.search( entity="Iran", entity_scope="novel", counter_narrative=True, platform=["reddit", "twitter"], ) asyncio.run(main())
platform accepts a single string or a list on every SDK method that takes it — the SDK normalizes it to a comma-separated query string. An inactive or unrecognized platform returns an empty result rather than raising. search() raises ValueError client-side for an invalid entity_scope, before any request goes out.

Data model

Freshness & coverage

Echo enrichment is written per platform per story — one row for Reddit, one for Bluesky, one for Twitter/X. Each is updated in place as new activity is detected. The fetched_at timestamp on each enrichment tells you when the latest signal was collected.

Platform Update cadence Signal type Status
Reddit Inline with article fetch, plus targeted search Comment threads on editorial posts, and threads found via query-driven search on any story Live
Bluesky Every 30 minutes Targeted post search driven by story entities Live
X / Twitter Every 6 hours Targeted search on high/medium-priority clusters, ranked by engagement Live

Stories with no Echo enrichment return a 404 from the single-story endpoint and are omitted from batch responses. Not all clusters will have enrichment — Echo targets active, high-signal stories, and platform coverage varies: Twitter's dataset is newer and smaller than Reddit's or Bluesky's as of this writing.