{
  "openapi": "3.1.0",
  "info": {
    "title": "shir-man.com Homepage Agent Chat API",
    "version": "1.0.0",
    "description": "In-page chat agent for the AI & Trends dashboard. Answers questions about the aggregated feed and about a single item, using a read-only contract over the dashboard's own stored data. Sessions are short-lived signed tokens; answers stream as Server-Sent Events. No accounts, no server-side transcripts."
  },
  "servers": [
    {
      "url": "https://shir-man.com"
    }
  ],
  "paths": {
    "/api/agent/config": {
      "get": {
        "summary": "Get the agent's public capability and quota advertisement",
        "operationId": "getAgentConfig",
        "description": "Unauthenticated. Clients call this before opening the chat to learn whether the agent is enabled, whether human verification is required, and what the per-session and per-day message quotas are.",
        "responses": {
          "200": {
            "description": "Current capability payload",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Config"
                }
              }
            }
          }
        }
      }
    },
    "/api/agent/session": {
      "post": {
        "summary": "Mint a short-lived signed session token",
        "operationId": "createAgentSession",
        "description": "Returns an opaque HMAC-signed token that must be presented in the `X-Agent-Session` header on every chat request. Nothing about the caller is stored server-side; the token carries its own expiry and a daily-salted hash of the client IP. When `requireTurnstile` is advertised by `/api/agent/config`, the body must carry a Cloudflare Turnstile token.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SessionRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Minted session token",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Session"
                }
              }
            }
          },
          "400": {
            "description": "Malformed or oversized JSON body",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Human verification failed, or the request origin is not allowed",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Agent disabled by kill switch, or session signing is not configured",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/agent/chat": {
      "post": {
        "summary": "Stream one conversational turn as Server-Sent Events",
        "operationId": "streamAgentChat",
        "description": "Requires a valid `X-Agent-Session` header. Conversation state is replayed by the client on every request: the server keeps no transcript and calls the model with storage disabled. The response is an SSE stream carrying the event vocabulary described in `SseEvent`; comment lines (`: ping`) are sent periodically to keep the connection open.",
        "parameters": [
          {
            "name": "X-Agent-Session",
            "in": "header",
            "required": true,
            "description": "Session token returned by `POST /api/agent/session`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ChatRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Server-Sent Events stream of `meta`, `tool`, `delta`, `done` and `error` events",
            "headers": {
              "Cache-Control": {
                "description": "Always `no-store, no-transform`.",
                "schema": {
                  "type": "string"
                }
              },
              "X-Accel-Buffering": {
                "description": "Always `no`, so intermediaries do not buffer the stream.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "text/event-stream": {
                "schema": {
                  "type": "string",
                  "description": "Newline-delimited SSE records of the form `event: <name>` followed by `data: <json>`. See the `SseEvent` schemas for each payload."
                }
              }
            }
          },
          "400": {
            "description": "Malformed JSON, or the last message is not a user message",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, tampered or expired session token",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Request origin is not in the allowlist",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "Request body exceeds 32 KB",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "A throttling layer rejected the turn before any model call was made",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RateLimited"
                }
              }
            }
          },
          "503": {
            "description": "Agent disabled by kill switch or missing provider credentials",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/agent/status": {
      "get": {
        "summary": "Get coarse health for the site's API catalog",
        "operationId": "getAgentStatus",
        "responses": {
          "200": {
            "description": "Health payload",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Status"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Config": {
        "type": "object",
        "required": ["enabled", "requireTurnstile", "itemChat", "briefEnabled", "model", "quotas"],
        "properties": {
          "enabled": {
            "type": "boolean",
            "description": "False when the kill switch is set or no provider credential is configured."
          },
          "requireTurnstile": {
            "type": "boolean",
            "description": "When true, `POST /api/agent/session` requires a Turnstile token."
          },
          "turnstileSiteKey": {
            "type": ["string", "null"],
            "description": "Public Turnstile site key, present only while `requireTurnstile` is true."
          },
          "itemChat": {
            "type": "boolean",
            "description": "Whether the per-item \"AI Discuss\" scope is offered."
          },
          "briefEnabled": {
            "type": "boolean",
            "description": "Reserved for the digest brief; currently always false."
          },
          "model": {
            "type": "string",
            "description": "Model identifier the agent runs on."
          },
          "quotas": {
            "$ref": "#/components/schemas/Quotas"
          }
        }
      },
      "Quotas": {
        "type": "object",
        "required": ["messagesPerSession", "messagesPerDay"],
        "properties": {
          "messagesPerSession": { "type": "integer", "minimum": 0 },
          "messagesPerDay": {
            "type": "integer",
            "minimum": 0,
            "description": "Per-day cap counted against a hashed client IP, not an account."
          }
        }
      },
      "SessionRequest": {
        "type": "object",
        "properties": {
          "turnstileToken": {
            "type": "string",
            "description": "Cloudflare Turnstile response token. Required only when `/api/agent/config` reports `requireTurnstile: true`."
          }
        }
      },
      "Session": {
        "type": "object",
        "required": ["sessionToken", "expiresAt", "tier", "quotas"],
        "properties": {
          "sessionToken": {
            "type": "string",
            "description": "Opaque signed token. Send it as the `X-Agent-Session` header; it is never set as a cookie."
          },
          "expiresAt": { "type": "string", "format": "date-time" },
          "tier": { "type": "string", "enum": ["free"] },
          "quotas": {
            "$ref": "#/components/schemas/Quotas"
          }
        }
      },
      "ChatRequest": {
        "type": "object",
        "required": ["messages"],
        "properties": {
          "messages": {
            "type": "array",
            "minItems": 1,
            "description": "Conversation history replayed by the client. Only the last 12 entries are read, each truncated to 2000 characters, and the last entry must be a user message. Roles are normalized to `user`/`assistant`, so a client cannot inject a system or developer turn.",
            "items": {
              "$ref": "#/components/schemas/ChatMessage"
            }
          },
          "scope": {
            "$ref": "#/components/schemas/ChatScope"
          },
          "sort": {
            "type": "string",
            "enum": ["trending", "day", "week", "month"],
            "default": "trending",
            "description": "Which feed ranking to ground a site-scoped conversation in."
          },
          "settings": {
            "$ref": "#/components/schemas/ChatSettings"
          }
        }
      },
      "ChatMessage": {
        "type": "object",
        "required": ["role", "content"],
        "properties": {
          "role": { "type": "string", "enum": ["user", "assistant"] },
          "content": { "type": "string", "maxLength": 2000 }
        }
      },
      "ChatScope": {
        "type": "object",
        "description": "Scopes the conversation to one dashboard item. An unresolvable key degrades to the site scope rather than inventing context.",
        "required": ["type", "key"],
        "properties": {
          "type": { "type": "string", "enum": ["item"] },
          "key": {
            "type": "string",
            "maxLength": 132,
            "description": "The card's save key, `<source-prefix>-<source-id>` (for example `hn-45312890`)."
          }
        }
      },
      "ChatSettings": {
        "type": "object",
        "description": "User preferences, injected at user-level authority only: they may shape style, tone, topics and language, never tool, quota or safety policy.",
        "properties": {
          "preferredLanguage": {
            "type": "string",
            "enum": ["auto", "en", "ru"],
            "default": "auto"
          },
          "tone": {
            "type": "string",
            "enum": ["neutral", "casual", "terse"],
            "default": "neutral"
          },
          "prePrompt": {
            "type": "string",
            "maxLength": 500,
            "description": "Free-text note. Control, zero-width and bidi characters are stripped before use."
          },
          "interests": {
            "type": "array",
            "maxItems": 10,
            "description": "Topic words for optional personalization, derived in the browser from local reading history. Each entry is truncated to 40 characters.",
            "items": { "type": "string", "maxLength": 40 }
          }
        }
      },
      "SseMetaEvent": {
        "type": "object",
        "description": "`event: meta` — first event of the stream; reports the quota headroom left after this turn, which is already counted against it.",
        "required": ["remaining", "tier"],
        "properties": {
          "remaining": {
            "type": "object",
            "properties": {
              "session": { "type": "integer", "minimum": 0 },
              "day": { "type": "integer", "minimum": 0 }
            }
          },
          "tier": { "type": "string", "enum": ["free"] }
        }
      },
      "SseToolEvent": {
        "type": "object",
        "description": "`event: tool` — emitted twice per tool call, once on start and once on completion. Only the tool name is disclosed, never its arguments or results.",
        "required": ["name", "status"],
        "properties": {
          "name": {
            "type": "string",
            "enum": [
              "search_items",
              "get_period_top",
              "get_feed_snapshot",
              "read_article",
              "find_similar_items",
              "get_trend_history"
            ]
          },
          "status": { "type": "string", "enum": ["start", "done"] }
        }
      },
      "SseDeltaEvent": {
        "type": "object",
        "description": "`event: delta` — one chunk of answer text. Concatenate in arrival order.",
        "required": ["text"],
        "properties": {
          "text": { "type": "string" }
        }
      },
      "SseDoneEvent": {
        "type": "object",
        "description": "`event: done` — terminal success event.",
        "required": ["usage"],
        "properties": {
          "usage": {
            "type": "object",
            "properties": {
              "in": { "type": "integer", "minimum": 0 },
              "out": { "type": "integer", "minimum": 0 },
              "cached": { "type": "integer", "minimum": 0 }
            }
          },
          "degraded": {
            "type": "boolean",
            "description": "True when the daily spend breaker was in its soft band and the answer was produced without tool rounds."
          },
          "cached": {
            "type": "boolean",
            "description": "True when the answer was served from the 24h answer cache instead of the model."
          }
        }
      },
      "SseErrorEvent": {
        "type": "object",
        "description": "`event: error` — terminal failure event. Throttling is reported as a 429 response before the stream opens, not as this event.",
        "required": ["error", "code"],
        "properties": {
          "error": { "type": "string", "enum": ["timeout", "upstream_error"] },
          "code": { "type": "string" }
        }
      },
      "RateLimited": {
        "type": "object",
        "description": "Returned with HTTP 429 and a `Retry-After` header when a throttling layer rejects the turn.",
        "required": ["error", "scope"],
        "properties": {
          "error": { "type": "string", "enum": ["rate_limited"] },
          "scope": {
            "type": "string",
            "enum": ["ip", "session", "global-budget"],
            "description": "Which layer rejected the request: per-IP burst or daily cap, per-session message cap or minimum gap, or the global daily spend breaker."
          },
          "retryAfterSeconds": { "type": "integer", "minimum": 0 }
        }
      },
      "Status": {
        "type": "object",
        "required": ["ok", "service", "budget", "itemChat"],
        "properties": {
          "ok": { "type": "boolean" },
          "service": { "type": "string", "enum": ["agent-worker"] },
          "budget": {
            "type": "string",
            "enum": ["ok", "soft", "exhausted", "unknown"],
            "description": "Daily spend breaker state: `soft` degrades answers, `exhausted` rejects turns for the rest of the day."
          },
          "itemChat": { "type": "boolean" }
        }
      },
      "Error": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": {
            "type": "string",
            "enum": [
              "disabled",
              "misconfigured",
              "forbidden_origin",
              "human_verification_failed",
              "invalid_session",
              "invalid_json",
              "payload_too_large",
              "no_user_message",
              "not_found",
              "internal_error"
            ]
          },
          "reason": {
            "type": "string",
            "description": "Optional detail, for example `expired` or `bad_signature` on an invalid session."
          }
        }
      }
    }
  }
}
