openapi: 3.1.0
info:
  title: PulseCheck API
  version: "2026-07-10"
  summary: Reverse-engineered, live-verified reference for PulseCheck.
  description: |
    **Unofficial.** PulseCheck publishes no API docs. Everything here was verified by
    live probing on 2026-07-10 with a real, freshly-rotated API key. Facts are labeled:

    - `x-verified: live` — exercised end-to-end against production and confirmed.
    - `x-verified: observed` — route/shape recovered from the app's own client bundle
      or a captured browser request, not fully round-tripped here.

    ## Two separate surfaces
    PulseCheck is **two** systems with **two** auth models. This trips people up, so
    it's the first thing to understand:

    | Surface | Host | Auth | What it does |
    |---------|------|------|--------------|
    | **Reporting API** | `api.pulsecheck.spectrocloud.com` | OAuth2 Bearer (your `pca_`/`psk_` key) | **Read-only** analytics: cycles, agent runs, people |
    | **App API** | `pulsecheck.spectrocloud.com` | SSO session **cookie** (Okta/Google/SAML) | **Everything interactive**: write/draft/submit check-ins, Ask AI, summaries |

    ### The key caveat
    Your rotated `pca_`/`psk_` key authenticates **only the Reporting API**, which is
    **read-only**. It **cannot** create, draft, submit, or edit a check-in; it cannot
    call Ask AI; it cannot read summary text. Those all live on the cookie-authenticated
    App API. There is (as of this probe) no API-key path to writing a check-in.

    ### On the `psk_`/`pca_` naming
    `pca_…` = client **id**, `psk_…` = client **secret**. They are exchanged at
    `POST /auth/token` (OAuth2 client-credentials) for a short-lived Bearer JWT.

servers:
  - url: https://api.pulsecheck.spectrocloud.com
    description: Reporting API (OAuth2 Bearer)
  - url: https://pulsecheck.spectrocloud.com
    description: App API (session cookie / SSO)

tags:
  - name: Auth
    description: Exchange your API key pair for a Bearer token.
  - name: Reporting
    description: "Read-only analytics on the Reporting API. Verified: live."
  - name: Check-in (App)
    description: "Create / draft / submit / edit check-ins. Cookie auth only."
  - name: Ask AI (App)
    description: "Conversational org assistant. Cookie auth only."

paths:
  # ─────────────────────────── Reporting API (Bearer) ───────────────────────────
  /auth/token:
    servers: [{ url: https://api.pulsecheck.spectrocloud.com }]
    post:
      tags: [Auth]
      operationId: issueToken
      summary: Exchange API key pair for a Bearer token
      x-verified: live
      description: |
        Standard OAuth2 **client-credentials** grant. Send your `pca_` id as
        `client_id` and your `psk_` secret as `client_secret`. Returns an RS256 JWT
        (issuer `pulse-check-token-broker`) valid for `expires_in` seconds. Basic-auth
        form (`-u pca_:psk_` + `grant_type`) also works.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [grant_type, client_id, client_secret]
              properties:
                grant_type: { type: string, enum: [client_credentials] }
                client_id: { type: string, example: "pca_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }
                client_secret: { type: string, example: "psk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }
      responses:
        "200":
          description: Token issued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token: { type: string, description: RS256 JWT Bearer token. }
                  token_type: { type: string, enum: [Bearer] }
                  expires_in: { type: integer, example: 3600 }
              example: { access_token: "eyJhbGciOiJSUzI1NiI...", token_type: "Bearer", expires_in: 3600 }
        "400":
          description: "`unsupported_grant_type` / `invalid_request` — bad or missing grant."
        "401":
          description: "`unauthenticated` — bad client id/secret."

  /api/v1/cycles:
    servers: [{ url: https://api.pulsecheck.spectrocloud.com }]
    get:
      tags: [Reporting]
      operationId: listCycles
      summary: List check-in cycles (weeks)
      x-verified: live
      description: Newest first. Each cycle is a weekly window; `run_count` is how many agent runs it has.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: page_token, in: query, required: false, schema: { type: string } }
      responses:
        "200":
          description: A page of cycles.
          content:
            application/json:
              schema:
                type: object
                properties:
                  cycles: { type: array, items: { $ref: "#/components/schemas/Cycle" } }
                  next_page_token: { type: [string, "null"] }
              example:
                cycles:
                  - { cycle_id: "7cb09611-0706-45be-8153-1797c3ef0b9f", start_date: "2026-07-06", end_date: "2026-07-12", run_count: 0 }
                  - { cycle_id: "17286785-e24b-49c7-9e89-8876d07abf56", start_date: "2026-06-29", end_date: "2026-07-05", run_count: 1 }
                next_page_token: null
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v1/cycles/{cycleId}/runs:
    servers: [{ url: https://api.pulsecheck.spectrocloud.com }]
    get:
      tags: [Reporting]
      operationId: listCycleRuns
      summary: List agent runs for a cycle
      x-verified: live
      description: |
        A **run** is an AI aggregation job over the org's check-ins (it reads the org
        chart + check-ins and writes items). This returns run **metadata** — status,
        model, token spend. It does **not** return the human-readable summary text; that
        is only surfaced in the app UI (`/home`).
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/CycleId"
        - { name: page_token, in: query, required: false, schema: { type: string } }
      responses:
        "200":
          description: Runs for the cycle.
          content:
            application/json:
              schema:
                type: object
                properties:
                  cycle_id: { type: string, format: uuid }
                  runs: { type: array, items: { $ref: "#/components/schemas/Run" } }
                  next_page_token: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v1/people:
    servers: [{ url: https://api.pulsecheck.spectrocloud.com }]
    get:
      tags: [Reporting]
      operationId: listPeople
      summary: List people (org roster)
      x-verified: live
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: page_token, in: query, required: false, schema: { type: string } }
      responses:
        "200":
          description: People directory.
          content:
            application/json:
              schema:
                type: object
                properties:
                  people: { type: array, items: { $ref: "#/components/schemas/Person" } }
                  next_page_token: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ─────────────────────────── App API (cookie) ───────────────────────────
  /my-check-in.data:
    servers: [{ url: https://pulsecheck.spectrocloud.com }]
    parameters:
      - $ref: "#/components/parameters/Cycle"
      - $ref: "#/components/parameters/Edit"
    get:
      tags: [Check-in (App)]
      operationId: loadCheckIn
      summary: Load your check-in for a cycle
      x-verified: live
      description: |
        Remix single-fetch loader. Without a valid session cookie it returns a
        `SingleFetchRedirect` to `/sign-in` (HTTP 202) — verified. With a cookie it
        returns your current check-in for that `cycle`. Use a past `cycle` UUID (from
        `GET /api/v1/cycles`) to read a past submission.
      security: [{ sessionCookie: [] }]
      responses:
        "202": { $ref: "#/components/responses/RemixData" }
    post:
      tags: [Check-in (App)]
      operationId: saveCheckIn
      summary: Create / draft / submit / edit a check-in
      x-verified: observed
      description: |
        Remix action. Field shape confirmed from a captured browser submission. This is
        the **only** write path for check-ins — the Reporting API cannot do this.

        - **New or edit**: same call; the `cycle` selects the week, `edit=1` targets the editor.
        - **Draft**: `intent=draft` (reversible, stays editable).
        - **Submit**: `intent=submit`.
        - **Edit a past submission**: point `cycle` at a past cycle UUID.

        Body is `application/x-www-form-urlencoded`. `accomplishments` and `blockers`
        accept rich-text HTML; `upcoming` is plain text.
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/CheckInForm" }
            examples:
              draft:
                summary: Save as draft
                value: { accomplishments: "<p>Shipped edge onboarding.</p>", upcoming: "2-node HA validation.", blockers: "<p>AMT firmware on node13.</p>", intent: draft }
              submit:
                summary: Submit
                value: { accomplishments: "<p>Shipped edge onboarding.</p>", upcoming: "2-node HA validation.", blockers: "<p>None.</p>", intent: submit }
      responses:
        "202": { $ref: "#/components/responses/RemixData" }

  /ai/chat/stream:
    servers: [{ url: https://pulsecheck.spectrocloud.com }]
    post:
      tags: [Ask AI (App)]
      operationId: askAi
      summary: Ask AI about the org (streaming)
      x-verified: observed
      description: |
        Backs the app's **Ask AI** button. Route confirmed in the client manifest
        (`routes/ai-chat-stream`, path `ai/chat/stream`); unauthenticated requests
        302-redirect to sign-in (verified). Cookie-authenticated and streaming
        (Server-Sent-Events style). Request body shape is parsed server-side and was
        not round-tripped here — treat the body below as provisional. Rate responses via
        `POST /ai-rating`.
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                message: { type: string, description: "The question (field name unconfirmed)." }
                conversationId: { type: [string, "null"] }
      responses:
        "200": { description: "text/event-stream of assistant tokens (provisional)." }
        "302": { description: "Redirect to /sign-in when unauthenticated (verified)." }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Bearer token from `POST /auth/token`. Reporting API only.
    sessionCookie:
      type: apiKey
      in: cookie
      name: __pulse_session
      description: >
        httpOnly SSO session cookie (Okta/Google/SAML), verified name `__pulse_session`.
        App API only. Obtain it by completing SSO in a browser (see the docs site's
        "Automating the session cookie" section) — there is no token grant for it.

  parameters:
    CycleId:
      name: cycleId
      in: path
      required: true
      schema: { type: string, format: uuid }
    Cycle:
      name: cycle
      in: query
      required: true
      description: Cycle (week) UUID, from `GET /api/v1/cycles` or the page URL `?cycle=`.
      schema: { type: string, format: uuid }
    Edit:
      name: edit
      in: query
      required: true
      schema: { type: integer, enum: [1] }

  responses:
    Unauthorized:
      description: Missing/invalid Bearer token.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: "unauthenticated", message: "valid bearer token required" } }
    RemixData:
      description: >
        Remix single-fetch response (`content-type: text/x-script`, header
        `x-remix-response: yes`). A turbo-stream body, not JSON — deserialized by the
        Remix client. Unauthenticated calls carry a `SingleFetchRedirect` to `/sign-in`.
      headers:
        x-remix-response: { schema: { type: string, enum: ["yes"] } }
      content:
        text/x-script:
          schema: { type: string }
          example: '[["SingleFetchRedirect",1],{...},"redirect","/sign-in?returnTo=%2Fmy-check-in","status",302,...]'

  schemas:
    Cycle:
      type: object
      properties:
        cycle_id: { type: string, format: uuid }
        start_date: { type: string, format: date }
        end_date: { type: string, format: date }
        run_count: { type: integer }
    Run:
      type: object
      properties:
        run_id: { type: string, format: uuid }
        cycle_id: { type: string, format: uuid }
        org_chart_version: { type: [string, "null"] }
        prompt_version: { type: [string, "null"] }
        status: { type: string, example: succeeded }
        created_at: { type: string, format: date-time }
        finished_at: { type: [string, "null"], format: date-time }
        items_written: { type: integer, example: 370 }
        model: { type: string, example: "gpt-5.4" }
        token_usage:
          type: object
          properties:
            priced: { type: boolean }
            totals: { type: object, additionalProperties: true }
            by_model: { type: object, additionalProperties: true }
    Person:
      type: object
      properties:
        person_id: { type: string, format: uuid }
        name: { type: string }
        work_email: { type: string, format: email }
        title: { type: string }
        department: { type: [string, "null"] }
        work_location: { type: [string, "null"] }
        employment_status: { type: string }
    CheckInForm:
      type: object
      required: [intent]
      properties:
        accomplishments: { type: string, description: "Rich-text HTML.", example: "<p>Shipped edge onboarding.</p>" }
        upcoming: { type: string, description: "Plain text.", example: "2-node HA validation." }
        blockers: { type: string, description: "Rich-text HTML.", example: "<p>None.</p>" }
        intent: { type: string, enum: [draft, submit] }
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: string }
            message: { type: string }
