App Control Manifest
Operational overview of every running component in Eko. Drop this file into a Claude Code session to inspect, diagnose, or control app functions.
How it works: Each section catalogs a category of operational component with its current status, schedule, dependencies, and env-var controls. YAML blocks are machine-readable directives — a Claude session or admin UI can parse them to render dashboards, trigger actions, or surface health checks.
Deployment Status
deployment:
platform: vercel
project: eko-web
team: lab-90
production_alias: app.eko.day # Crons ONLY run on production
preview_alias: eko-web-git-dev-lab-90.vercel.app
current_environment: preview # No production deploy active
crons_running: false # Crons require production deployment
Note: Vercel crons only execute against the production deployment. All current deployments target
preview, so no crons are firing.
1. Scheduled Tasks (Cron Jobs)
1a. Scheduled in vercel.json (5)
These crons are defined in apps/web/vercel.json and will fire automatically on production deploy.
crons_scheduled:
payment-reminders:
path: /api/cron/payment-reminders
schedule: "0 9 * * *" # Daily 9:00 AM UTC
status: active
summary: Send renewal reminder emails (7d, 3d, 1d before expiry)
deduplication: per user + billing period + reminder tier
dependencies:
db: [user_subscriptions, system_notifications]
packages: ["@eko/email", "@eko/db"]
env: [CRON_SECRET, EMAIL_PROVIDER, RESEND_API_KEY]
payment-escalation:
path: /api/cron/payment-escalation
schedule: "0 9 * * *" # Daily 9:00 AM UTC
status: active
summary: Escalation emails for past-due subscriptions (7d, 14d thresholds)
deduplication: per user + escalation level
dependencies:
db: [user_subscriptions, system_notifications]
packages: ["@eko/email", "@eko/db"]
env: [CRON_SECRET, EMAIL_PROVIDER, RESEND_API_KEY]
monthly-usage-report:
path: /api/cron/monthly-usage-report
schedule: "0 9 1 * *" # 1st of month, 9:00 AM UTC
status: deprecated # v1 tables removed; stub only
summary: No-op stub retained to prevent scheduler errors
notes: "getMonthlyUsageStats removed during v1 table cleanup"
action_needed: Remove from vercel.json
account-anniversaries:
path: /api/cron/account-anniversaries
schedule: "0 9 * * *" # Daily 9:00 AM UTC
status: active
summary: Milestone emails/notifications (1w, 1m, 3m, 6m, 1y account age)
deduplication: per user + milestone subtype
dependencies:
db: [user_subscriptions, system_notifications]
packages: ["@eko/email", "@eko/db"]
env: [CRON_SECRET]
daily-cost-report:
path: /api/cron/daily-cost-report
schedule: "0 6 * * *" # Daily 6:00 AM UTC (midnight CST)
status: active
summary: Email admins AI cost breakdown by model/feature with 7-day trend
dependencies:
db: [ai_cost_log]
packages: ["@eko/email", "@eko/db", "@eko/config"]
env: [CRON_SECRET, ADMIN_EMAIL_ALLOWLIST, ANTHROPIC_DAILY_SPEND_CAP_USD]
1b. Not Scheduled (route exists, not in vercel.json) (8)
These cron routes exist as code but have no vercel.json entry. They must be triggered manually, added to the scheduler, or are handled by external orchestration.
crons_unscheduled:
ingest-news:
path: /api/cron/ingest-news
intended_schedule: "*/15 * * * *" # Every 15 minutes
status: active
summary: Enqueue INGEST_NEWS messages for all configured news providers x active topics
dependencies:
db: [topic_categories]
queue: INGEST_NEWS
env: [CRON_SECRET, NEWS_API_KEY, GOOGLE_NEWS_API_KEY, THENEWS_API_KEY, NEWSDATA_API_KEY, EVENT_REGISTRY_API_KEY]
notes: Primary news pipeline trigger; high frequency
cluster-sweep:
path: /api/cron/cluster-sweep
intended_schedule: "0 * * * *" # Every hour
status: active
summary: Find unclustered news sources (>1h old) and enqueue CLUSTER_STORIES batches
dependencies:
db: [news_sources]
queue: CLUSTER_STORIES
env: [CRON_SECRET]
generate-evergreen:
path: /api/cron/generate-evergreen
intended_schedule: "0 3 * * *" # Daily 3:00 AM UTC
status: active
summary: Enqueue GENERATE_EVERGREEN for active topics up to daily quota
dependencies:
db: [topic_categories]
queue: GENERATE_EVERGREEN
env: [CRON_SECRET, EVERGREEN_ENABLED, EVERGREEN_DAILY_QUOTA]
gate: EVERGREEN_ENABLED must be true
validation-retry:
path: /api/cron/validation-retry
intended_schedule: "0 */4 * * *" # Every 4 hours
status: active
summary: Re-enqueue stuck pending validations (not updated in 4+ hours)
dependencies:
db: [fact_records]
queue: VALIDATE_FACT
env: [CRON_SECRET]
archive-content:
path: /api/cron/archive-content
intended_schedule: "0 2 * * *" # Daily 2:00 AM UTC
status: active
summary: Promote high-engagement facts to enduring; archive expired facts
dependencies:
db: [fact_records]
env: [CRON_SECRET]
thresholds: 50+ interactions for enduring promotion
topic-quotas:
path: /api/cron/topic-quotas
intended_schedule: "0 6 * * *" # Daily 6:00 AM UTC
status: active
summary: Audit daily fact counts vs topic quotas; log warnings for over/under
dependencies:
db: [topic_categories, fact_records]
env: [CRON_SECRET]
notes: Monitoring only; does not enqueue work
import-facts:
path: /api/cron/import-facts
intended_schedule: "0 4 * * *" # Daily 4:00 AM UTC
status: stub
summary: Placeholder for structured API imports (ESPN, WikiQuote, GeoNames)
notes: No-op; awaiting external API integrations
daily-digest:
path: /api/cron/daily-digest
status: deprecated # v1 notification_deliveries table removed
summary: Stub retained to prevent scheduler errors
action_needed: Delete route and any references
2. Background Workers
Five Bun-based queue consumer processes. Each exposes a /health endpoint and implements graceful shutdown.
workers:
worker-ingest:
app: apps/worker-ingest
status: active
health_port: 8080 # configurable via PORT
heartbeat_interval: 30s
lease_duration: 2m
queues:
- name: INGEST_NEWS
summary: Fetch articles from news APIs (NewsAPI, GNews, TheNewsAPI, Newsdata.io, Event Registry)
- name: CLUSTER_STORIES
summary: Cluster articles into stories via TF-IDF cosine similarity
- name: RESOLVE_IMAGE
summary: Resolve and cache images for fact records
- name: RESOLVE_CHALLENGE_IMAGE
summary: Resolve anti-spoiler images for challenges where fact image would reveal the answer
dependencies:
env: [UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN, SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY]
optional: [SENTRY_DSN]
worker-facts:
app: apps/worker-facts
status: active
health_port: 8080
heartbeat_interval: 30s
lease_duration: 2m
concurrency: WORKER_CONCURRENCY # default 1, set 3-5 for seed pipeline
queues:
- name: EXTRACT_FACTS
summary: AI-extract structured facts from news stories
- name: IMPORT_FACTS
summary: Bulk import from external APIs (stub)
- name: GENERATE_EVERGREEN
summary: AI-generate timeless knowledge facts
- name: EXPLODE_CATEGORY_ENTRY
summary: Expand seed entries into structured facts
- name: FIND_SUPER_FACTS
summary: Discover cross-entry fact correlations
- name: GENERATE_CHALLENGE_CONTENT
summary: Pre-generate challenge content per quiz style
dependencies:
env: [UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN, SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY]
ai: [ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY] # at least one
optional: [SENTRY_DSN, WORKER_CONCURRENCY]
worker-validate:
app: apps/worker-validate
status: active
health_port: 8080
heartbeat_interval: 30s
lease_duration: 2m
queues:
- name: VALIDATE_FACT
summary: "4-phase validation: structural → consistency → cross-model (gemini-2.5-flash) → evidence (APIs + gemini-2.5-flash)"
dependencies:
env: [UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN, SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, GOOGLE_API_KEY]
optional: [SENTRY_DSN]
worker-reel-render:
app: apps/worker-reel-render
status: active
health_port: 8080
queues: [] # Consumes from R2/internal triggers, not Upstash queues
summary: Render reel videos from fact records using ffmpeg
dependencies:
env: [SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY]
services: [R2, ffmpeg]
optional: [SENTRY_DSN]
worker-sms:
app: apps/worker-sms
status: active
health_port: 8080
queues:
- name: SEND_SMS
summary: Send SMS notifications via Twilio
dependencies:
env: [UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN, SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY]
services: [Twilio]
optional: [SENTRY_DSN]
3. Queue System
Backend: Upstash Redis (REST API). Max 3 attempts before dead-letter queue.
queue_infrastructure:
backend: upstash_redis
max_attempts: 3
dlq_suffix: ":dlq"
soak_isolation: SOAK_QUEUE_SUFFIX # env var for test isolation
backoff: exponential_with_jitter # 250ms to 5m
queue_types:
# --- Ingestion Pipeline ---
INGEST_NEWS:
consumer: worker-ingest
status: active
trigger: cron/ingest-news (every 15m)
CLUSTER_STORIES:
consumer: worker-ingest
status: active
trigger: cron/cluster-sweep (hourly)
RESOLVE_IMAGE:
consumer: worker-ingest
status: active
trigger: post-extraction (automatic)
RESOLVE_CHALLENGE_IMAGE:
consumer: worker-ingest
status: active
trigger: post-challenge-generation (automatic)
# --- Fact Engine ---
EXTRACT_FACTS:
consumer: worker-facts
status: active
trigger: post-clustering (automatic)
IMPORT_FACTS:
consumer: worker-facts
status: stub
trigger: cron/import-facts (not active)
GENERATE_EVERGREEN:
consumer: worker-facts
status: active
trigger: cron/generate-evergreen (daily)
gate: EVERGREEN_ENABLED=true
EXPLODE_CATEGORY_ENTRY:
consumer: worker-facts
status: active
trigger: seed pipeline (manual/batch)
FIND_SUPER_FACTS:
consumer: worker-facts
status: active
trigger: seed pipeline (manual/batch)
GENERATE_CHALLENGE_CONTENT:
consumer: worker-facts
status: active
trigger: post-extraction or seed pipeline
# --- Validation ---
VALIDATE_FACT:
consumer: worker-validate
status: active
trigger: post-extraction (automatic) + cron/validation-retry (every 4h)
# --- Deprecated ---
SEND_SMS:
consumer: worker-sms
status: active
notes: SMS delivery via Twilio; legacy tables removed in migration 0118 but queue is active
4. API Surface
4a. Core Content APIs
api_content:
health:
method: GET
path: /api/health
auth: none
summary: Uptime check; returns { status, timestamp }
feed:
method: GET
path: /api/feed
auth: optional
summary: "Blended fact feed: 40% recent, 30% review-due, 20% evergreen, 10% random"
params: [limit (max 100), offset, topic]
notes: Public users get simple chronological feed
review:
method: GET
path: /api/review
auth: required
summary: Facts due for spaced repetition (next_review_at passed, streak < 5)
params: [limit (max 50, default 10)]
card_detail:
method: GET
path: /api/cards/[slug]
auth: required + subscription
summary: Full card detail (fact, topic, schema, source story)
gate: Active subscription or trial required
entity_detail:
page: /entity/[id]
auth: required + subscription
summary: Entity detail page — header, FCG grid, linked entities
gate: Active subscription or trial required
explore:
page: /explore
auth: required
summary: Browse and search entities with infinite scroll
4b. Entity APIs
api_entities:
search:
method: GET
path: /api/entities
auth: required
summary: Search and list entities with text search, topic filter, pagination
params: [q (search text), topic (category UUID), limit (max 50), offset, orderBy]
gate: Only returns entities with 1+ validated facts
entity_facts:
method: GET
path: /api/entities/[id]/facts
auth: required
summary: Fetch facts for a given seed entry
entity_links:
method: GET
path: /api/entities/[id]/links
auth: required
summary: Fetch linked entities for graph traversal
4c. Card Interaction APIs
api_interactions:
interact:
method: POST
path: /api/cards/[slug]/interact
auth: required
summary: "Record interaction: viewed, expanded, answered, bookmarked, shared"
features:
- AI scoring for free_text format (moderateTextAnswer + scoreTextAnswer)
- Spaced repetition via calculateNextReview()
rate_limit: 50 free-text answers/user/day
dispute:
method: POST
path: /api/cards/[slug]/dispute
auth: required
summary: Submit score dispute for AI re-evaluation
rate_limit: 10 disputes/user/month
decisions: [upheld, adjusted, reversed]
4c. Billing APIs
api_billing:
stripe_webhooks:
method: POST
path: /api/stripe/webhooks
auth: stripe_signature
summary: Handle Stripe events (subscription changes, payment failures)
idempotency: billing_webhook_events table (stripe_event_id)
env: [STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET]
stripe_portal:
method: POST
path: /api/stripe/portal
auth: required
summary: Create Stripe Customer Portal session for subscription management
env: [STRIPE_SECRET_KEY]
stripe_checkout:
method: POST
path: /api/stripe/checkout
auth: required
summary: Create Stripe Checkout session for subscription upgrade
plans: [base, pro, team, plus]
env: [STRIPE_SECRET_KEY, STRIPE_PRICE_*]
4d. Account & User APIs
api_account:
notifications:
methods: [GET, PATCH]
path: /api/account/notifications
auth: required
summary: Get/update notification preferences (email_enabled, digest_frequency, quiet_hours)
profile:
methods: [GET, PATCH]
path: /api/account/profile
auth: required
summary: Get/update profile (display_name, timezone, onboarding_completed_at)
rewards:
methods: [GET, POST]
path: /api/user/rewards
auth: required
summary: "GET: milestones + cumulative score. POST: claim milestone to extend subscription"
4e. Challenge APIs
api_challenges:
formats:
method: GET
path: /api/challenge-formats
auth: none (public)
summary: List all active challenge formats with styles and eligible topic IDs
cache: 1 hour (public)
formats_by_topic:
method: GET
path: /api/challenge-formats/for-topic/[topicSlug]
auth: none (public)
summary: Challenge formats filtered by topic
format_detail:
method: GET
path: /api/challenge-formats/[slug]
auth: none (public)
summary: Single challenge format detail
create_session:
method: POST
path: /api/challenge-sessions
auth: required
summary: Start a new conversational challenge session
rate_limit: 5 sessions/user/day
end_session:
method: POST
path: /api/challenge-sessions/[sessionId]/end
auth: required
summary: End session early; calculates final score
turn:
method: POST
path: /api/challenge-sessions/[sessionId]/turn
auth: required
summary: Process conversational turn (moderation + AI reasoning + scoring)
max_input: 2000 characters
4f. External Webhooks
api_webhooks:
twilio:
method: POST
path: /api/webhooks/twilio
status: deprecated
summary: SMS delivery status stub; always returns 200
action_needed: Remove route after Twilio config cleanup
email_test:
method: POST
path: /api/email/test
auth: required
summary: Send test email to authenticated user
env: [EMAIL_PROVIDER, RESEND_API_KEY]
5. Admin Dashboard
The admin app (apps/admin) serves admin.eko.day and provides operational screens.
admin_pages:
# --- Active v2 Screens ---
dashboard: { path: "/", summary: "Overview metrics and health" }
billing: { path: "/billing", summary: "Revenue overview and plan distribution" }
billing_customer: { path: "/billing/customers/[id]", summary: "Individual customer billing detail" }
queue: { path: "/queue", summary: "Queue depths, DLQ counts, processing rates" }
users: { path: "/users", summary: "User list with search and filters" }
user_detail: { path: "/users/[id]", summary: "Individual user profile and activity" }
user_dashboard: { path: "/users/[id]/dashboard", summary: "User-level metrics" }
user_notifs: { path: "/users/[id]/notifications", summary: "User notification history" }
content: { path: "/content", summary: "Fact records browser and moderation" }
feature_flags: { path: "/feature-flags", summary: "Runtime feature flag toggles" }
onboarding: { path: "/onboarding", summary: "Onboarding funnel metrics" }
audience: { path: "/audience", summary: "User segmentation and engagement" }
# --- Legacy v1 Screens (may need update/removal) ---
urls: { path: "/urls", summary: "URL tracking list (v1 legacy)" }
url_detail: { path: "/urls/[id]", summary: "URL detail and change history (v1 legacy)" }
changes: { path: "/changes", summary: "URL change feed (v1 legacy)" }
change_detail: { path: "/changes/[id]", summary: "Individual change detail (v1 legacy)" }
domains_pending: { path: "/domains/pending", summary: "Domains awaiting approval (v1 legacy)" }
domains_rejected: { path: "/domains/rejected", summary: "Rejected domains (v1 legacy)" }
diff_settings: { path: "/diff-settings", summary: "Diff algorithm config (v1 legacy)" }
feeder_sources: { path: "/feeder-sources", summary: "RSS/feed source management (v1 legacy)" }
media: { path: "/media", summary: "Media asset browser" }
avatars: { path: "/avatars", summary: "User avatar management" }
user_urls: { path: "/users/[id]/urls/add", summary: "Add URLs for user (v1 legacy)" }
6. Environment Controls
Central config lives in packages/config/src/index.ts. All env vars are loaded from .env.local at monorepo root.
6a. AI & Cost Controls
ai_controls:
AI_PROVIDER: google # anthropic | openai | google (default: gemini-3-flash-preview)
ANTHROPIC_DAILY_SPEND_CAP_USD: 5 # Daily budget cap (default $5)
OPUS_ESCALATION_ENABLED: false # Allow routing to Opus for complex tasks
OPUS_MAX_DAILY_CALLS: 20 # Hard cap on Opus invocations
FACT_EXTRACTION_BATCH_SIZE: 10 # Facts processed per AI call
VALIDATION_MIN_SOURCES: 2 # Minimum sources for multi-source validation
NOTABILITY_THRESHOLD: 0.6 # Minimum score to retain a fact (0.0-1.0)
6b. Pipeline Controls
pipeline_controls:
EVERGREEN_ENABLED: false # Master switch for evergreen generation
EVERGREEN_DAILY_QUOTA: 20 # Max evergreen facts per day
NEWS_INGESTION_INTERVAL_MINUTES: 15 # Cron frequency
CRON_BATCH_SIZE: 100 # Max items per cron batch
WORKER_CONCURRENCY: 1 # Parallel handlers per queue (set 3-5 for seeding)
6c. External Services
services:
# Database
SUPABASE_URL: required
SUPABASE_ANON_KEY: required
SUPABASE_SERVICE_ROLE_KEY: required
# Queue
UPSTASH_REDIS_REST_URL: optional # Required for workers/crons
UPSTASH_REDIS_REST_TOKEN: optional
# Email
EMAIL_PROVIDER: none # none | resend
RESEND_API_KEY: optional
RESEND_FROM_EMAIL: optional
# Error Tracking
ERROR_TRACKING_PROVIDER: none # none | sentry
SENTRY_DSN: optional
# Billing
STRIPE_SECRET_KEY: optional # Required for billing features
STRIPE_WEBHOOK_SECRET: optional
# News APIs
NEWS_API_KEY: optional
GOOGLE_NEWS_API_KEY: optional
THENEWS_API_KEY: optional
NEWSDATA_API_KEY: optional
EVENT_REGISTRY_API_KEY: optional
# Image APIs
UNSPLASH_ACCESS_KEY: optional
PEXELS_API_KEY: optional
# Enrichment
BRANDFETCH_API_KEY: optional
PDL_API_KEY: optional
7. Operational Debt & Action Items
action_items:
- id: OPS-001
priority: low
action: Remove monthly-usage-report from vercel.json
reason: Deprecated stub (v1 tables removed)
file: apps/web/vercel.json
- id: OPS-002
priority: low
action: Remove daily-digest cron route
reason: Deprecated stub (v1 notification_deliveries removed)
file: apps/web/app/api/cron/daily-digest/route.ts
- id: OPS-003
priority: low
action: Remove twilio webhook route
reason: SMS delivery tables dropped; stub only
file: apps/web/app/api/webhooks/twilio/route.ts
- id: OPS-004
priority: medium
action: Add 8 unscheduled crons to vercel.json before production deploy
reason: Active pipeline crons (ingest-news, cluster-sweep, etc.) have no scheduler
file: apps/web/vercel.json
- id: OPS-005
priority: high
action: Deploy to production to activate cron scheduling
reason: All deployments are preview; crons only fire on production
- id: OPS-006
priority: low
action: Audit SEND_SMS queue usage and worker-sms integration
reason: Worker exists but legacy SMS tables were dropped; verify end-to-end flow
- id: OPS-007
priority: low
action: Remove legacy v1 admin pages (urls, changes, domains, diff-settings, feeder-sources)
reason: v1 tables removed; pages likely broken or empty
8. Pipeline Flow Diagram
News APIs ─┐
├──▶ [INGEST_NEWS] ──▶ worker-ingest ──▶ news_sources table
│ │
│ ┌─────────────────────┘
│ ▼
│ [CLUSTER_STORIES] ──▶ worker-ingest ──▶ story_clusters
│ │
│ ┌─────────────────────┘
│ ▼
│ [EXTRACT_FACTS] ──▶ worker-facts ──▶ fact_records
│ │
│ ┌───────────┬───────────────┘
│ ▼ ▼
│ [VALIDATE_FACT] [RESOLVE_IMAGE]
│ │ │
│ ▼ ▼
│ worker-validate worker-ingest
│ │ │
│ ▼ ▼
│ fact verified image cached
│
Seed Data ─┤
├──▶ [EXPLODE_CATEGORY_ENTRY] ──▶ worker-facts ──▶ fact_records
├──▶ [FIND_SUPER_FACTS] ──▶ worker-facts ──▶ cross-correlations
└──▶ [GENERATE_CHALLENGE_CONTENT] ──▶ worker-facts ──▶ challenge_content
│
┌───────────────────────────────┘
▼ (if image would spoil answer)
[RESOLVE_CHALLENGE_IMAGE] ──▶ worker-ingest ──▶ anti-spoiler image
Evergreen ────▶ [GENERATE_EVERGREEN] ──▶ worker-facts ──▶ fact_records
9. Recent Ops Logs
All operational events are logged in docs/ops/logs/ with structured frontmatter for tracking.
- Current month: February 2026
- Log template: docs/ops/logs/README.md
After any notable operational event (deploy, incident, config change, cron issue), create a log file using the template and update the monthly index.
Event Types at a Glance
| Type | When to Log |
|---|---|
deploy | Production deployments, promotion from preview |
cron-run | Manual cron triggers, notable scheduled failures |
incident | Service degradation, errors, outages |
config-change | Env var changes, feature flag toggles |
maintenance | Cleanup, migration execution, deprecation removal |
worker-issue | Worker crashes, stuck queues, DLQ growth |
queue-drain | Manual DLQ reprocessing |
billing-event | Stripe webhook issues, subscription anomalies |
cost-alert | AI spend cap hit, budget anomalies |
Reference: Quick Commands
# Check worker health
curl http://localhost:8080/health # Any worker
# Trigger a cron manually (dev)
curl -X POST http://localhost:3000/api/cron/ingest-news \
-H "Authorization: Bearer $CRON_SECRET"
# Check queue depths (requires Upstash dashboard or Redis CLI)
# Queue names: queue:ingest_news, queue:cluster_stories, etc.
# DLQ names: queue:ingest_news:dlq, etc.
# Run all workers locally
bun run dev:worker-ingest
bun run dev:worker-facts
bun run dev:worker-validate
# View AI cost report
curl -X POST http://localhost:3000/api/cron/daily-cost-report \
-H "Authorization: Bearer $CRON_SECRET"