{
  "openapi": "3.1.0",
  "info": {
    "title": "puna.one agent booking",
    "version": "1.0.0",
    "description": "Lets an AI agent check open times and request a meeting with puna.one for the person it works for. The person confirms by email before anything is booked. Human-readable guide: https://puna.one/agents/"
  },
  "servers": [
    {
      "url": "https://puna.one/functions/v1"
    }
  ],
  "paths": {
    "/check-consultation-availability": {
      "post": {
        "operationId": "checkAvailability",
        "summary": "Check whether a time is open",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "preferred_date",
                  "preferred_time"
                ],
                "properties": {
                  "preferred_date": {
                    "$ref": "#/components/schemas/Date"
                  },
                  "preferred_time": {
                    "$ref": "#/components/schemas/Time"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Whether the time is open",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "available": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The date or time can never be booked: past, weekend, more than 90 days out, or outside 07:00 to 17:00 Hawaii time",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "available": {
                      "type": "boolean",
                      "const": false
                    },
                    "error": {
                      "type": "string"
                    },
                    "problems": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "Too many requests"
          }
        },
        "security": [],
        "description": " Configured source contract: verify_jwt=false and the handler does not require caller Authorization/apikey. The browser may send both. Deployed gateway/proxy policy and anonymous production access have not been measured; runtime_verified:false."
      }
    },
    "/agent-booking": {
      "post": {
        "operationId": "requestMeeting",
        "summary": "Request a meeting for a person",
        "description": "Emails the person a confirmation link that works for 24 hours. The request reaches the puna.one team only after they confirm. Each email address can receive three confirmation emails a day. Configured source contract: verify_jwt=false and the handler does not require caller Authorization/apikey. The browser may send both. Deployed gateway/proxy policy and anonymous production access have not been measured; runtime_verified:false.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "full_name",
                  "email",
                  "service_type",
                  "preferred_date",
                  "preferred_time"
                ],
                "properties": {
                  "full_name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 200,
                    "description": "Name of the agent's owner or human contact, the person the meeting is for."
                  },
                  "email": {
                    "type": "string",
                    "format": "email",
                    "maxLength": 320,
                    "description": "That person's email address. The confirmation link is sent here."
                  },
                  "service_type": {
                    "type": "string",
                    "enum": [
                      "free_ai_consultation",
                      "ai_strategy_session",
                      "managed_services_intro"
                    ]
                  },
                  "preferred_date": {
                    "$ref": "#/components/schemas/Date"
                  },
                  "preferred_time": {
                    "$ref": "#/components/schemas/Time"
                  },
                  "location": {
                    "type": "string",
                    "enum": [
                      "zoom",
                      "google_meet"
                    ],
                    "default": "zoom"
                  },
                  "phone": {
                    "type": "string",
                    "maxLength": 50
                  },
                  "company": {
                    "type": "string",
                    "maxLength": 200
                  },
                  "notes": {
                    "type": "string",
                    "maxLength": 2000
                  },
                  "agent_name": {
                    "type": "string",
                    "maxLength": 200
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Confirmation email sent",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "const": "awaiting_confirmation"
                    },
                    "message": {
                      "type": "string"
                    },
                    "expires_at": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "A field is missing or out of range",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "problems": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "That time is taken"
          },
          "429": {
            "description": "Too many requests"
          },
          "503": {
            "description": "Booking by agent is paused"
          }
        },
        "security": []
      }
    },
    "/agent-booking/confirm": {
      "post": {
        "operationId": "confirmMeetingRequest",
        "summary": "Confirm a meeting request with the emailed token",
        "description": "POST only. The token is the one in the email fragment and is not a query parameter. A second POST with the same token, or a token past its expiry, returns 410. Cancel and reschedule are separate POST operations. runtime_verified:false.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["token"],
                "properties": {
                  "token": {
                    "type": "string",
                    "minLength": 43,
                    "maxLength": 43,
                    "pattern": "^[A-Za-z0-9_-]{43}$",
                    "description": "The confirmation token from the email fragment. Do not put it in a URL query or a log."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The person confirmed. The request is in the team queue. This is not a calendar event on puna.one's calendar.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": { "type": "string", "const": "confirmed" },
                    "date": { "$ref": "#/components/schemas/Date" },
                    "time": { "$ref": "#/components/schemas/Time" },
                    "service_type": { "type": "string" },
                    "location": { "type": "string", "enum": ["zoom", "google_meet"] }
                  }
                }
              }
            }
          },
          "400": { "description": "The token is missing or the wrong length" },
          "410": { "description": "The token is expired or was already used" },
          "429": { "description": "Too many requests" },
          "500": { "description": "The request could not be saved" }
        },
        "security": []
      }
    },
    "/agent-booking/cancel": {
      "post": {
        "operationId": "cancelMeetingRequest",
        "summary": "Cancel a meeting request with the emailed token",
        "description": "POST only. confirm must be true. GET does not cancel. The body cannot name a Google event. A rejected row returns 409 status_locked. A pending or approved row with no calendar_event_id is set to cancelled in the database only. If calendar_event_id is stored, the event is deleted first. Delete is idempotent when the event is already gone, and it does not create a replacement. A 403 or timeout returns calendar_forbidden or calendar_timeout and does not change the row. If the delete succeeds and the row update does not, the response is 500 calendar_deleted_row_unchanged with retryable true. The same POST again finishes the row update and does not create an event. Repeating a finished cancel returns 200 and does not write again. No email is sent. The response has no email address and no token. runtime_verified:false.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["token", "confirm"],
                "properties": {
                  "token": { "type": "string", "minLength": 43, "maxLength": 43 },
                  "confirm": { "type": "boolean", "const": true }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Cancelled, including a repeat of an already cancelled request" },
          "400": { "description": "confirm is not true, or the token is the wrong length" },
          "409": { "description": "The queue row is rejected" },
          "410": { "description": "No matching token" },
          "429": { "description": "Too many requests" },
          "403": { "description": "Google refused the event delete, so the queue row was left unchanged" },
          "500": { "description": "The Google event was deleted and the queue row was not updated. The same POST can finish the row." },
          "502": { "description": "The Google event was not deleted, so the queue row was left unchanged" },
          "504": { "description": "The calendar request timed out, so the queue row was left unchanged" }
        },
        "security": []
      }
    },
    "/agent-booking/reschedule": {
      "post": {
        "operationId": "rescheduleMeetingRequest",
        "summary": "Move a meeting request to another open slot",
        "description": "POST only. confirm must be true. GET does not move the time. Uses the same date and time rules as the availability check. A non-OK, network, timeout, or malformed availability response fails closed and changes nothing. available must be boolean true. The body cannot name a Google event. A rejected or cancelled row returns 409 status_locked. The same slot returns 200 without a write. A taken slot returns 409 slot_unavailable before any calendar call. A pending or approved row with no calendar_event_id is updated in the database only. If calendar_event_id is stored, that same event is moved first. A matching operation id is not patched again. Update does not create a second event. A 403 or timeout does not change the row. If the move succeeds and the row update does not, the handler tries to move the event back. Restored state returns 500 calendar_restored_row_unchanged and is not success. If the event cannot be moved back, the response is 500 calendar_updated_row_unchanged with retryable true. The same POST again finishes the row and does not apply a second time change. A lost race returns 409 conflict after the event is put back to the stored time. The moved event keeps its existing location text and uses a 60 minute length, the same default as approval. No email is sent. The response has no email address and no token. runtime_verified:false.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["token", "confirm", "preferred_date", "preferred_time"],
                "properties": {
                  "token": { "type": "string", "minLength": 43, "maxLength": 43 },
                  "confirm": { "type": "boolean", "const": true },
                  "preferred_date": { "$ref": "#/components/schemas/Date" },
                  "preferred_time": { "$ref": "#/components/schemas/Time" }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "The stored time was updated, or it was already that time" },
          "400": { "description": "confirm is not true, or the slot breaks the booking rules" },
          "409": { "description": "The slot is taken, or the queue row is rejected or cancelled" },
          "410": { "description": "The token is missing, expired, or unknown" },
          "429": { "description": "Too many requests" },
          "403": { "description": "Google refused the event move, so the queue row was left unchanged" },
          "500": { "description": "The calendar and the row were not both updated. This is not success. A retryable body can be repeated." },
          "502": { "description": "The Google event was not moved, so the queue row was left unchanged" },
          "503": { "description": "Availability could not be confirmed, so nothing was changed" },
          "504": { "description": "The calendar request timed out, so the queue row was left unchanged" }
        },
        "security": []
      }
    }
  },
  "components": {
    "schemas": {
      "Date": {
        "type": "string",
        "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
        "description": "A weekday from today to 90 days out, YYYY-MM-DD."
      },
      "Time": {
        "type": "string",
        "pattern": "^\\d{2}:(00|30)$",
        "description": "Hawaii time (UTC-10), 24-hour HH:MM from 07:00 to 17:00 on the hour or half hour."
      }
    }
  }
}
