{
  "openapi": "3.1.0",
  "info": {
    "title": "TechSnif API",
    "description": "Public, free, no-auth JSON API for TechSnif, a source-first tech news aggregator. Story clusters aggregate coverage from every outlet with social discussion links. No API keys, no rate limits, no paywall. Every /api/ endpoint sends permissive CORS headers (Access-Control-Allow-Origin: *), so browser apps can call the API directly.",
    "version": "1.0.0",
    "contact": { "email": "press@mail.techsnif.com", "url": "https://techsnif.com/agents" }
  },
  "servers": [
    { "url": "https://techsnif.com", "description": "Primary" },
    { "url": "https://api.techsnif.com", "description": "API alias" }
  ],
  "paths": {
    "/api/stories": {
      "get": {
        "operationId": "listStories",
        "summary": "List story clusters",
        "description": "Story clusters with flexible sorting, filtering, and offset pagination. Returns real total counts.",
        "parameters": [
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 0, "maximum": 80, "default": 20 } },
          { "name": "offset", "in": "query", "schema": { "type": "integer", "minimum": 0, "default": 0 } },
          { "name": "sort", "in": "query", "schema": { "type": "string", "enum": ["rank", "newest"], "default": "rank" } },
          { "name": "tier", "in": "query", "description": "Headline tier: 1=standard, 2=major, 3=top story", "schema": { "type": "integer", "minimum": 1, "maximum": 3 } },
          { "name": "publisher", "in": "query", "description": "Case-insensitive partial match on lead publisher", "schema": { "type": "string" } },
          { "name": "platform", "in": "query", "description": "Only clusters with discussion on this platform", "schema": { "type": "string", "enum": ["x", "bluesky", "linkedin", "forums", "hn", "threads", "mastodon", "other"] } },
          { "name": "has_discussion", "in": "query", "description": "Only clusters with any social discussion", "schema": { "type": "string", "enum": ["true"] } },
          { "name": "since", "in": "query", "description": "ISO 8601 date — only clusters seen since this time", "schema": { "type": "string", "format": "date-time" } },
          { "name": "until", "in": "query", "description": "ISO 8601 date, only clusters last seen at or before this time. Combine with since for archive range queries.", "schema": { "type": "string", "format": "date-time" } },
          { "name": "topic", "in": "query", "description": "Topic slug (e.g. ai, security)", "schema": { "type": "string" } },
          { "name": "entity", "in": "query", "description": "Entity slug (company, person, or product), only clusters mentioning this entity. Discover slugs via /api/entities.", "schema": { "type": "string" } }
        ],
        "responses": {
          "200": {
            "description": "Story cluster list",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StoryListResponse" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" }
        }
      }
    },
    "/api/entities": {
      "get": {
        "operationId": "listEntities",
        "summary": "List tracked entities",
        "description": "Companies, people, and products extracted from story clusters, with story counts. Only entities linked to at least one story are returned, ordered by story count descending. Pass a slug to the entity param of /api/stories for that entity's full story list.",
        "parameters": [
          { "name": "type", "in": "query", "description": "Filter by entity type", "schema": { "type": "string", "enum": ["company", "person", "product"] } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 500, "default": 100 } }
        ],
        "responses": {
          "200": {
            "description": "Entity list",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "entities": { "type": "array", "items": { "type": "object", "properties": { "slug": { "type": "string" }, "name": { "type": "string" }, "type": { "type": "string", "enum": ["company", "person", "product"] }, "storyCount": { "type": "integer" } } } } } } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" }
        }
      }
    },
    "/api/stories/search": {
      "get": {
        "operationId": "searchStories",
        "summary": "Search story clusters",
        "description": "Relevance-ranked full-text search over headlines and excerpts, with substring fallback for word prefixes. Set mode=semantic to rank by embedding cosine similarity instead, which matches paraphrases and related concepts.",
        "parameters": [
          { "name": "q", "in": "query", "required": true, "schema": { "type": "string", "minLength": 2 } },
          { "name": "mode", "in": "query", "description": "keyword (default) runs full-text search. semantic ranks by embedding similarity and ignores the publisher filter; it falls back to keyword when semantic search is unavailable.", "schema": { "type": "string", "enum": ["keyword", "semantic"], "default": "keyword" } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 0, "maximum": 50, "default": 10 } },
          { "name": "offset", "in": "query", "schema": { "type": "integer", "minimum": 0, "default": 0 } },
          { "name": "publisher", "in": "query", "description": "Keyword mode only", "schema": { "type": "string" } }
        ],
        "responses": {
          "200": { "description": "Search results. The response includes a mode field (keyword or semantic) reflecting the search that actually ran.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StoryListResponse" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" }
        }
      }
    },
    "/api/stories/changes": {
      "get": {
        "operationId": "listStoryChanges",
        "summary": "Story change feed since a given time",
        "description": "Merged feed of story.new events (cluster first seen after since) and story.updated events (existing cluster re-seen after since), ordered oldest first. Designed for polling: pass nextCursor back as since to continue where the last page ended.",
        "parameters": [
          { "name": "since", "in": "query", "description": "ISO 8601 date. Defaults to 24 hours ago when missing or invalid.", "schema": { "type": "string", "format": "date-time" } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 } }
        ],
        "responses": {
          "200": { "description": "Change feed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ChangesResponse" } } } }
        }
      }
    },
    "/api/stories/trending": {
      "get": {
        "operationId": "trendingStories",
        "summary": "Stories gaining coverage and discussion right now",
        "description": "Story clusters ranked by velocity inside a recent window. Velocity compares the earliest and latest pipeline snapshot in the window: commentary link delta plus coverage link delta plus rank improvement.",
        "parameters": [
          { "name": "hours", "in": "query", "description": "Lookback window in hours", "schema": { "type": "integer", "minimum": 1, "maximum": 48, "default": 6 } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 50, "default": 20 } }
        ],
        "responses": {
          "200": { "description": "Trending stories", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TrendingResponse" } } } }
        }
      }
    },
    "/api/stories/{slug}/timeline": {
      "get": {
        "operationId": "getStoryTimeline",
        "summary": "Rank and coverage history for one story",
        "description": "One point per pipeline snapshot (recorded every 30 minutes), oldest first: rank, headline tier, commentary link count, and coverage link count over time.",
        "parameters": [
          { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "200": { "description": "Story timeline", "content": { "application/json": { "schema": { "type": "object", "properties": { "timeline": { "$ref": "#/components/schemas/ClusterTimeline" } } } } } },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/stories/{slug}": {
      "get": {
        "operationId": "getStory",
        "summary": "Get a story cluster by slug",
        "parameters": [
          { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "200": { "description": "Full story cluster", "content": { "application/json": { "schema": { "type": "object", "properties": { "story": { "$ref": "#/components/schemas/StoryCluster" } } } } } },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/articles": {
      "get": {
        "operationId": "listArticles",
        "summary": "List TechSnif editorial articles",
        "parameters": [
          { "name": "category", "in": "query", "description": "Category key (AI) or slug (artificial-intelligence)", "schema": { "type": "string" } },
          { "name": "tag", "in": "query", "schema": { "type": "string" } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 0, "maximum": 50, "default": 10 } },
          { "name": "offset", "in": "query", "schema": { "type": "integer", "minimum": 0, "default": 0 } }
        ],
        "responses": {
          "200": { "description": "Article list", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ArticleListResponse" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" }
        }
      }
    },
    "/api/articles/search": {
      "get": {
        "operationId": "searchArticles",
        "summary": "Full-text search articles",
        "parameters": [
          { "name": "q", "in": "query", "required": true, "schema": { "type": "string", "minLength": 2 } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 0, "maximum": 50, "default": 20 } },
          { "name": "offset", "in": "query", "schema": { "type": "integer", "minimum": 0, "default": 0 } }
        ],
        "responses": {
          "200": { "description": "Search results", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ArticleListResponse" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" }
        }
      }
    },
    "/api/articles/categories": {
      "get": {
        "operationId": "listCategories",
        "summary": "List article categories",
        "responses": {
          "200": { "description": "Category metadata", "content": { "application/json": { "schema": { "type": "object", "properties": { "categories": { "type": "array", "items": { "type": "object" } } } } } } }
        }
      }
    },
    "/api/articles/tags": {
      "get": {
        "operationId": "listTags",
        "summary": "List article tags",
        "responses": {
          "200": { "description": "Tag metadata", "content": { "application/json": { "schema": { "type": "object", "properties": { "tags": { "type": "array", "items": { "type": "object" } }, "total": { "type": "integer" } } } } } }
        }
      }
    },
    "/api/articles/trending": {
      "get": {
        "operationId": "trendingArticles",
        "summary": "Trending articles (48h window)",
        "parameters": [
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 0, "maximum": 20, "default": 5 } }
        ],
        "responses": {
          "200": { "description": "Trending articles", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ArticleListResponse" } } } }
        }
      }
    },
    "/api/articles/{slug}": {
      "get": {
        "operationId": "getArticle",
        "summary": "Get a full article by slug",
        "parameters": [
          { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "200": { "description": "Full article with body", "content": { "application/json": { "schema": { "type": "object", "properties": { "article": { "$ref": "#/components/schemas/Article" } } } } } },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/leaderboard": {
      "get": {
        "operationId": "getLeaderboard",
        "summary": "Source and author leaderboards",
        "description": "Which publications and journalists drive the most tech story coverage.",
        "parameters": [
          { "name": "tab", "in": "query", "schema": { "type": "string", "enum": ["sources", "authors"], "default": "sources" } },
          { "name": "window", "in": "query", "schema": { "type": "string", "enum": ["7d", "30d", "all"], "default": "30d" } },
          { "name": "topic", "in": "query", "schema": { "type": "string" } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 0, "maximum": 100, "default": 50 } }
        ],
        "responses": {
          "200": { "description": "Leaderboard entries", "content": { "application/json": { "schema": { "type": "object", "properties": { "entries": { "type": "array", "items": { "$ref": "#/components/schemas/LeaderboardEntry" } }, "total": { "type": "integer" }, "tab": { "type": "string" }, "timeWindow": { "type": "string" } } } } } },
          "400": { "$ref": "#/components/responses/BadRequest" }
        }
      }
    },
    "/api/events": {
      "get": {
        "operationId": "listEvents",
        "summary": "Upcoming tech industry events",
        "parameters": [
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 0, "maximum": 50, "default": 10 } }
        ],
        "responses": {
          "200": { "description": "Event list", "content": { "application/json": { "schema": { "type": "object", "properties": { "events": { "type": "array", "items": { "$ref": "#/components/schemas/TechEvent" } }, "total": { "type": "integer" } } } } } }
        }
      }
    },
    "/api/stream": {
      "get": {
        "operationId": "streamStories",
        "summary": "Live story stream (server-sent events)",
        "description": "text/event-stream endpoint that pushes story changes as they happen. Emits event: story.new when a cluster first appears and event: story.updated when an existing cluster changes. Each event id is the change timestamp; send it as the Last-Event-ID request header (or a since query param) to resume after a disconnect. Each data field is one StorySummary JSON object. Optional topics and tier query params filter which events are delivered, in both replay and live broadcast. The same URL also accepts a WebSocket upgrade (Upgrade: websocket) with the same filters plus since for replay; each WebSocket message is a JSON string { id, type, story }. SSE heartbeat comments have the form \": ping lastEventId=<id or none>\" so idle clients can detect missed events.",
        "parameters": [
          { "name": "topics", "in": "query", "description": "Comma-separated topic slugs (e.g. ai,security). Only events whose story topics intersect the list are delivered.", "schema": { "type": "string" } },
          { "name": "tier", "in": "query", "description": "Minimum headline tier. Only events with story tier at or above this value are delivered (3 = top stories). Stories without a tier count as 0.", "schema": { "type": "integer", "minimum": 1, "maximum": 3 } }
        ],
        "responses": {
          "200": {
            "description": "SSE stream of story.new and story.updated events",
            "content": { "text/event-stream": { "schema": { "type": "string", "description": "SSE frames. Each data line is a StorySummary JSON object." } } }
          }
        }
      }
    },
    "/api/stream/status": {
      "get": {
        "operationId": "streamStatus",
        "summary": "Live stream status",
        "description": "Connection and buffer stats for the live story stream. No auth. Served with Cache-Control: no-store so counts are always current.",
        "responses": {
          "200": {
            "description": "Current stream status",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "sseConnected": { "type": "integer", "description": "Open SSE connections" },
                    "wsConnected": { "type": "integer", "description": "Open WebSocket connections" },
                    "bufferSize": { "type": "integer", "description": "Events currently in the replay ring buffer (max 500)" },
                    "lastEventId": { "type": ["string", "null"], "description": "Newest buffered event id, or null when the buffer is empty" }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/push/subscribe": {
      "post": {
        "operationId": "pushSubscribe",
        "summary": "Register a web push subscription",
        "description": "Stores a browser Web Push subscription for big-story notifications. TechSnif only notifies for tier 3 (top) stories, at most one story per pipeline run, at most one push every 3 hours, and at most 3 pushes per UTC day. Send the object produced by PushSubscription.toJSON(); the endpoint must be an https URL. Re-subscribing with the same endpoint refreshes the stored keys.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["subscription"],
                "properties": {
                  "subscription": {
                    "type": "object",
                    "required": ["endpoint", "keys"],
                    "properties": {
                      "endpoint": { "type": "string", "format": "uri", "description": "https push service endpoint" },
                      "keys": {
                        "type": "object",
                        "required": ["p256dh", "auth"],
                        "properties": {
                          "p256dh": { "type": "string" },
                          "auth": { "type": "string" }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": { "description": "Subscription stored", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" } } } } } },
          "400": { "description": "Invalid subscription payload" }
        }
      }
    },
    "/api/push/unsubscribe": {
      "post": {
        "operationId": "pushUnsubscribe",
        "summary": "Remove a web push subscription",
        "description": "Deletes a stored Web Push subscription by endpoint. Responds 200 whether or not the endpoint was registered.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["endpoint"],
                "properties": {
                  "endpoint": { "type": "string", "format": "uri" }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Subscription removed (or was never registered)", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" } } } } } },
          "400": { "description": "Invalid body" }
        }
      }
    },
    "/api/webhooks": {
      "post": {
        "operationId": "registerWebhook",
        "summary": "Register a webhook for story events",
        "description": "Registers an https URL to receive story.new and story.updated events as the pipeline publishes them (runs every 30 minutes). Registration performs a challenge handshake so this API cannot be used to send traffic to third parties: TechSnif POSTs { \"type\": \"webhook.verify\", \"challenge\": \"<uuid>\" } to the url, and the endpoint must respond with a 2xx status and include the challenge string anywhere in its response body within 10 seconds, otherwise registration fails with 422. The 201 response includes the signing secret exactly once. Deliveries are POSTs of { \"events\": [...] } where each event has type, id, at, and story (a StorySummary object); the id field is stable and usable for deduplication. Every delivery carries an X-TechSnif-Signature header of the form sha256=<hex HMAC-SHA256 of the raw request body, computed with the secret>. Verify a delivery by recomputing the HMAC over the exact raw body bytes and comparing hex digests. Subscriptions are disabled after 10 consecutive delivery failures. Only https URLs on the default port are accepted (no localhost, no raw IP hosts), topics is limited to 20 slugs, and per-host and global registration caps apply.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["url"],
                "properties": {
                  "url": { "type": "string", "format": "uri", "description": "Public https URL on the default port. http, localhost, raw IP hosts, and non-443 ports are rejected." },
                  "topics": { "type": "array", "maxItems": 20, "items": { "type": "string" }, "description": "Topic slugs (e.g. ai, security). Empty or omitted means all topics." },
                  "tier": { "type": "integer", "minimum": 1, "maximum": 3, "description": "Minimum headline tier to deliver (3 = top stories only). Omitted means all tiers." }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Subscription created. The secret is returned only in this response.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": { "type": "string" },
                    "url": { "type": "string", "format": "uri" },
                    "topics": { "type": "array", "items": { "type": "string" } },
                    "minTier": { "type": ["integer", "null"] },
                    "secret": { "type": "string", "description": "64 hex characters. Shown once; used to sign deliveries as X-TechSnif-Signature." },
                    "note": { "type": "string" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "409": { "description": "This url is already registered. DELETE it (with the secret) to re-register.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "422": { "description": "Challenge verification failed: the endpoint did not answer 2xx with the challenge string echoed in its response body within 10 seconds.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "429": { "description": "Registration caps reached (global active subscription limit, or per-host limit of 5).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      },
      "delete": {
        "operationId": "unregisterWebhook",
        "summary": "Remove a webhook subscription",
        "description": "Deletes the subscription whose url and secret both match. The secret is the value returned once at registration.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["url", "secret"],
                "properties": {
                  "url": { "type": "string", "format": "uri" },
                  "secret": { "type": "string" }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Subscription removed", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" } } } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "403": { "description": "Secret does not match", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/mcp": {
      "get": {
        "operationId": "getMcpConfig",
        "summary": "MCP setup metadata",
        "responses": {
          "200": { "description": "Machine-readable MCP configuration", "content": { "application/json": { "schema": { "type": "object" } } } }
        }
      },
      "post": {
        "operationId": "mcpJsonRpc",
        "summary": "Hosted MCP endpoint (Streamable HTTP, stateless)",
        "description": "JSON-RPC 2.0 endpoint implementing the Model Context Protocol. Also served at https://mcp.techsnif.com. No authentication required.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "type": "object", "description": "JSON-RPC 2.0 request or batch" } } }
        },
        "responses": {
          "200": { "description": "JSON-RPC 2.0 response", "content": { "application/json": { "schema": { "type": "object" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" }
        }
      }
    }
  },
  "components": {
    "responses": {
      "BadRequest": {
        "description": "Invalid request parameters",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "NotFound": {
        "description": "Resource not found",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "properties": { "error": { "type": "string" } }
      },
      "StoryListResponse": {
        "type": "object",
        "properties": {
          "stories": { "type": "array", "items": { "$ref": "#/components/schemas/StoryCluster" } },
          "total": { "type": "integer", "description": "Total matching clusters (for pagination)" },
          "limit": { "type": "integer" },
          "offset": { "type": "integer" }
        }
      },
      "ArticleListResponse": {
        "type": "object",
        "properties": {
          "articles": { "type": "array", "items": { "$ref": "#/components/schemas/Article" } },
          "total": { "type": "integer" },
          "limit": { "type": "integer" },
          "offset": { "type": "integer" }
        }
      },
      "StorySummary": {
        "type": "object",
        "description": "Compact story cluster reference used by the changes feed, trending list, and SSE stream.",
        "properties": {
          "slug": { "type": "string" },
          "headline": { "type": "string" },
          "tier": { "type": "integer", "description": "Headline tier: 1=standard, 2=major, 3=top story" },
          "rank": { "type": "integer", "description": "Front page position, lower is more prominent" },
          "leadUrl": { "type": "string", "format": "uri" },
          "sourcePublisher": { "type": ["string", "null"] },
          "url": { "type": "string", "format": "uri", "description": "TechSnif story page" },
          "topics": { "type": "array", "items": { "type": "string" } }
        }
      },
      "TimelinePoint": {
        "type": "object",
        "properties": {
          "at": { "type": "string", "format": "date-time" },
          "rank": { "type": ["integer", "null"] },
          "tier": { "type": ["integer", "null"] },
          "commentaryCount": { "type": "integer" },
          "coverageCount": { "type": "integer" }
        }
      },
      "ClusterTimeline": {
        "type": "object",
        "properties": {
          "slug": { "type": "string" },
          "headline": { "type": "string" },
          "firstSeenAt": { "type": ["string", "null"], "format": "date-time" },
          "lastSeenAt": { "type": ["string", "null"], "format": "date-time" },
          "points": { "type": "array", "items": { "$ref": "#/components/schemas/TimelinePoint" } }
        }
      },
      "StoryChange": {
        "type": "object",
        "properties": {
          "type": { "type": "string", "enum": ["story.new", "story.updated"] },
          "at": { "type": "string", "format": "date-time" },
          "story": { "$ref": "#/components/schemas/StorySummary" }
        }
      },
      "ChangesResponse": {
        "type": "object",
        "properties": {
          "changes": { "type": "array", "items": { "$ref": "#/components/schemas/StoryChange" } },
          "nextCursor": { "type": ["string", "null"], "format": "date-time", "description": "Timestamp of the last returned event. Pass back as since to continue." },
          "hasMore": { "type": "boolean" }
        }
      },
      "TrendingStory": {
        "allOf": [
          { "$ref": "#/components/schemas/StorySummary" },
          {
            "type": "object",
            "properties": {
              "velocity": { "type": "integer", "description": "commentaryDelta + coverageDelta + rank improvement inside the window" },
              "commentaryDelta": { "type": "integer" },
              "coverageDelta": { "type": "integer" },
              "snapshotCount": { "type": "integer", "description": "Pipeline snapshots recorded inside the window" }
            }
          }
        ]
      },
      "TrendingResponse": {
        "type": "object",
        "properties": {
          "stories": { "type": "array", "items": { "$ref": "#/components/schemas/TrendingStory" } },
          "hours": { "type": "integer", "description": "Lookback window used for this response" }
        }
      },
      "StoryCluster": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "slug": { "type": "string" },
          "headline": { "type": "string" },
          "excerpt": { "type": "string" },
          "whyItMatters": { "type": "string", "description": "AI-generated 2-3 sentence context blurb (top stories only)" },
          "topics": { "type": "array", "items": { "type": "string" } },
          "rank": { "type": "integer" },
          "headlineTier": { "type": "integer", "description": "1=standard, 2=major, 3=top story" },
          "leadUrl": { "type": "string", "format": "uri" },
          "leadTitle": { "type": "string" },
          "sourcePublisher": { "type": "string" },
          "sourceAuthor": { "type": "string" },
          "imageUrl": { "type": "string", "format": "uri" },
          "publishedAt": { "type": "string", "format": "date-time" },
          "firstSeenAt": { "type": "string", "format": "date-time" },
          "lastSeenAt": { "type": "string", "format": "date-time" },
          "xPostUrl": { "type": "string", "format": "uri" },
          "links": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": { "type": "string" },
                "title": { "type": "string" },
                "url": { "type": "string", "format": "uri" },
                "publisher": { "type": "string" },
                "author": { "type": "string" },
                "linkType": { "type": "string", "enum": ["lead", "sub", "more", "related"] },
                "position": { "type": "integer" }
              }
            }
          },
          "commentaryLinks": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": { "type": "string" },
                "platform": { "type": "string" },
                "label": { "type": "string" },
                "url": { "type": "string", "format": "uri" },
                "position": { "type": "integer" }
              }
            }
          },
          "techsnifArticle": {
            "type": "object",
            "properties": {
              "slug": { "type": "string" },
              "title": { "type": "string" },
              "publishedAt": { "type": "string", "format": "date-time" }
            }
          }
        }
      },
      "Article": {
        "type": "object",
        "properties": {
          "slug": { "type": "string" },
          "title": { "type": "string" },
          "excerpt": { "type": "string" },
          "content": { "type": "string", "description": "HTML body (detail endpoint only)" },
          "category": { "type": "string" },
          "tags": { "type": "array", "items": { "type": "string" } },
          "image": { "type": "string", "format": "uri" },
          "source": { "type": "string" },
          "publishedAt": { "type": "string", "format": "date-time" }
        }
      },
      "LeaderboardEntry": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "slug": { "type": "string" },
          "name": { "type": "string" },
          "count": { "type": "integer" },
          "leadCount": { "type": "integer" },
          "rank": { "type": "integer" }
        }
      },
      "TechEvent": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "name": { "type": "string" },
          "location": { "type": "string" },
          "startDate": { "type": "string" },
          "endDate": { "type": "string" },
          "url": { "type": "string", "format": "uri" },
          "featured": { "type": "boolean" },
          "hybrid": { "type": "boolean" },
          "virtual": { "type": "boolean" }
        }
      }
    }
  }
}
