{
  "openapi": "3.1.0",
  "x-sole-status": "preview",
  "info": {
    "title": "Sole — AI assistant for independent beauty professionals",
    "version": "0.1.0-preview",
    "description": "Sole is an AI assistant for independent beauty professionals — hairstylists, barbers, nail techs, and estheticians who work without a front desk.\n\nThis connector is **stylist-side**: a beauty professional connects her own Sole account to her own Muse so she can ask about her business and hand off client follow-up by voice or text, including hands-free through smart glasses while she works.\n\nThis API is a **PREVIEW** in active development. All endpoints currently return 503 while the integration layer is completed.\n\n**Client texts always require the stylist’s explicit confirmation.** Every action that would text a client returns a preview first. Texts are sent only when the stylist confirms.",
    "termsOfService": "https://sbscoaches.com/terms-of-service",
    "contact": {
      "email": "support@sbscoaches.com"
    },
    "x-sole-status": "preview"
  },
  "servers": [
    {
      "url": "https://api.sbscoaches.com/muse/v1",
      "description": "Production"
    }
  ],
  "components": {
    "securitySchemes": {
      "OAuthPKCE": {
        "type": "oauth2",
        "description": "OAuth 2.0 authorization code flow with PKCE (RFC 7636). PKCE is required for all clients — include code_challenge and code_challenge_method=S256 in the authorization request.",
        "flows": {
          "authorizationCode": {
            "authorizationUrl": "https://api.sbscoaches.com/muse/oauth/authorize",
            "tokenUrl": "https://api.sbscoaches.com/muse/oauth/token",
            "refreshUrl": "https://api.sbscoaches.com/muse/oauth/token",
            "scopes": {
              "sole.read": "Read the signed-in stylist’s own business summary, lapsed clients, and goal status.",
              "sole.client_visit": "Start a client visit, which sends texts to a client ONLY after the stylist explicitly confirms the preview."
            }
          }
        }
      }
    },
    "headers": {
      "X-RateLimit-Limit": {
        "description": "Maximum requests allowed per rate-limit window.",
        "schema": {
          "type": "integer",
          "example": 60
        }
      },
      "X-RateLimit-Remaining": {
        "description": "Requests remaining in the current rate-limit window.",
        "schema": {
          "type": "integer",
          "example": 58
        }
      },
      "X-RateLimit-Reset": {
        "description": "Unix timestamp when the current rate-limit window resets.",
        "schema": {
          "type": "integer",
          "example": 1762300800
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": ["error"],
        "description": "Standard error envelope. Every error includes a next_step field explaining what to do.",
        "properties": {
          "error": {
            "type": "object",
            "required": ["code", "message", "next_step"],
            "properties": {
              "code": {
                "type": "string",
                "description": "Machine-readable error code.",
                "enum": [
                  "unauthorized",
                  "insufficient_scope",
                  "not_found",
                  "ambiguous_client",
                  "client_opted_out",
                  "rate_limited",
                  "service_unavailable"
                ]
              },
              "message": {
                "type": "string",
                "description": "Human-readable description of the error."
              },
              "next_step": {
                "type": "string",
                "description": "What the agent or stylist should do next."
              }
            }
          }
        },
        "example": {
          "error": {
            "code": "not_found",
            "message": "No client named Jordan found.",
            "next_step": "Ask the stylist to confirm the client’s name and try again."
          }
        }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Access token is missing or has expired.",
        "content": {
          "application/json": {
            "schema": {"$ref": "#/components/schemas/Error"},
            "example": {
              "error": {
                "code": "unauthorized",
                "message": "Access token is missing or has expired.",
                "next_step": "Re-authenticate via OAuth to obtain a new token."
              }
            }
          }
        }
      },
      "InsufficientScope": {
        "description": "Token does not include the required scope.",
        "content": {
          "application/json": {
            "schema": {"$ref": "#/components/schemas/Error"},
            "example": {
              "error": {
                "code": "insufficient_scope",
                "message": "This action requires the sole.client_visit scope.",
                "next_step": "Re-authenticate and request the sole.client_visit scope."
              }
            }
          }
        }
      },
      "RateLimited": {
        "description": "Too many requests. Wait for Retry-After seconds before retrying.",
        "headers": {
          "Retry-After": {
            "description": "Seconds to wait before retrying.",
            "schema": {"type": "integer", "example": 30}
          },
          "X-RateLimit-Limit": {"$ref": "#/components/headers/X-RateLimit-Limit"},
          "X-RateLimit-Remaining": {"$ref": "#/components/headers/X-RateLimit-Remaining"},
          "X-RateLimit-Reset": {"$ref": "#/components/headers/X-RateLimit-Reset"}
        },
        "content": {
          "application/json": {
            "schema": {"$ref": "#/components/schemas/Error"},
            "example": {
              "error": {
                "code": "rate_limited",
                "message": "Rate limit exceeded.",
                "next_step": "Wait for the number of seconds in the Retry-After header before trying again."
              }
            }
          }
        }
      },
      "ServiceUnavailable": {
        "description": "API is in development and not yet available.",
        "headers": {
          "Retry-After": {
            "description": "Seconds to wait before retrying.",
            "schema": {"type": "integer", "example": 86400}
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "status": {"type": "string"},
                "message": {"type": "string"},
                "docs": {"type": "string"},
                "support": {"type": "string"}
              }
            },
            "example": {
              "status": "in_development",
              "message": "The Sole connector for Meta Muse is in development and will be available soon.",
              "docs": "https://sbscoaches.com/muse",
              "support": "support@sbscoaches.com"
            }
          }
        }
      }
    }
  },
  "paths": {
    "/business-summary": {
      "get": {
        "operationId": "get_business_summary",
        "summary": "Get the stylist’s business summary",
        "description": "Use this when the stylist asks about her recent business performance.\n\nExample phrases: “How’s my week going?”, “How did I do today?”, “What was my revenue this month?”\n\nThis covers only the signed-in stylist’s own data. It never returns data for other stylists.",
        "security": [{"OAuthPKCE": ["sole.read"]}],
        "parameters": [
          {
            "name": "period",
            "in": "query",
            "description": "Time period to summarize. Defaults to the current week.",
            "required": false,
            "schema": {
              "type": "string",
              "enum": ["today", "week", "month"],
              "default": "week"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Business summary for the requested period.",
            "headers": {
              "X-RateLimit-Limit": {"$ref": "#/components/headers/X-RateLimit-Limit"},
              "X-RateLimit-Remaining": {"$ref": "#/components/headers/X-RateLimit-Remaining"},
              "X-RateLimit-Reset": {"$ref": "#/components/headers/X-RateLimit-Reset"}
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["period", "data_source"],
                  "properties": {
                    "period": {
                      "type": "string",
                      "enum": ["today", "week", "month"]
                    },
                    "revenue": {
                      "type": ["number", "null"],
                      "description": "Total revenue in USD for the period, or null if unavailable."
                    },
                    "clients_seen": {
                      "type": ["integer", "null"],
                      "description": "Number of clients seen in the period."
                    },
                    "average_ticket": {
                      "type": ["number", "null"],
                      "description": "Average revenue per client in USD."
                    },
                    "retail_sales": {
                      "type": ["number", "null"],
                      "description": "Total retail product sales in USD."
                    },
                    "vs_prior_period": {
                      "type": ["object", "null"],
                      "description": "Comparison to the equivalent prior period.",
                      "properties": {
                        "revenue_change_pct": {
                          "type": ["number", "null"],
                          "description": "Revenue change as a percentage. Positive = improvement."
                        },
                        "clients_seen_change": {
                          "type": ["integer", "null"],
                          "description": "Change in number of clients seen."
                        }
                      }
                    },
                    "data_source": {
                      "type": "string",
                      "enum": ["square", "self_reported", "none"],
                      "description": "'square' = live Square data. 'self_reported' = numbers the stylist entered manually. 'none' = no data source connected; tell the stylist she can connect Square or enter numbers by texting Sole."
                    }
                  }
                },
                "example": {
                  "period": "week",
                  "revenue": 1240.00,
                  "clients_seen": 8,
                  "average_ticket": 155.00,
                  "retail_sales": 87.50,
                  "vs_prior_period": {
                    "revenue_change_pct": 12.5,
                    "clients_seen_change": 1
                  },
                  "data_source": "square"
                }
              }
            }
          },
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/InsufficientScope"},
          "429": {"$ref": "#/components/responses/RateLimited"},
          "503": {"$ref": "#/components/responses/ServiceUnavailable"}
        }
      }
    },
    "/lapsed-clients": {
      "get": {
        "operationId": "get_lapsed_clients",
        "summary": "Get the stylist’s most overdue clients",
        "description": "Use this when the stylist asks who hasn’t been in lately.\n\nExample phrases: “Who hasn’t been in lately?”, “Who should I be reaching out to?”\n\nReturns the most overdue clients only, up to the requested limit. Maximum 10. No phone numbers, no last names, no bulk exports.",
        "security": [{"OAuthPKCE": ["sole.read"]}],
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "description": "Maximum number of clients to return. Default 5, maximum 10.",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 5,
              "minimum": 1,
              "maximum": 10
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lapsed client list, ordered by most overdue first.",
            "headers": {
              "X-RateLimit-Limit": {"$ref": "#/components/headers/X-RateLimit-Limit"},
              "X-RateLimit-Remaining": {"$ref": "#/components/headers/X-RateLimit-Remaining"},
              "X-RateLimit-Reset": {"$ref": "#/components/headers/X-RateLimit-Reset"}
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["total_lapsed", "clients"],
                  "properties": {
                    "total_lapsed": {
                      "type": "integer",
                      "description": "Total number of lapsed clients. May be larger than the number returned."
                    },
                    "estimated_revenue_at_risk": {
                      "type": ["number", "null"],
                      "description": "Estimated lost revenue if lapsed clients don’t return, in USD."
                    },
                    "clients": {
                      "type": "array",
                      "maxItems": 10,
                      "items": {
                        "type": "object",
                        "required": ["first_name", "weeks_since_visit"],
                        "properties": {
                          "first_name": {
                            "type": "string",
                            "description": "Client’s first name only."
                          },
                          "weeks_since_visit": {
                            "type": "integer",
                            "description": "Weeks elapsed since the client’s last visit."
                          },
                          "usual_interval_weeks": {
                            "type": ["integer", "null"],
                            "description": "How often this client usually comes in, in weeks. Null if unknown."
                          }
                        }
                      }
                    }
                  }
                },
                "example": {
                  "total_lapsed": 14,
                  "estimated_revenue_at_risk": 1820.00,
                  "clients": [
                    {"first_name": "Maya", "weeks_since_visit": 14, "usual_interval_weeks": 6},
                    {"first_name": "Jordan", "weeks_since_visit": 11, "usual_interval_weeks": 5},
                    {"first_name": "Riley", "weeks_since_visit": 10, "usual_interval_weeks": 6},
                    {"first_name": "Sam", "weeks_since_visit": 9, "usual_interval_weeks": 8},
                    {"first_name": "Casey", "weeks_since_visit": 8, "usual_interval_weeks": 6}
                  ]
                }
              }
            }
          },
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/InsufficientScope"},
          "429": {"$ref": "#/components/responses/RateLimited"},
          "503": {"$ref": "#/components/responses/ServiceUnavailable"}
        }
      }
    },
    "/goal-status": {
      "get": {
        "operationId": "get_goal_status",
        "summary": "Get the stylist’s monthly goal pace",
        "description": "Use this when the stylist asks about her goal for the month.\n\nExample phrases: “Am I on pace for my goal?”, “How am I doing toward my goal?”\n\nIf no goal is set, say so plainly and tell the stylist she can set one by texting Sole.",
        "security": [{"OAuthPKCE": ["sole.read"]}],
        "responses": {
          "200": {
            "description": "Goal status for the current calendar month.",
            "headers": {
              "X-RateLimit-Limit": {"$ref": "#/components/headers/X-RateLimit-Limit"},
              "X-RateLimit-Remaining": {"$ref": "#/components/headers/X-RateLimit-Remaining"},
              "X-RateLimit-Reset": {"$ref": "#/components/headers/X-RateLimit-Reset"}
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["goal_set", "pace"],
                  "properties": {
                    "goal_set": {
                      "type": "boolean",
                      "description": "Whether the stylist has set a monthly revenue goal."
                    },
                    "goal_amount": {
                      "type": ["number", "null"],
                      "description": "Monthly revenue goal in USD, or null if not set."
                    },
                    "progress": {
                      "type": ["number", "null"],
                      "description": "Revenue earned so far this month in USD."
                    },
                    "days_remaining": {
                      "type": "integer",
                      "description": "Calendar days remaining in the current month."
                    },
                    "pace": {
                      "type": "string",
                      "enum": ["ahead", "on_track", "behind"],
                      "description": "Whether the stylist is ahead of, on track for, or behind her goal pace."
                    },
                    "needed_per_working_day": {
                      "type": ["number", "null"],
                      "description": "Revenue needed per remaining working day to reach the goal. Null if no goal is set."
                    },
                    "no_goal_hint": {
                      "type": "string",
                      "description": "Present only when goal_set is false. Plain-language instruction for setting a goal by text."
                    }
                  }
                },
                "examples": {
                  "on_track": {
                    "summary": "Goal set, stylist is on track",
                    "value": {
                      "goal_set": true,
                      "goal_amount": 5000.00,
                      "progress": 2980.00,
                      "days_remaining": 9,
                      "pace": "on_track",
                      "needed_per_working_day": 252.00
                    }
                  },
                  "no_goal": {
                    "summary": "No goal set",
                    "value": {
                      "goal_set": false,
                      "goal_amount": null,
                      "progress": 2980.00,
                      "days_remaining": 9,
                      "pace": "on_track",
                      "needed_per_working_day": null,
                      "no_goal_hint": "You haven’t set a monthly goal yet. Text Sole ‘my goal is $5,000’ to set one."
                    }
                  }
                }
              }
            }
          },
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/InsufficientScope"},
          "429": {"$ref": "#/components/responses/RateLimited"},
          "503": {"$ref": "#/components/responses/ServiceUnavailable"}
        }
      }
    },
    "/client-visits": {
      "post": {
        "operationId": "start_client_visit",
        "summary": "Start a client follow-up visit",
        "description": "Use this when the stylist says she’s done with a client and wants Sole to send the follow-up texts — product recommendation and next-visit booking.\n\nExample phrase: “Check out Maya, six weeks, recommend Olaplex No. 3.”\n\n**Always call with confirm=false first (the default).** Show the stylist the preview — the exact texts that will be sent and the suggested next-visit date — and get her explicit approval before calling again with confirm=true. Never confirm without showing the preview.\n\nIf multiple clients match the name given, Sole returns a 409 listing candidates by first name, last initial, and last visit date. Ask the stylist which one she means — never guess.",
        "security": [{"OAuthPKCE": ["sole.client_visit"]}],
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "description": "Required when confirm=true. A UUID that prevents duplicate visits if the request is retried. The same key always returns the same result.",
            "required": false,
            "schema": {
              "type": "string",
              "format": "uuid",
              "example": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["client_name"],
                "properties": {
                  "client_name": {
                    "type": "string",
                    "description": "Client’s first name, or first and last. Sole searches among the signed-in stylist’s clients."
                  },
                  "interval_weeks": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 26,
                    "description": "Weeks until the suggested next appointment. If omitted, Sole uses the client’s usual interval."
                  },
                  "recommended_product": {
                    "type": "string",
                    "description": "Product to recommend by name. If omitted, the product recommendation step is skipped."
                  },
                  "confirm": {
                    "type": "boolean",
                    "default": false,
                    "description": "false (default): return a preview only — nothing is sent to the client. true: start the visit. Requires Idempotency-Key and confirmation_token."
                  },
                  "confirmation_token": {
                    "type": "string",
                    "description": "The token returned in the preview response. Required when confirm=true."
                  }
                }
              },
              "examples": {
                "preview": {
                  "summary": "Preview request (confirm=false, default — call this first)",
                  "value": {
                    "client_name": "Maya",
                    "interval_weeks": 6,
                    "recommended_product": "Olaplex No. 3",
                    "confirm": false
                  }
                },
                "confirm": {
                  "summary": "Confirm request (confirm=true — only after stylist approves the preview)",
                  "value": {
                    "client_name": "Maya",
                    "interval_weeks": 6,
                    "recommended_product": "Olaplex No. 3",
                    "confirm": true,
                    "confirmation_token": "tok_prev_abc123xyz"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Preview (confirm=false) or confirmed visit (confirm=true).",
            "headers": {
              "X-RateLimit-Limit": {"$ref": "#/components/headers/X-RateLimit-Limit"},
              "X-RateLimit-Remaining": {"$ref": "#/components/headers/X-RateLimit-Remaining"},
              "X-RateLimit-Reset": {"$ref": "#/components/headers/X-RateLimit-Reset"}
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["confirmed", "client"],
                  "properties": {
                    "confirmed": {
                      "type": "boolean",
                      "description": "false = preview returned, nothing sent. true = visit started."
                    },
                    "client": {
                      "type": "object",
                      "required": ["first_name", "phone_masked"],
                      "properties": {
                        "first_name": {
                          "type": "string"
                        },
                        "phone_masked": {
                          "type": "string",
                          "description": "Masked phone number. Format: ‘(813) •••-0525’. The full number is never returned."
                        }
                      }
                    },
                    "preview_texts": {
                      "type": "array",
                      "description": "Present in preview responses only. The exact messages that will be sent when the stylist confirms.",
                      "items": {
                        "type": "object",
                        "required": ["to", "body"],
                        "properties": {
                          "to": {
                            "type": "string",
                            "description": "Masked destination phone number."
                          },
                          "body": {
                            "type": "string",
                            "description": "Exact text message body."
                          }
                        }
                      }
                    },
                    "suggested_next_visit": {
                      "type": "string",
                      "format": "date",
                      "description": "Suggested next appointment date (YYYY-MM-DD). Present in preview responses."
                    },
                    "confirmation_token": {
                      "type": "string",
                      "description": "Token required to call again with confirm=true. Present in preview responses only. Expires after 10 minutes."
                    },
                    "status": {
                      "type": "string",
                      "enum": ["started", "queued"],
                      "description": "Present when confirmed=true. Current status of the visit after starting."
                    }
                  }
                },
                "examples": {
                  "preview": {
                    "summary": "Preview response — show this to the stylist and wait for confirmation",
                    "value": {
                      "confirmed": false,
                      "client": {
                        "first_name": "Maya",
                        "phone_masked": "(813) •••-0525"
                      },
                      "preview_texts": [
                        {
                          "to": "(813) •••-0525",
                          "body": "Hi Maya! Alex recommends Olaplex No. 3 ($28 at a salon). No worries either way — just wanted you to have it. Reply YES if you’d like it or NO to skip."
                        },
                        {
                          "to": "(813) •••-0525",
                          "body": "Your next appointment with Alex would be around Tue, Nov 3 at 2:00 PM — six weeks from today. Reply Y to book it or N for another time."
                        }
                      ],
                      "suggested_next_visit": "2026-11-03",
                      "confirmation_token": "tok_prev_abc123xyz"
                    }
                  },
                  "confirmed": {
                    "summary": "Confirmed response — visit started",
                    "value": {
                      "confirmed": true,
                      "client": {
                        "first_name": "Maya",
                        "phone_masked": "(813) •••-0525"
                      },
                      "status": "started"
                    }
                  }
                }
              }
            }
          },
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "403": {"$ref": "#/components/responses/InsufficientScope"},
          "409": {
            "description": "Multiple clients match the name. Ask the stylist which one she means. Never guess.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["error"],
                  "properties": {
                    "error": {
                      "type": "object",
                      "required": ["code", "message", "next_step", "candidates"],
                      "properties": {
                        "code": {"type": "string"},
                        "message": {"type": "string"},
                        "next_step": {"type": "string"},
                        "candidates": {
                          "type": "array",
                          "description": "Clients matching the name, listed with enough detail for the stylist to distinguish them.",
                          "items": {
                            "type": "object",
                            "properties": {
                              "first_name": {"type": "string"},
                              "last_initial": {"type": "string"},
                              "last_visit": {"type": "string", "format": "date"}
                            }
                          }
                        }
                      }
                    }
                  }
                },
                "example": {
                  "error": {
                    "code": "ambiguous_client",
                    "message": "Two clients named Maya match. Please confirm which one.",
                    "next_step": "Ask the stylist which Maya she means and try again with the full name or last visit date.",
                    "candidates": [
                      {"first_name": "Maya", "last_initial": "R.", "last_visit": "2026-07-14"},
                      {"first_name": "Maya", "last_initial": "T.", "last_visit": "2026-06-02"}
                    ]
                  }
                }
              }
            }
          },
          "422": {
            "description": "Client has opted out of text messages. Do not send.",
            "content": {
              "application/json": {
                "schema": {"$ref": "#/components/schemas/Error"},
                "example": {
                  "error": {
                    "code": "client_opted_out",
                    "message": "Maya has opted out of text messages from Sole.",
                    "next_step": "Do not attempt to text this client. Let the stylist know Maya has opted out."
                  }
                }
              }
            }
          },
          "429": {"$ref": "#/components/responses/RateLimited"},
          "503": {"$ref": "#/components/responses/ServiceUnavailable"}
        }
      }
    }
  }
}
