openapi: 3.1.0
info:
  title: Atly Places API
  version: 0.0.1
  description: |
    Atly knows what real people say about close to 2 million places — the great majority in the United
    States, plus pockets of coverage in Mexico, Israel and Thailand — distilled into
    scores for 1,400+ intents ("work friendly", "gluten free", "first date", "cappuccino") and short
    quotable statements. It answers "where should I go for X near Y" with reasons: pick category ids with
    `list_categories`, a location with `list_areas` (or coordinates), then `search_places`; `get_place` for depth.


    Start with `get_docs` if you have not used this API before. Every response includes a `call_id`.

    Access: no key is required, but anonymous callers share a small hourly quota per IP. Call `create_api_key`
    with an email to get a key with a daily quota; send it as `X-API-Key` (or `Authorization: Bearer`). Every
    metered HTTP response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`; every request,
    including errors, counts one unit. `get_api_key` is free.
servers:
  - url: https://agentic-api.atly.com/v0
security:
  - {}
  - ApiKeyAuth: []
  - BearerAuth: []
components:
  schemas:
    PlaceSummary:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        address: { type: string }
        lat: { type: number }
        lon: { type: number }
        distance_km: { type: number, description: From the search center. }
        score: { type: number, description: Atly score 0–10 — for the requested categories in search, the overall score in get_place — relative to other places in the same country. Absent when unmeasured (too few reviews, or closed). }
        category_scores:
          type: array
          items:
            type: object
            properties:
              category_id: { type: string }
              name: { type: string }
              score: { type: number }
              reviews: { type: integer, description: Reviews that mention this category. }
        reasons:
          type: array
          description: Short first-person statements from real reviews, most useful first. Quote them.
          items:
            type: object
            properties:
              text: { type: string }
              kind: { type: string }
              sentiment: { type: string, enum: [positive, neutral, negative] }
        blurb: { type: string, description: One-line editorial summary. }
        place_type: { type: string }
        review_count: { type: integer }
        google_rating: { type: number }
        price: { type: string }
        gluten_free: { $ref: "#/components/schemas/GlutenFree" }
        url: { type: string, description: Canonical Atly page. Link it when you cite. }
        last_updated: { type: string, format: date-time }
    PlaceDetail:
      allOf:
        - $ref: "#/components/schemas/PlaceSummary"
        - type: object
          properties:
            phone: { type: string }
            website: { type: string }
            menu_url: { type: string }
            reservation_url: { type: string }
            hours:
              type: array
              description: Weekly opening periods as reported by Google, when known.
              items: { type: object }
            timezone: { type: string }
            good_to_know: { type: array, items: { type: string } }
            business_status: { type: string }
    GlutenFree:
      type: object
      description: Present only for places with a gluten-free identity.
      properties:
        safety: { type: string, enum: ["100% dedicated gluten-free", "celiac friendly (low risk of cross-contamination)", "accommodating (some risk of cross-contamination)", "discuss cross-contamination with staff"] }
        dedicated_kitchen: { type: string }
        trained_staff: { type: string }
        dedicated_fryer: { type: string }
        menu: { type: string }
        verified_by_atly: { type: boolean }
    Quota:
      type: object
      properties:
        limit: { type: integer, description: Calls allowed per window. }
        remaining: { type: integer }
        window: { type: string, enum: [hour, day] }
        resets_at: { type: string, format: date-time }
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
    BearerAuth:
      type: http
      scheme: bearer
      description: The same key as a bearer token, for clients that only speak Authorization headers (most MCP clients).
paths:
  /keys:
    post:
      operationId: create_api_key
      x-internal: true  # REST only: chat connectors authenticate with OAuth and never need this; keeps an email-sending primitive away from the model
      x-mcp:
        title: Create an API key
        readOnly: false
        readOnlyWhy: >-
          Creates an account record and a new API key, so it changes stored state.
        openWorld: true
        openWorldWhy: >-
          Sends a verification email through an external email provider.
        destructive: false
        destructiveWhy: >-
          Only adds a new key; existing keys, accounts and data are untouched.
      x-quota: create_key
      summary: Create an API key
      description: |
        Free, headless account creation: send an email address, get an API key back. The key is shown once;
        store it and send it as `X-API-Key`. Keys start at the unverified daily quota; when email sending is
        enabled a verification code is emailed — confirm it with `verify_email` to raise every key of the
        account to the verified quota. One key per call; the same email may create several keys (for example
        one per integration) — they share the account's quota.
      security: [{}]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email, maxLength: 254, description: Contact email for this key. Used to verify and to reach you about quota. }
                label: { type: string, maxLength: 80, description: "Optional name for the key, e.g. \"my travel GPT\"." }
      responses:
        "201":
          description: The new key
          content:
            application/json:
              schema:
                type: object
                properties:
                  api_key: { type: string, description: Send as X-API-Key. Shown only once. }
                  key_id: { type: string }
                  tier: { type: string }
                  quota: { $ref: "#/components/schemas/Quota" }
                  verification:
                    type: object
                    description: Present when a verification email was triggered.
                    properties:
                      sent: { type: boolean }
                      hint: { type: string }
                  doc_notes: { type: string }
                  call_id: { type: string }
  /keys/me:
    get:
      operationId: get_api_key
      x-mcp:
        title: Show my key and quota
        readOnly: true
        readOnlyWhy: >-
          Reports the caller's own tier and remaining quota; it changes no user-visible state. The only writes are internal service bookkeeping: a call-log row and a per-caller quota counter (and a last-used timestamp on the calling key).
        openWorld: false
        openWorldWhy: >-
          Reads internal account state only; no external system is contacted.
        destructive: false
        destructiveWhy: >-
          Does not delete or revoke anything.
      x-quota: none
      summary: Show my key, tier and remaining quota
      description: Who am I and how much quota is left. Works without a key too (then it describes the anonymous allowance for your IP).
      responses:
        "200":
          description: Caller status
          content:
            application/json:
              schema:
                type: object
                properties:
                  key_id: { type: string }
                  label: { type: string }
                  tier: { type: string, enum: [anonymous, unverified, verified] }
                  email_verified: { type: boolean }
                  quota: { $ref: "#/components/schemas/Quota" }
                  doc_notes: { type: string }
                  call_id: { type: string }
  /keys/verify:
    post:
      operationId: verify_email
      x-mcp:
        title: Confirm a verification code
        readOnly: false
        readOnlyWhy: >-
          Consumes a one-time verification code and raises the calling API key to the verified tier, which changes stored state.
        openWorld: false
        openWorldWhy: >-
          Validates the code against Atly's own store; it sends nothing and contacts no third-party system.
        destructive: false
        destructiveWhy: >-
          Only grants a higher quota; it revokes nothing and deletes nothing.
      x-quota: verify
      summary: Verify an email address
      description: |
        Confirms a verification sent by `create_api_key` (or `resend_verification`): send the `email`
        with its 6-digit `code`. Call this authenticated with the API key you want raised (send it as
        `X-API-Key`) and that key moves to the verified daily quota. Codes are single-use, expire after
        15 minutes, and are entered where verification began (there is no magic link).
      security: [{}]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, code]
              properties:
                email: { type: string, format: email, maxLength: 254 }
                code: { type: string, minLength: 6, maxLength: 6, pattern: "^[0-9]{6}$" }
      responses:
        "200":
          description: Verified
          content:
            application/json:
              schema:
                type: object
                properties:
                  verified: { type: boolean }
                  email: { type: string }
                  tier: { type: string }
                  key_elevated: { type: boolean, description: True when the key you called with was raised to the verified quota. }
                  doc_notes: { type: string }
                  call_id: { type: string }
  /keys/verify/resend:
    post:
      operationId: resend_verification
      x-internal: true  # REST only: chat connectors authenticate with OAuth and never need this; keeps an email-sending primitive away from the model
      x-mcp:
        title: Resend a verification code
        readOnly: false
        readOnlyWhy: >-
          Creates a new pending verification record, so it changes stored state.
        openWorld: true
        openWorldWhy: >-
          Sends an email through an external email provider.
        destructive: false
        destructiveWhy: >-
          Does not delete or revoke anything; previous codes expire on their own schedule.
      x-quota: verify
      summary: Send a fresh verification email
      description: |
        Emails a new code and link. The response is the same whether or not the address has an
        account (no enumeration). Per-IP hourly budget, plus a per-address cap on how many emails one inbox receives.
      security: [{}]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email, maxLength: 254 }
      responses:
        "200":
          description: Requested
          content:
            application/json:
              schema:
                type: object
                properties:
                  sent: { type: boolean }
                  doc_notes: { type: string }
                  call_id: { type: string }
  /places/search:
    post:
      operationId: search_places
      x-mcp:
        title: Search places
        readOnly: true
        readOnlyWhy: >-
          Only reads Atly's place catalog and reference data; it does not create, update or delete anything. The only writes are internal service bookkeeping: a call-log row and a per-caller quota counter (and a last-used timestamp on the calling key).
        openWorld: false
        openWorldWhy: >-
          Reads Atly's own data store; it does not write to the public internet or contact third-party systems.
        destructive: false
        destructiveWhy: >-
          Does not delete, overwrite, revoke access or perform any irreversible action.
      summary: Find places for an intent near a location
      description: |
        The core call. Give one or more category ids (from `list_categories`; all must match) and a location — either
        an `area_id` from `list_areas` or coordinates with a radius — and get places ranked by Atly's score for those
        categories, each with quotable reasons from real reviews, a link to cite, and freshness. Scores are 0–10 and
        relative to the area; a place with no score is unmeasured, not bad. If no place matches every category, trailing
        categories are dropped until something does (the first is never dropped), and `dropped_categories` says which.
        Ranking considers the strongest 100 candidates by Atly's base score. Scores are relative to other places in the same country.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [categories]
              properties:
                categories:
                  type: array
                  minItems: 1
                  maxItems: 5
                  items: { type: string }
                  description: Category ids, most important first. Every result matches all of them (after any relaxation).
                area_id:
                  type: string
                  description: "An id from `list_areas`; the search covers that area's viewport. Give this or lat/lon, not both."
                lat: { type: number, minimum: -90, maximum: 90 }
                lon: { type: number, minimum: -180, maximum: 180 }
                radius_km: { type: number, minimum: 0.2, maximum: 50, default: 5, description: Used with lat/lon. }
                limit: { type: integer, minimum: 1, maximum: 25, default: 10 }
                intent: { type: string, maxLength: 300, description: Optional. The user's request in their own words. Logged to improve the API; does not affect ranking. }
      responses:
        "200":
          description: Ranked places
          content:
            application/json:
              schema:
                type: object
                properties:
                  places:
                    type: array
                    items: { $ref: "#/components/schemas/PlaceSummary" }
                  total_matched:
                    description: How many places match in this area, capped ("1000+"). Absent when counting timed out.
                    oneOf:
                      - type: integer
                      - type: string
                  dropped_categories: { type: array, items: { type: string }, description: Category ids dropped to avoid an empty result. }
                  area:
                    description: The resolved search geometry — an area (id, name, level, center, bbox) or a circle (center, radius_km).
                    type: object
                  doc_notes: { type: string }
                  call_id: { type: string }
  /places/{id}:
    get:
      operationId: get_place
      x-mcp:
        title: Get place details
        readOnly: true
        readOnlyWhy: >-
          Only reads Atly's place catalog and reference data; it does not create, update or delete anything. The only writes are internal service bookkeeping: a call-log row and a per-caller quota counter (and a last-used timestamp on the calling key).
        openWorld: false
        openWorldWhy: >-
          Reads Atly's own data store; it does not write to the public internet or contact third-party systems.
        destructive: false
        destructiveWhy: >-
          Does not delete, overwrite, revoke access or perform any irreversible action.
      summary: Everything about one place
      description: |
        Full detail for a place id from `search_places`: profile, hours, all category scores, up to eight quotable
        statements, good-to-know facts, gluten-free identity when known, and the canonical Atly URL to cite.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, minLength: 3, maxLength: 40 }
      responses:
        "200":
          description: Place detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  place: { $ref: "#/components/schemas/PlaceDetail" }
                  doc_notes: { type: string }
                  call_id: { type: string }
  /feedback:
    post:
      operationId: submit_feedback
      x-mcp:
        title: Send feedback
        readOnly: false
        readOnlyWhy: >-
          Writes a feedback record linked to a prior call_id, so it changes stored state.
        openWorld: false
        openWorldWhy: >-
          The record is stored in Atly's private store; nothing is published externally.
        destructive: false
        destructiveWhy: >-
          Inserts or updates only the caller's own feedback for one call; no other data is deleted or made irreversible.
      summary: Tell us how a call went
      description: |
        Reference the `call_id` of any earlier response and say whether it helped. Free text welcome: what was right,
        what was missing or wrong. This is how the API improves. One row per caller per call; lightly rate-limited.
      x-quota: feedback
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [call_id, outcome]
              properties:
                call_id: { type: string, minLength: 36, maxLength: 36 }
                outcome: { type: string, enum: [helpful, partly_helpful, not_helpful, wrong] }
                notes: { type: string, maxLength: 2000 }
      responses:
        "200":
          description: Recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  recorded: { type: boolean }
                  doc_notes: { type: string }
                  call_id: { type: string }
  /health:
    get:
      operationId: health
      x-internal: true
      summary: Service health
      description: Liveness plus connectivity to Atly's place data and the API's own state store. No key needed.
      responses:
        "200":
          description: Health
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  env: { type: string }
                  version: { type: string }
                  places_db: { type: boolean }
                  state_db: { type: boolean }
                  via_edge: { type: boolean, description: True when reached through the public hostname's edge. }
                  dev_echo: { type: boolean, description: True when this host returns verification codes to unmetered callers instead of emailing them (never in production). }
                  call_id: { type: string }
  /docs:
    get:
      operationId: get_docs
      x-mcp:
        title: Read the Atly API guide
        readOnly: true
        readOnlyWhy: >-
          Only reads Atly's place catalog and reference data; it does not create, update or delete anything. The only writes are internal service bookkeeping: a call-log row and a per-caller quota counter (and a last-used timestamp on the calling key).
        openWorld: false
        openWorldWhy: >-
          Reads Atly's own data store; it does not write to the public internet or contact third-party systems.
        destructive: false
        destructiveWhy: >-
          Does not delete, overwrite, revoke access or perform any irreversible action.
      summary: Read the documentation
      description: |
        Returns the API guide as Markdown: what each operation is for, how to turn a user's request into
        category ids and a location, request and response shapes, how to read scores. Call this once per session.
      responses:
        "200":
          description: Documentation
          content:
            application/json:
              schema:
                type: object
                properties:
                  markdown: { type: string }
                  call_id: { type: string }
  /categories:
    get:
      operationId: list_categories
      x-mcp:
        title: Find categories
        readOnly: true
        readOnlyWhy: >-
          Only reads Atly's place catalog and reference data; it does not create, update or delete anything. The only writes are internal service bookkeeping: a call-log row and a per-caller quota counter (and a last-used timestamp on the calling key).
        openWorld: false
        openWorldWhy: >-
          Reads Atly's own data store; it does not write to the public internet or contact third-party systems.
        destructive: false
        destructiveWhy: >-
          Does not delete, overwrite, revoke access or perform any irreversible action.
      summary: List intent categories
      description: |
        The intent taxonomy: cuisines, dishes, features ("Wifi", "Outdoor Seating"), vibes ("Cozy", "Lively"),
        occasions ("First Date", "Group Dinner"), diets ("Gluten Free", "Keto") and more. Map the user's words to
        category ids before searching. Filter with `q`, one concept per call — exact and prefix matches rank first, then substrings and keywords;
        plurals, hyphens and small typos are tolerated — or fetch once without `q` and cache the full list.
      parameters:
        - name: q
          in: query
          description: Words to match against category names and keywords, e.g. "coffee", "dog friendly", "gluten-free". Ranked; tolerant of plurals and small typos.
          schema: { type: string, maxLength: 60 }
        - name: limit
          in: query
          description: Maximum categories to return.
          schema: { type: integer, minimum: 1, maximum: 2000, default: 2000 }
      responses:
        "200":
          description: Categories
          content:
            application/json:
              schema:
                type: object
                properties:
                  categories:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                        emoji: { type: string }
                        match: { type: string, description: How the query matched this category — exact, prefix, word, substring, keyword or fuzzy. }
                  total: { type: integer }
                  doc_notes: { type: string }
                  call_id: { type: string }
  /areas:
    get:
      operationId: list_areas
      x-mcp:
        title: Find areas
        readOnly: true
        readOnlyWhy: >-
          Only reads Atly's place catalog and reference data; it does not create, update or delete anything. The only writes are internal service bookkeeping: a call-log row and a per-caller quota counter (and a last-used timestamp on the calling key).
        openWorld: false
        openWorldWhy: >-
          Reads Atly's own data store; it does not write to the public internet or contact third-party systems.
        destructive: false
        destructiveWhy: >-
          Does not delete, overwrite, revoke access or perform any irreversible action.
      summary: List supported areas
      description: |
        Cities, neighborhoods and states Atly covers — densest in the United States, with pockets in Mexico, Israel and Thailand — each with an `id` you can pass to
        search, a center, a bounding box when known, and how many places it holds. Filter with `q` (substring of
        the name) and `level` (city, neighborhood, state). Results are ordered by place count.
      parameters:
        - name: q
          in: query
          description: Area name or common abbreviation, e.g. "san diego", "midtown", "nyc". Exact and prefix matches rank first.
          schema: { type: string, maxLength: 80 }
        - name: level
          in: query
          description: Restrict to one level.
          schema: { type: string, enum: [city, neighborhood, state] }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        "200":
          description: Areas
          content:
            application/json:
              schema:
                type: object
                properties:
                  areas:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                        level: { type: string }
                        region: { type: string, description: State for US cities and neighborhoods; the equivalent district or province elsewhere. }
                        parent: { type: string, description: City for neighborhoods. }
                        country: { type: string, description: Country the area is in. Coverage is densest in the United States, with pockets in Mexico, Israel and Thailand. }
                        place_count: { type: integer }
                        bbox: { type: array, items: { type: number }, description: "[west, south, east, north]" }
                        center: { type: array, items: { type: number }, description: "[lon, lat]" }
                  total: { type: integer }
                  doc_notes: { type: string }
                  call_id: { type: string }
