openapi: 3.1.0
info:
  title: Jobby.dev Public API
  version: "1.0.0"
  description: |
    Public, read-only discovery surface for Jobby.dev — the on-demand job fair.

    Agents can use these endpoints without authentication to answer:
    *"Is there a live Jobby.dev session a user should jump into right now?"*

    Scoped write actions (queue-join, own-profile read/write, own-match read)
    use personal access tokens the user mints at `/settings/api-tokens`.
    Present as `Authorization: Bearer jbb_...`. The humans-only booth rule
    means there is intentionally no endpoint to accept a match or enter a
    video room. Return the web URL to the human instead.

    Recommended header on every agent-authored request:
    `X-Agent-Identity: <vendor>/<product> (contact)`
servers:
  - url: https://jobby.dev
paths:
  /api/v1/live-events/live-now:
    get:
      operationId: liveEventsLiveNow
      summary: Currently-live job-fair events (open-invite only)
      description: |
        Returns Jobby.dev events with status `live` or `paused` and visibility
        `open_invite`. Edge-cached ~15s; safe to call at any frequency.
      responses:
        "200":
          description: List of currently-live events.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LiveNowResponse"
        "429":
          $ref: "#/components/responses/RateLimited"
  /api/v1/jobs/search:
    get:
      operationId: jobsSearch
      summary: Search jobs attached to currently-live open-invite events
      description: |
        Only returns jobs whose parent event is currently live and
        open-invite. Edge-cached ~30s keyed on query params.
      parameters:
        - in: query
          name: skills
          description: Comma-separated skill tags; returns jobs whose essential or bonus skills overlap.
          schema: { type: string, example: "rust,go" }
        - in: query
          name: remote
          description: Filter by remote preference (remote, hybrid, onsite).
          schema: { type: string, enum: [remote, hybrid, onsite] }
        - in: query
          name: location
          description: Substring match on job location.
          schema: { type: string }
        - in: query
          name: salary_min
          description: Minimum salary the candidate wants; returns jobs whose salary_max is at or above this.
          schema: { type: integer, minimum: 0 }
        - in: query
          name: limit
          schema: { type: integer, default: 20, minimum: 1, maximum: 50 }
      responses:
        "200":
          description: Matching jobs.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JobsSearchResponse"
        "429":
          $ref: "#/components/responses/RateLimited"
  /api/v1/queue/join:
    post:
      operationId: queueJoin
      summary: Join the matchmaking queue
      description: |
        Places the authenticated seeker in the Jobby.dev queue. Agents cannot
        accept a match — that still requires a human tap in the browser or
        native app. Optional live-event scoping is supported here; job scoping
        is not. Requires scope `queue:write`.
      security:
        - ApiToken: [queue:write]
      parameters:
        - $ref: "#/components/parameters/AgentIdentity"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                live_event_slug: { type: string, minLength: 3, maxLength: 64 }
      responses:
        "200":
          description: Queue entry created or resumed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QueueJoinResponse"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /api/v1/queue/status:
    get:
      operationId: queueStatus
      summary: Read current queue status
      description: Requires scope `queue:read`.
      security:
        - ApiToken: [queue:read]
      parameters:
        - $ref: "#/components/parameters/AgentIdentity"
      responses:
        "200":
          description: Current queue entry or null.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QueueStatusResponse"
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/matches:
    get:
      operationId: matchesList
      summary: List my matches
      description: Requires scope `matches:read`.
      security:
        - ApiToken: [matches:read]
      parameters:
        - $ref: "#/components/parameters/AgentIdentity"
      responses:
        "200":
          description: Up to 50 recent matches for the authenticated user.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MatchesListResponse"
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/v1/profile:
    get:
      operationId: profileGet
      summary: Read my profile
      description: Requires scope `profile:read`.
      security:
        - ApiToken: [profile:read]
      parameters:
        - $ref: "#/components/parameters/AgentIdentity"
      responses:
        "200":
          description: The authenticated user's profile (or null if not yet created).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProfileResponse"
        "401": { $ref: "#/components/responses/Unauthorized" }
    patch:
      operationId: profilePatch
      summary: Update my profile
      description: Requires scope `profile:write`.
      security:
        - ApiToken: [profile:write]
      parameters:
        - $ref: "#/components/parameters/AgentIdentity"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                full_name: { type: string }
                title: { type: string }
                summary: { type: string, nullable: true }
                skills:
                  type: array
                  items: { type: string }
                experience_years: { type: integer, minimum: 0, maximum: 60 }
                location: { type: string, nullable: true }
                remote_preference:
                  type: string
                  enum: [onsite, hybrid, fully_remote]
                  nullable: true
      responses:
        "200":
          description: Update accepted.
          content:
            application/json:
              schema:
                type: object
                required: [profile_id]
                properties:
                  profile_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409":
          description: User has not completed onboarding yet.
  /api/v1/events/{slug}:
    get:
      operationId: eventBySlug
      summary: Single event detail (open-invite only)
      parameters:
        - in: path
          name: slug
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Event detail.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EventDetailResponse"
        "404":
          description: Event not found or not publicly discoverable.
        "429":
          $ref: "#/components/responses/RateLimited"
components:
  parameters:
    AgentIdentity:
      name: X-Agent-Identity
      in: header
      required: false
      description: |
        Recommended on every agent-authored request. Format:
        `<vendor>/<product> (contact)` — e.g. `Claude/1.0 (support@anthropic.com)`.
        Stored on agent-originated queue entries so the human counterparty
        sees a subtle "joined via agent" chip before the video call. Falls
        back to the token's friendly name when the header is absent.
      schema:
        type: string
        maxLength: 200
        example: "Claude/1.0 (support@anthropic.com)"
  securitySchemes:
    ApiToken:
      type: http
      scheme: bearer
      bearerFormat: jbb_*
      description: |
        Personal access token minted at `/settings/api-tokens`. Scopes are
        listed in the operation's `security` block.
  responses:
    RateLimited:
      description: Too many requests; retry after the `Retry-After` header.
    Unauthorized:
      description: Missing or invalid API token.
    Forbidden:
      description: Token is valid but lacks the required scope.
  schemas:
    LiveEvent:
      type: object
      required: [slug, status, company_name, accent_color, join_url, started_at]
      properties:
        slug: { type: string, example: "acme-backend-hiring" }
        status: { type: string, enum: [live, paused] }
        company_name: { type: string }
        logo_url: { type: string, nullable: true }
        accent_color: { type: string, example: "#f59e0b" }
        headline: { type: string, nullable: true }
        description: { type: string, nullable: true }
        started_at: { type: string, format: date-time, nullable: true }
        ends_at: { type: string, format: date-time, nullable: true }
        candidates_joined: { type: integer }
        matches_made: { type: integer }
        job_titles:
          type: array
          items: { type: string }
        join_url:
          type: string
          format: uri
          description: Deep link a human taps to land on the event page and join the queue.
    Job:
      type: object
      required: [id, title, company_name, essential_skills, event_slug, join_url]
      properties:
        id: { type: string, format: uuid }
        title: { type: string }
        company_name: { type: string }
        logo_url: { type: string, nullable: true }
        essential_skills:
          type: array
          items: { type: string }
        bonus_skills:
          type: array
          items: { type: string }
        experience_min: { type: integer }
        salary_min: { type: integer, nullable: true }
        salary_max: { type: integer, nullable: true }
        location: { type: string, nullable: true }
        remote_preference: { type: string, nullable: true }
        event_slug: { type: string }
        join_url:
          type: string
          format: uri
    LiveNowResponse:
      type: object
      required: [events, generated_at]
      properties:
        events:
          type: array
          items: { $ref: "#/components/schemas/LiveEvent" }
        generated_at: { type: string, format: date-time }
    JobsSearchResponse:
      type: object
      required: [jobs, generated_at]
      properties:
        jobs:
          type: array
          items: { $ref: "#/components/schemas/Job" }
        generated_at: { type: string, format: date-time }
    EventDetailResponse:
      type: object
      required: [event, jobs]
      properties:
        event: { $ref: "#/components/schemas/LiveEvent" }
        jobs:
          type: array
          items: { $ref: "#/components/schemas/Job" }
    QueueJoinResponse:
      type: object
      required: [queue_id, status, join_url, humans_only_note]
      properties:
        queue_id: { type: string, format: uuid }
        status: { type: string, enum: [waiting] }
        live_event_slug: { type: string, nullable: true }
        join_url: { type: string, format: uri }
        humans_only_note: { type: string }
    QueueStatusResponse:
      type: object
      required: [queue_entry]
      properties:
        queue_entry:
          nullable: true
          type: object
          required: [id, status, entered_at, job_ids, join_url]
          properties:
            id: { type: string, format: uuid }
            status: { type: string }
            entered_at: { type: string, format: date-time }
            interview_mode: { type: string, nullable: true }
            job_ids:
              type: array
              items: { type: string, format: uuid }
            live_event_slug: { type: string, nullable: true }
            join_url: { type: string, format: uri }
    MatchesListResponse:
      type: object
      required: [matches]
      properties:
        matches:
          type: array
          items:
            type: object
            required: [id, status, created_at, details_url]
            properties:
              id: { type: string, format: uuid }
              status: { type: string }
              match_score: { type: number, nullable: true }
              created_at: { type: string, format: date-time }
              expires_at: { type: string, format: date-time, nullable: true }
              role: { type: string, enum: [seeker, recruiter], nullable: true }
              job_id: { type: string, format: uuid, nullable: true }
              job_title: { type: string, nullable: true }
              details_url: { type: string, format: uri }
    ProfileResponse:
      type: object
      required: [profile]
      properties:
        profile:
          nullable: true
          type: object
          properties:
            full_name: { type: string }
            title: { type: string }
            summary: { type: string, nullable: true }
            skills:
              type: array
              items: { type: string }
            experience_years: { type: integer }
            location: { type: string, nullable: true }
            remote_preference: { type: string, nullable: true }
