Operator Reads — the industry-insights agent
Operator Reads is a curation agent for operators: a shortlist of the most
recent articles, blog posts, and news that actually matter to your
operation — ranked by relevance to the product verticals your machines stock
and the places your machines sit, newest first. It ships in the Operator X
app as its own tab, in the AI assistant as the get_operator_reads tool, and
over the API under /api/v1/operator-reads/*.
It is wired the same way as the Location Scout agent: a per-user-id subscription record activates the agent, and an n8n workflow powered by Firecrawl does the web discovery.
Activation is a subscription (per user-id)
The agent is off by default. Feed, item actions, and refresh all return
403 until the operator activates:
curl -X POST https://api.kiosk-x.ai/api/v1/operator-reads/subscription \
-H "X-API-Key: $KEY"
# → {"active": true, "operatorEmail": "...", "activatedAt": "..."}
POST /subscription/cancel turns the agent off; the shortlist (and its
read/saved state) is kept so re-activating later doesn't start from a blank
feed. GET /subscription reports the activation state plus unread/saved
counts for badges.
Subscription records live in the operator_reads_subs store,
FLEET-tier persisted (app/persistence.py) — replica-shared over
Postgres LISTEN/NOTIFY and rehydrated on boot, so an activation on pod A
gates the feed served by pod B and survives the ~20 redeploys/day.
Both Operator Reads stores are additionally reset-proof
(_RESET_PROOF_DICT_STORES): the demo environment's routine
/sandbox/reset re-seeds machines and orders but leaves agent activations
and reading state alone — the same reasoning that puts Location Scout's
subscriptions in the money tier.
Convergence note: Location Scout keeps its paid entitlements in its own
scout_subscriptions(money-tier) store. Operator Reads activation is a free feature toggle, so it lives in its own fleet-tier store rather than a shared entitlements table; if per-agent entitlements ever become paid SKUs, fold both into one store.
The operator profile is derived, never declared
Relevance is computed from what the fleet actually is
(GET /operator-reads/profile):
- Verticals — the distinct aisle categories across the operator's machines (from the live planogram), most-stocked first. Stock vape tomorrow, and vape-regulation news enters your next curation run without any config.
- Keyword pack — an industry baseline every subscriber gets (smart
vending, unattended retail, micro markets) plus per-vertical vocabulary
from
VERTICAL_KEYWORDSinapp/routes/operator_reads.py(e.g.alcohol→ "age verification vending";collectibles→ "trading card vending machine"). - Machine locations — machineNo, venue name, city/state/zip, lat/lng, and OpenOOH venue category for every machine, so the workflow can search city-scoped news ("vending regulation Brooklyn NY", venue openings, foot-traffic events near the machines).
How ranking works
Each ingested item carries relevance evidence — a list of
{kind, reason} pairs the workflow attaches, e.g.
{"kind": "vertical", "reason": "matches your 'vape' vertical"} or
{"kind": "location", "reason": "near your Airport T2 machine"}. Those
reasons are shown verbatim as chips in the app, so ranking stays explainable.
baseScore = 1 + Σ weight(kind)with vertical1.5> location1.2> industry0.4, capped at 8. It is recomputed server-side at ingest — a buggy or malicious caller cannot inflate its own scores.- At read time:
rank = baseScore × 0.5^(ageDays / 7)— a 7-day half-life, so the feed is always newest-and-most-relevant first and a stale high-relevance piece eventually yields to fresh news.
Dedupe across refreshes hashes the normalized URL (scheme/www/trailing
slash/fragment-insensitive) per operator. Re-ingesting a known URL refreshes
its metadata but never clobbers the operator's read/saved/dismissed state.
The feed is capped (KIOSKX_OPERATOR_READS_MAX_ITEMS, default 200) by
pruning the oldest unsaved items; saved items are never pruned.
The n8n workflow + Firecrawl
Discovery runs in the "Operator Reads" n8n workflow
(integrations/n8n/operator-reads.json, importable):
- Triggers — a 6-hour schedule (all active subscribers) and a webhook
POST /webhook/operator-reads-refresh(one operator, fired by the backend's/refreshendpoint on pull-to-refresh; cooldown-limited because every run spends Firecrawl credits). - Subscribers —
GET /api/v1/operator-reads/subscriberswith the fleet-admin key returns each active subscriber with their derived profile. - Firecrawl search — for each profile it builds queries from the
keyword pack + city-scoped queries and calls Firecrawl
https://api.firecrawl.dev/v1/search(the API key lives in n8n as an HTTP-header-auth credential named "Firecrawl API (header auth)" —Authorization: Bearer fc-…; the backend holds no Firecrawl key). - Score & tag — a Code node tags each hit with its relevance evidence (which vertical/city/industry query found it), extracts the published date, dedupes by URL, and keeps the top ~30 per operator.
- Callback —
POST /api/v1/operator-reads/ingestwith the admin key:{operatorEmail, runId, items: [{url, title, source, publishedAt, summary, relevance[]}]}. Non-admin credentials can only ingest into their own feed, and ingest into a non-activated operator is refused.
If n8n is unreachable, /refresh answers honestly
({"queued": false, "note": "...workflow is unreachable..."}) and the
scheduled run picks the operator up on its next pass.
API surface
| Endpoint | What |
|---|---|
GET /api/v1/operator-reads/subscription |
Activation state + unread/saved counts |
POST /api/v1/operator-reads/subscription |
Activate the agent for this user-id |
POST /api/v1/operator-reads/subscription/cancel |
Deactivate (shortlist kept) |
GET /api/v1/operator-reads/profile |
The fleet-derived curation profile |
GET /api/v1/operator-reads/feed?view=inbox\|saved\|dismissed\|all |
Ranked shortlist, paginated |
POST /api/v1/operator-reads/feed/{itemId}/read\|save\|dismiss\|restore |
Item state |
POST /api/v1/operator-reads/refresh |
Trigger a curation run now (cooldown-limited) |
GET /api/v1/operator-reads/subscribers |
Admin: active subscribers + profiles (workflow input) |
POST /api/v1/operator-reads/ingest |
Authenticated curation callback (workflow output) |
Admin credentials pass ?operatorEmail= (or the body field) to act on a
specific operator; operator credentials always act on themselves.
Configuration
| Env var | Default | What |
|---|---|---|
KIOSKX_OPERATOR_READS_N8N_WEBHOOK |
https://n8n.intelli-verse-x.ai/webhook/operator-reads-refresh |
Manual-refresh trigger; empty disables |
KIOSKX_OPERATOR_READS_COOLDOWN |
60 |
Seconds between manual refreshes per operator |
KIOSKX_OPERATOR_READS_MAX_ITEMS |
200 |
Per-operator feed retention cap |
KIOSKX_FIRECRAWL_API_KEY |
(empty) | Optional direct-Firecrawl fallback (n8n normally owns discovery) |
Tests: tests/test_operator_reads.py — subscription gating, lifecycle,
profile derivation, ranking/recency, dedupe, callback auth, refresh
cooldown, assistant tool, and fleet-sync peer-apply/restart survival.