{
  "openapi": "3.1.0",
  "info": {
    "title": "Pace API",
    "version": "1.0.0",
    "summary": "Read upcoming wakes and queue changes for a Pace iPhone alarm.",
    "description": "Pace is an agentic iPhone alarm. This API lets any program — a Telegram bot, a cron script, a home-automation hook — read a user's upcoming wakes and queue changes **as intent**. The server never rings: every write becomes an intent the iPhone pulls on its next sync, applies to AlarmKit (or declines), and acknowledges. Read `GET /v1/intents/{id}` to see what the phone did.\n\nAuthenticate with a personal access token from `POST /v1/tokens/exchange` (pairing code shown in the Pace app) or with an OAuth 2.1 access token from the MCP flow. All dates and times are local to the user's IANA time zone, returned as `tz`.\n\nSDK: https://github.com/guzus/pace-sdk · Docs: https://site-production-3ba3.up.railway.app/api/",
    "contact": {
      "name": "Pace support",
      "url": "https://site-production-3ba3.up.railway.app/support/"
    }
  },
  "servers": [
    {
      "url": "https://mcp-production-e0af.up.railway.app",
      "description": "Production"
    }
  ],
  "tags": [
    { "name": "tokens", "description": "Personal access tokens" },
    { "name": "wakes", "description": "Upcoming wake occurrences (read)" },
    { "name": "intents", "description": "Queue changes and track what the phone did" }
  ],
  "security": [{ "bearer": [] }],
  "paths": {
    "/v1/tokens/exchange": {
      "post": {
        "tags": ["tokens"],
        "operationId": "exchangePairingCode",
        "summary": "Exchange a pairing code for a personal access token",
        "description": "Consumes the 8-character pairing code the Pace app shows (Settings → Connect an agent). Single use, valid 10 minutes — the same rules as the OAuth consent page. The token is returned once and stored only as a SHA-256 hash. It lives 90 days; there is no refresh — pair again. Rate limited per client IP (60/min).",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/ExchangeRequest" },
              "example": { "pairing_code": "K7PM-3QXZ", "name": "my telegram bot", "scope": "wakes:write" }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Token minted",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Token" },
                "example": { "token": "pat_…", "name": "my telegram bot", "scope": "wakes:write", "expires_at": "2026-11-26T13:00:00Z", "token_id": "tok_4f1c…" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/tokens/me": {
      "delete": {
        "tags": ["tokens"],
        "operationId": "revokeOwnToken",
        "summary": "Revoke the calling personal access token",
        "description": "The bearer must be the personal access token itself. Idempotent. The phone can also revoke every token by unpairing (`DELETE /v1/devices/me`).",
        "responses": {
          "204": { "description": "Revoked" },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/wakes/next": {
      "get": {
        "tags": ["wakes"],
        "operationId": "getNextWake",
        "summary": "The next wake that will actually ring",
        "description": "First occurrence in the next 8 days that is not skipped, after applying the phone's overrides and every intent the phone has not applied yet (`pending: true`). Requires `wakes:read` (`wakes:write` implies it).",
        "responses": {
          "200": {
            "description": "Next wake",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Wake" },
                "example": { "occurrence_id": "A1@2026-08-29", "date": "2026-08-29", "time": "07:00", "tz": "Asia/Seoul", "label": "Wake", "reason": "standup", "source": "rule", "skipped": false, "alarm_id": "A1", "at": "2026-08-29T07:00:00+09:00", "pending": false }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": {
            "description": "No upcoming wake (nothing scheduled, or the phone has not synced yet)",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "error": "no_upcoming_wake", "message": "no wake is scheduled in the next 8 days" }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/wakes": {
      "get": {
        "tags": ["wakes"],
        "operationId": "listWakes",
        "summary": "Every wake occurrence in the next N days",
        "description": "Chronological. Skipped occurrences are included with `skipped: true`; proposals the phone has not created yet appear with `source: \"proposal\"` and `alarm_id: null`. Requires `wakes:read`.",
        "parameters": [
          {
            "name": "days",
            "in": "query",
            "required": false,
            "description": "Window length in days, 1–60. Default 14.",
            "schema": { "type": "integer", "minimum": 1, "maximum": 60, "default": 14 }
          }
        ],
        "responses": {
          "200": {
            "description": "Window",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/WakeList" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/wakes/proposals": {
      "post": {
        "tags": ["intents"],
        "operationId": "proposeWake",
        "summary": "Propose a new one-time wake",
        "description": "Queues a `propose_wake` intent. The phone creates a one-shot alarm (or shows it as a suggestion, depending on the user's autonomy setting). `occurrence_id` in the response is `null`: the alarm does not exist until the phone creates it. The same date/time/reason again returns the original intent with `deduplicated: true` while it is still live. Requires `wakes:write`.",
        "parameters": [{ "$ref": "#/components/parameters/IdempotencyKey" }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/ProposeRequest" },
              "example": { "date": "2026-08-29", "time": "07:30", "reason": "flight at 9:40" }
            }
          }
        },
        "responses": {
          "202": { "$ref": "#/components/responses/Accepted" },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/occurrences/{id}/move": {
      "post": {
        "tags": ["intents"],
        "operationId": "moveOccurrence",
        "summary": "Move one occurrence to a new time",
        "description": "Queues a `move_occurrence` intent for that day only; the alarm's other days are untouched. Moving a skipped occurrence un-skips it. Requires `wakes:write`.",
        "parameters": [{ "$ref": "#/components/parameters/OccurrenceID" }, { "$ref": "#/components/parameters/IdempotencyKey" }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/MoveRequest" },
              "example": { "time": "07:30", "reason": "late train" }
            }
          }
        },
        "responses": {
          "202": { "$ref": "#/components/responses/Accepted" },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/occurrences/{id}/skip": {
      "post": {
        "tags": ["intents"],
        "operationId": "skipOccurrence",
        "summary": "Skip one occurrence",
        "description": "Queues a `skip_occurrence` intent: the alarm does not ring that day and keeps ringing on its other days. Never deletes the alarm. The body may be empty. Requires `wakes:write`.",
        "parameters": [{ "$ref": "#/components/parameters/OccurrenceID" }, { "$ref": "#/components/parameters/IdempotencyKey" }],
        "requestBody": {
          "required": false,
          "content": { "application/json": { "schema": { "type": "object", "additionalProperties": false } } }
        },
        "responses": {
          "202": { "$ref": "#/components/responses/Accepted" },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/occurrences/{id}/note": {
      "post": {
        "tags": ["intents"],
        "operationId": "setOccurrenceNote",
        "summary": "Set the reason shown when one occurrence rings",
        "description": "Queues a `set_occurrence_note` intent. Does not change the time. Requires `wakes:write`.",
        "parameters": [{ "$ref": "#/components/parameters/OccurrenceID" }, { "$ref": "#/components/parameters/IdempotencyKey" }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/NoteRequest" },
              "example": { "reason": "Flight to Tokyo at 9:40, leave by 7" }
            }
          }
        },
        "responses": {
          "202": { "$ref": "#/components/responses/Accepted" },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/intents/{id}": {
      "get": {
        "tags": ["intents"],
        "operationId": "getIntent",
        "summary": "What happened to a queued change",
        "description": "`pending` → the phone has not pulled it; `acked` → pulled, awaiting the user (autonomy = suggest) or application; `applied` / `rejected` → the phone's verdict (`result` explains); `expired` → the date passed before the phone applied it. Requires `wakes:read`.",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "description": "Intent id from a 202 response", "schema": { "type": "string", "pattern": "^int_[0-9a-f]+$" } }
        ],
        "responses": {
          "200": {
            "description": "Intent",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Intent" },
                "example": { "intent_id": "int_5b0c…", "kind": "move_occurrence", "status": "applied", "result": "AlarmKit rescheduled", "created_at": "2026-08-28T13:02:11Z", "resolved_at": "2026-08-28T13:05:40Z", "occurrence_id": "A1@2026-08-29" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/openapi.json": {
      "get": {
        "tags": ["tokens"],
        "operationId": "getOpenAPI",
        "summary": "This document",
        "security": [],
        "responses": {
          "200": { "description": "OpenAPI 3.1 document", "content": { "application/json": { "schema": { "type": "object" } } } }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearer": {
        "type": "http",
        "scheme": "bearer",
        "description": "A personal access token (`pat_…`, from `POST /v1/tokens/exchange`) or an OAuth 2.1 access token from the MCP flow. Scopes: `wakes:read` for GET, `wakes:write` for POST (`wakes:write` implies `wakes:read`)."
      }
    },
    "parameters": {
      "OccurrenceID": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "Occurrence id `<alarm_id>@<YYYY-MM-DD>` from `GET /v1/wakes`. Stable across days and snapshots.",
        "schema": { "type": "string", "examples": ["A1@2026-08-29"] }
      },
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "description": "Optional client key (≤ 200 chars). A retry with the same key and body returns the original 202 with `deduplicated: true`; the same key with a different body is a 409. Without it a key is derived from the arguments, so exact retries still collapse.",
        "schema": { "type": "string", "maxLength": 200 }
      }
    },
    "responses": {
      "Accepted": {
        "description": "Intent queued; the phone applies it on its next sync",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Accepted" },
            "example": { "intent_id": "int_5b0c…", "status": "pending", "occurrence_id": "A1@2026-08-29", "deduplicated": false }
          }
        }
      },
      "BadRequest": {
        "description": "Validation failed (`bad_json`, `bad_date`, `bad_time`, `past_date`, `bad_occurrence_id`, `bad_days`, `empty_reason`, `reason_too_long`, `bad_pairing_code`, `bad_name`, `bad_scope`, `bad_idempotency_key`, `not_a_personal_token`)",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "bad_time", "message": "time \"7:30\" must be HH:MM (24h)" } } }
      },
      "Unauthorized": {
        "description": "Missing, unknown, expired, or revoked token. Carries `WWW-Authenticate: Bearer`.",
        "headers": { "WWW-Authenticate": { "schema": { "type": "string" }, "description": "`Bearer realm=\"pace\"`, with `error=\"invalid_token\"` when a token was presented" } },
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "unauthorized", "message": "bearer token required" } } }
      },
      "Forbidden": {
        "description": "Token lacks the scope this route needs",
        "headers": { "WWW-Authenticate": { "schema": { "type": "string" }, "description": "`Bearer realm=\"pace\", error=\"insufficient_scope\", scope=\"wakes:write\"`" } },
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "insufficient_scope", "message": "token lacks wakes:write" } } }
      },
      "NotFound": {
        "description": "Unknown occurrence (`unknown_occurrence`: not one of this user's upcoming wakes — unknown alarm, disabled, not on that weekday, already rang, or too far out) or unknown intent (`unknown_intent`)",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "unknown_occurrence", "message": "occurrence \"A1@2026-08-30\" is not an upcoming wake on this user's iPhone" } } }
      },
      "Conflict": {
        "description": "`Idempotency-Key` reused with different arguments",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "idempotency_conflict", "message": "idempotency key \"client_abc\" was already used for a different request; omit it or choose a new key" } } }
      },
      "RateLimited": {
        "description": "More than 60 requests in a minute for this token (or IP on the exchange route)",
        "headers": { "Retry-After": { "schema": { "type": "integer" }, "description": "Seconds until one more request is allowed" } },
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "rate_limited", "message": "limit is 60 requests per minute; retry in 1s" } } }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": ["error", "message"],
        "properties": {
          "error": { "type": "string", "description": "Machine-readable snake_case code" },
          "message": { "type": "string", "description": "Human-readable explanation" }
        }
      },
      "ExchangeRequest": {
        "type": "object",
        "required": ["pairing_code", "name"],
        "additionalProperties": false,
        "properties": {
          "pairing_code": { "type": "string", "description": "8 characters as shown in the Pace app; case and dashes are ignored", "examples": ["K7PM-3QXZ"] },
          "name": { "type": "string", "maxLength": 80, "description": "Shown on the phone as the connected agent" },
          "scope": { "type": "string", "description": "Space-separated subset of `wakes:read wakes:write`. Omitted or empty = both.", "examples": ["wakes:write", "wakes:read wakes:write"] }
        }
      },
      "Token": {
        "type": "object",
        "required": ["token", "name", "scope", "expires_at", "token_id"],
        "properties": {
          "token": { "type": "string", "description": "Opaque bearer, shown once. Prefix `pat_`." },
          "name": { "type": "string" },
          "scope": { "type": "string" },
          "expires_at": { "type": "string", "format": "date-time", "description": "90 days after issue (UTC, RFC 3339)" },
          "token_id": { "type": "string", "description": "Public id, prefix `tok_`; appears in the phone's connected-agents list" }
        }
      },
      "Wake": {
        "type": "object",
        "required": ["occurrence_id", "date", "time", "tz", "label", "reason", "source", "skipped", "alarm_id", "at", "pending"],
        "properties": {
          "occurrence_id": { "type": "string", "description": "`<alarm_id>@<YYYY-MM-DD>`; for a proposal `proposed:<intent_id>`" },
          "date": { "type": "string", "format": "date", "description": "Local date in `tz`" },
          "time": { "type": "string", "pattern": "^[0-2][0-9]:[0-5][0-9]$", "description": "Local time HH:MM after overrides and pending intents" },
          "tz": { "type": "string", "description": "IANA zone" },
          "label": { "type": "string" },
          "reason": { "type": "string" },
          "source": { "type": "string", "enum": ["rule", "override", "proposal"], "description": "`rule`: the alarm's own schedule; `override`: moved or skipped for this day; `proposal`: a `propose_wake` intent the phone has not created yet" },
          "skipped": { "type": "boolean" },
          "alarm_id": { "type": ["string", "null"], "description": "The phone's alarm id; `null` for a proposal" },
          "at": { "type": "string", "format": "date-time", "description": "Absolute instant, RFC 3339 with the user's offset" },
          "pending": { "type": "boolean", "description": "An intent on this occurrence is queued but not yet applied by the phone" }
        }
      },
      "WakeList": {
        "type": "object",
        "required": ["tz", "days", "now", "snapshot_at", "wakes"],
        "properties": {
          "tz": { "type": "string" },
          "days": { "type": "integer" },
          "now": { "type": "string", "format": "date-time", "description": "Server clock in `tz`" },
          "snapshot_at": { "type": "string", "description": "When the phone last pushed its alarms (RFC 3339), or empty if never" },
          "wakes": { "type": "array", "items": { "$ref": "#/components/schemas/Wake" } }
        }
      },
      "ProposeRequest": {
        "type": "object",
        "required": ["date", "time"],
        "additionalProperties": false,
        "properties": {
          "date": { "type": "string", "format": "date", "description": "YYYY-MM-DD in the user's zone; must be in the future" },
          "time": { "type": "string", "pattern": "^[0-2][0-9]:[0-5][0-9]$", "description": "HH:MM, 24-hour" },
          "reason": { "type": "string", "maxLength": 280, "description": "Shown on the alarm" }
        }
      },
      "MoveRequest": {
        "type": "object",
        "required": ["time"],
        "additionalProperties": false,
        "properties": {
          "time": { "type": "string", "pattern": "^[0-2][0-9]:[0-5][0-9]$", "description": "New HH:MM, same date" },
          "reason": { "type": "string", "maxLength": 280, "description": "Optional; stored on the intent" }
        }
      },
      "NoteRequest": {
        "type": "object",
        "required": ["reason"],
        "additionalProperties": false,
        "properties": {
          "reason": { "type": "string", "minLength": 1, "maxLength": 280 }
        }
      },
      "Accepted": {
        "type": "object",
        "required": ["intent_id", "status", "occurrence_id", "deduplicated"],
        "properties": {
          "intent_id": { "type": "string" },
          "status": { "type": "string", "enum": ["pending", "acked"] },
          "occurrence_id": { "type": ["string", "null"], "description": "The targeted occurrence; `null` for a proposal" },
          "deduplicated": { "type": "boolean", "description": "`true` when an identical live intent already existed and nothing new was queued" }
        }
      },
      "Intent": {
        "type": "object",
        "required": ["intent_id", "kind", "status", "result", "created_at", "resolved_at", "occurrence_id"],
        "properties": {
          "intent_id": { "type": "string" },
          "kind": { "type": "string", "enum": ["propose_wake", "move_occurrence", "skip_occurrence", "set_occurrence_note"] },
          "status": { "type": "string", "enum": ["pending", "acked", "applied", "rejected", "expired"] },
          "result": { "type": ["string", "null"], "description": "The phone's explanation, when it gave one" },
          "created_at": { "type": "string", "format": "date-time" },
          "resolved_at": { "type": ["string", "null"], "format": "date-time", "description": "When the phone applied or rejected it; `null` while live and for `expired`" },
          "occurrence_id": { "type": ["string", "null"] }
        }
      }
    }
  }
}
