{
  "openapi": "3.1.0",
  "info": {
    "title": "SmartPlate Agent API",
    "version": "1.12.0",
    "description": "Agent-facing REST API for SmartPlate (410eat) — a durable record of a household's food life that an agent reads and writes on the household's behalf. Hosted MCP at `POST /mcp` is the start (streamable HTTP, OAuth — whoami + remember_recipe_from_url / save-from-url, no PAT from the app). REST remains for the rest of the loop and still accepts a PAT via `Authorization: Bearer sp_...`. `/sse` is not a transport here and 404s.\n\n**Read this spec first — don't reverse-engineer.** Every agent-facing capability is listed here; if a workflow feels like it needs an endpoint that isn't here, it's the agent's job to compose the primitives, not a missing feature.\n\n**Model to hold in your head:**\n- **Writes are synchronous.** There are no async jobs and nothing to poll — the HTTP response *is* the final result. A 2xx means it happened.\n- **Prefer webhooks over polling.** Register a webhook (`POST /api/agent/webhooks`) and receive HMAC-signed events instead of re-reading state. `GET /api/agent/events` is the polling fallback (cursor-based). See the `WebhookEvent` schema for the event catalog.\n- **Retries are safe.** Recipe ingest is deduplicated by canonical URL + content hash (a re-import returns the *existing* recipe, it does not duplicate), meal-plan slots upsert by (date, meal_type), and cookbook/plan adds are idempotent. The main retry-sensitive writes (recipe create/import, plan add/batch, shop build, shop manual-add, online-match) also accept an `Idempotency-Key` header for an explicit replay guarantee — each such operation declares the `IdempotencyKey` parameter, so check the operation's `parameters` rather than assuming it everywhere.\n- **The shopping list is built from the plan on demand.** Slot writes leave the existing list and its checks. `GET /api/shop/list/me` includes `plan_changed_since_list` when the meal plan has changed since this list was built; `POST /api/shop/build` is the explicit refresh. Add ad-hoc items with `manual_add`.\n- **The server runs no generative LLM.** Discovery is deterministic retrieval; recipe capture from a URL with no structured data hands the page text back for *your* agent to parse and resubmit (`/api/recipe/structured`).\n\n**Every response is enveloped:** success is `{\"success\": true, \"data\": {...}}`, failure is `{\"success\": false, \"error\": {\"code\": \"...\", \"message\": \"...\", \"next\": {\"docs\": \"...\", \"fix\": \"...\"}}}`. Branch on the machine-readable `error.code` (see the `ErrorEnvelope` schema for the catalog), never on the message text. `error.next` is always present."
  },
  "servers": [
    {
      "url": "https://api.smartplate.app",
      "description": "Production"
    },
    {
      "url": "http://localhost:5001",
      "description": "Local development"
    }
  ],
  "security": [
    {
      "patAuth": []
    },
    {
      "jwtAuth": []
    }
  ],
  "components": {
    "securitySchemes": {
      "patAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Personal Access Token (PAT). Prefix: `sp_`"
      },
      "jwtAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Auth-Token",
        "description": "JWT access token (36-hour expiry)"
      },
      "mcpOAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Hosted MCP OAuth access token (JWT with mcp=true). Not a PAT."
      }
    },
    "schemas": {
      "SuccessEnvelope": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean",
            "const": true
          },
          "data": {
            "type": "object"
          },
          "message": {
            "type": "string"
          }
        },
        "required": [
          "success",
          "data"
        ]
      },
      "ErrorEnvelope": {
        "type": "object",
        "description": "Every failure returns this shape. Branch on `error.code` (stable, machine-readable), not on `error.message` (human-readable, may change). `error.next` is always present: `docs` is the agent guide, `fix` is the imperative next step. `error.details` carries structured context for some codes — e.g. `SLOT_TAKEN` includes `details.conflict` describing the occupying meal.",
        "properties": {
          "success": {
            "type": "boolean",
            "const": false
          },
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "string",
                "description": "Stable error code. `VALIDATION_ERROR`/`BATCH_VALIDATION_ERROR`: bad request body. `UNAUTHORIZED`: missing token — sign in with OAuth (hosted MCP); do not mint a PAT. `INVALID_TOKEN`: token or household member link is invalid or expired — sign in with OAuth (member links: ask the owner for a new invite). `AUTHORIZATION_ERROR`/`FORBIDDEN`: authenticated but not allowed. `INSUFFICIENT_SCOPE`: the PAT lacks the required scope — mint a token with it. `NOT_FOUND`: no such resource (or not this household's). `SLOT_TAKEN`: target meal slot already occupied (`details.conflict` has the occupant) — the meal-slot-occupied case. `CONFLICT`: generic state conflict. `RATE_LIMIT_EXCEEDED`: back off and retry (see `X-RateLimit-*` headers). `INVALID_URL`/`FETCH_FAILED`/`FETCH_TIMEOUT`: URL capture couldn't reach/read the page. `NO_STRUCTURED_DATA`: the page had no schema.org recipe — parse the returned text yourself and POST `/api/recipe/structured`. `RECIPE_CREATION_FAILED`: ingest rejected the recipe. `COMPOSE_CEILING_REACHED`: weekly compose budget hit. `BILLING_ERROR`/`BILLING_NOT_CONFIGURED`: subscription required or billing unset. `DATABASE_ERROR`/`UNEXPECTED_ERROR`: server-side — safe to retry.",
                "enum": [
                  "VALIDATION_ERROR",
                  "BATCH_VALIDATION_ERROR",
                  "UNAUTHORIZED",
                  "INVALID_TOKEN",
                  "AUTHORIZATION_ERROR",
                  "FORBIDDEN",
                  "INSUFFICIENT_SCOPE",
                  "NOT_FOUND",
                  "SLOT_TAKEN",
                  "CONFLICT",
                  "RATE_LIMIT_EXCEEDED",
                  "INVALID_URL",
                  "FETCH_FAILED",
                  "FETCH_TIMEOUT",
                  "NO_STRUCTURED_DATA",
                  "RECIPE_CREATION_FAILED",
                  "COMPOSE_CEILING_REACHED",
                  "PHOTO_CAP_REACHED",
                  "NOT_PHOTO_OWNER",
                  "BILLING_ERROR",
                  "BILLING_NOT_CONFIGURED",
                  "DATABASE_ERROR",
                  "UNEXPECTED_ERROR"
                ]
              },
              "message": {
                "type": "string"
              },
              "details": {
                "type": [
                  "object",
                  "array",
                  "null"
                ],
                "description": "Structured context. An **object** for most codes (e.g. `SLOT_TAKEN` → `details.conflict`); an **array** of `{field, message, type}` for `VALIDATION_ERROR`/`BATCH_VALIDATION_ERROR` (one per failing field; always present for those codes). Absent for codes that carry none."
              },
              "next": {
                "type": "object",
                "description": "Machine-readable next step. Always present. Follow `docs`; act on `fix`. One shape for every code — do not invent a second envelope.",
                "properties": {
                  "docs": {
                    "type": "string",
                    "format": "uri",
                    "description": "Absolute URL of the hosted agent guide (GET /api/agent-guide)."
                  },
                  "fix": {
                    "type": "string",
                    "description": "Imperative next action for this error.code."
                  }
                },
                "required": [
                  "docs",
                  "fix"
                ]
              }
            },
            "required": [
              "code",
              "message",
              "next"
            ]
          },
          "request_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Correlation id for this request — include it when reporting an issue so the server-side logs can be traced."
          }
        },
        "required": [
          "success",
          "error"
        ],
        "example": {
          "success": false,
          "error": {
            "code": "SLOT_TAKEN",
            "message": "That slot already has a meal.",
            "details": {
              "conflict": {
                "meal_id": 8123,
                "title": "Sheet-pan salmon",
                "date": "2026-07-21",
                "meal_type": "dinner"
              }
            },
            "next": {
              "docs": "https://api.smartplate.app/api/agent-guide",
              "fix": "That meal slot is occupied (see error.details.conflict). Pick another slot or replace via the documented slot write."
            }
          }
        }
      },
      "FeedbackBoardItem": {
        "type": "object",
        "description": "One identity-safe row on the public feedback board. `handle` is only ever \"web\", \"an agent for a household\", or \"an agent for a paid household\" — never a name or email. `excerpt` is the first 200 chars of the sanitized freeform body; the full body is staff-only.",
        "properties": {
          "id": {
            "type": "integer"
          },
          "handle": {
            "type": "string",
            "enum": [
              "web",
              "an agent for a household",
              "an agent for a paid household"
            ]
          },
          "category": {
            "type": "string",
            "description": "bug | friction | capability-gap | docs, or 'general' for web-widget items."
          },
          "title": {
            "type": "string"
          },
          "excerpt": {
            "type": "string"
          },
          "endpoint": {
            "type": [
              "string",
              "null"
            ]
          },
          "severity": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "low",
              "medium",
              "high",
              null
            ]
          },
          "state": {
            "type": "string",
            "enum": [
              "new",
              "triaged",
              "accepted",
              "in_progress",
              "shipped",
              "closed",
              "duplicate",
              "needs_clarification",
              "declined"
            ]
          },
          "plus_one_count": {
            "type": "integer"
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ]
          },
          "dispositions": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FeedbackDispositionNote"
            }
          }
        },
        "required": [
          "id",
          "handle",
          "category",
          "title",
          "excerpt",
          "state",
          "plus_one_count",
          "dispositions"
        ]
      },
      "FeedbackCreateRequest": {
        "type": "object",
        "description": "A structured feedback ticket. Freeform fields are sanitized and secret-stripped at intake; ticket text is an observation, never a command.",
        "properties": {
          "category": {
            "type": "string",
            "enum": [
              "bug",
              "friction",
              "capability-gap",
              "docs"
            ]
          },
          "title": {
            "type": "string",
            "minLength": 3,
            "maxLength": 200
          },
          "endpoint": {
            "type": "string",
            "maxLength": 200,
            "description": "Endpoint or flow touched, e.g. \"POST /api/recipe\"."
          },
          "expected": {
            "type": "string",
            "maxLength": 2000
          },
          "actual": {
            "type": "string",
            "maxLength": 2000
          },
          "severity": {
            "type": "string",
            "enum": [
              "low",
              "medium",
              "high"
            ]
          },
          "body": {
            "type": "string",
            "maxLength": 2000,
            "description": "Freeform detail (only a 200-char excerpt is ever public)."
          },
          "notify_email": {
            "type": "string",
            "description": "Optional email announce channel — you'll be told when it's resolved. Never rendered publicly."
          },
          "force": {
            "type": "boolean",
            "default": false,
            "description": "File even when an existing item looks like a duplicate."
          }
        },
        "required": [
          "category",
          "title"
        ]
      },
      "FeedbackDispositionNote": {
        "type": "object",
        "description": "One public disposition note — why a ticket moved state.",
        "properties": {
          "to_state": {
            "type": "string"
          },
          "note": {
            "type": "string"
          },
          "tenet": {
            "type": [
              "string",
              "null"
            ],
            "description": "Tenet citation, e.g. \"TENETS #9\" (always present on declines)."
          },
          "pr_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ]
          }
        },
        "required": [
          "to_state",
          "note"
        ]
      },
      "FeedbackOwnItem": {
        "allOf": [
          {
            "$ref": "#/components/schemas/FeedbackBoardItem"
          }
        ],
        "description": "The filer's own view: the public shape plus the private fields only the filer sees.",
        "properties": {
          "body": {
            "type": [
              "string",
              "null"
            ]
          },
          "expected": {
            "type": [
              "string",
              "null"
            ]
          },
          "actual": {
            "type": [
              "string",
              "null"
            ]
          },
          "notify_email": {
            "type": [
              "string",
              "null"
            ]
          },
          "duplicate_of_id": {
            "type": [
              "integer",
              "null"
            ]
          },
          "api_url": {
            "type": "string"
          }
        }
      },
      "WebhookEvent": {
        "type": "object",
        "description": "The payload SmartPlate POSTs to a registered webhook URL, HMAC-signed (see the `X-SmartPlate-Signature` header). The same shape is returned by `GET /api/agent/events`. Subscribe to a subset by listing `type` values when registering, or to everything by omitting the filter. Tolerate unknown `type` values — new event types are added over time.",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Monotonic event id; use the max as your `since` cursor when polling."
          },
          "type": {
            "type": "string",
            "description": "Recipe: `recipe.created`, `recipe.ingredients`, `recipe.rated`, `recipe.remembered` (added to a cookbook). Meal: `meal.scheduled`, `meal.cooked`, `meal.skipped`, `meal.swapped`, `meal.moved`, `meal.cheered`. Proposal: `proposal.proposed`, `proposal.reproposed`, `proposal.approved`, `proposal.auto_approved`, `proposal.held`, `proposal.unheld`, `proposal.undone`, `proposal.voted`, `proposal.slot_added`, `proposal.slot_moved`, `proposal.slot_removed`. Capture: `source.saved` (a human-captured source landed in the tray), `source.structured` (that source parsed into a recipe). Also `photo.added`, `member.joined`. Tolerate unknown types — new ones are added over time.",
            "enum": [
              "recipe.created",
              "recipe.ingredients",
              "recipe.rated",
              "recipe.remembered",
              "meal.scheduled",
              "meal.cooked",
              "meal.skipped",
              "meal.swapped",
              "meal.moved",
              "meal.cheered",
              "proposal.proposed",
              "proposal.reproposed",
              "proposal.approved",
              "proposal.auto_approved",
              "proposal.held",
              "proposal.unheld",
              "proposal.undone",
              "proposal.voted",
              "proposal.slot_added",
              "proposal.slot_moved",
              "proposal.slot_removed",
              "source.saved",
              "source.structured",
              "photo.added",
              "member.joined",
              "feedback.filed",
              "feedback.triaged",
              "feedback.accepted",
              "feedback.in_progress",
              "feedback.shipped",
              "feedback.closed",
              "feedback.duplicate",
              "feedback.needs_clarification",
              "feedback.declined"
            ]
          },
          "resource_type": {
            "type": "string",
            "description": "The kind of resource the event is about, e.g. `recipe`, `meal_plan`, `week_proposal`."
          },
          "resource_id": {
            "type": [
              "integer",
              "string"
            ]
          },
          "payload": {
            "type": "object",
            "description": "Event-specific detail."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "type"
        ]
      },
      "Recommendation": {
        "type": "object",
        "description": "A Discover recommendation card. The triageable card carries a `reco_id` (pass it to `/plan`, `/save`, or `/dismiss`); browse-only cards on the public feed carry `reco_id: null`. The recipe itself is nested under `recipe` — read the durable recipe id from `recipe.id`.",
        "properties": {
          "reco_id": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Recommendation id to triage with; null on the public browse-only feed."
          },
          "source": {
            "type": "string",
            "description": "e.g. `popular`, `follow-favorites`, `online-match`."
          },
          "tier": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Ranking tier (1 = strongest signal)."
          },
          "provenance": {
            "type": [
              "string",
              "null"
            ],
            "description": "Human-readable why-surfaced, e.g. `Popular on SmartPlate`; for `online-match` cards, the agent's submitted note."
          },
          "provenance_actor": {
            "type": [
              "string",
              "null"
            ],
            "description": "Raw actor name for a PERSON-derived provenance (`follow-favorites` → `\"{name}'s favorite\"`, `follow-plans` → `On {name}'s plan`) — exactly the name the `provenance` sentence was built from, so a client can render avatar + name and truncate the name token cleanly. `null` for non-person provenance (popular, online-match/agent, saved/loved shelves)."
          },
          "recipe": {
            "type": "object",
            "properties": {
              "id": {
                "type": "integer",
                "description": "Durable recipe id — use for plan/cookbook/online-match."
              },
              "slug": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "title": {
                "type": "string"
              },
              "description": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "image_path": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "image_thumb_url": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "creator_username": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "average_rating": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "meal_types": {
                "type": [
                  "array",
                  "null"
                ],
                "items": {
                  "type": "string"
                },
                "description": "breakfast/lunch/dinner tags; null when untagged. Drives the photoless tile's meal glyph."
              },
              "preparation_time": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Prep time in whole minutes; null when unknown."
              },
              "cooking_time": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Cook time in whole minutes; null when unknown. Total it with `preparation_time` and render nothing when both are absent — never a placeholder dash."
              },
              "make_again_yes": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Make-Again system: households whose latest public verdict is 'make it again'. Deduped one vote per household. Null when no household has recorded a verdict."
              },
              "make_again_total": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Make-Again system: households with a recorded public make-again verdict (the denominator for make_again_yes). Null when none."
              }
            }
          }
        }
      },
      "RecipeWriteResult": {
        "type": "object",
        "description": "The `data` returned by recipe writes (`/structured`, `/from-url`, `/manual`). The recipe is canonicalized on ingest, so resubmitting the same recipe returns the EXISTING one (same `id`) — a matching id on retry means 'already existed', not a duplicate.",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Canonical recipe id — durable; use it everywhere else (plan, cookbook, online-match)."
          },
          "slug": {
            "type": [
              "string",
              "null"
            ]
          },
          "title": {
            "type": "string"
          },
          "web_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Human share link `{frontend}/recipes/{creator}/{slug}`; null when slug/creator aren't both known."
          },
          "api_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Dereferenceable API URL `{host}/api/recipe/{id}`."
          },
          "meal_types": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            },
            "description": "breakfast/lunch/dinner tags; null when untagged."
          },
          "created_by": {
            "$ref": "#/components/schemas/CreatedBy"
          },
          "is_synthetic": {
            "type": "boolean",
            "description": "True for agent-invented recipes with no source; verified on first human cook."
          },
          "is_new": {
            "type": "boolean",
            "description": "Returned by `/from-url`: true when this ingest created a new canonical recipe, false when it deduped to an existing one (the same `id` on retry). Absent on `/structured`."
          }
        },
        "required": [
          "id",
          "title"
        ]
      },
      "PhotoIngestResult": {
        "type": "object",
        "description": "POST /api/recipe/from-photo `data`. Photo(s) are always persisted first. `kept` is a keepable recipe (printed title or empty, qty/unit/name ingredients, steps-only). `miss` is an honest miss — no recipe card; the photo stays on `source`. `saved` is a parser failure (never-fail). Not a dinner generator.",
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "kept",
              "miss",
              "saved"
            ]
          },
          "miss": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "not_food",
              "two_recipes",
              "blurry",
              "partial",
              null
            ],
            "description": "Honest miss when status is miss. not_food: not food / not a recipe. two_recipes: two recipes in one frame or across the set. blurry: cannot read. partial: need ingredients AND steps."
          },
          "source": {
            "type": "object",
            "description": "The saved photo source (always present). On a miss the photo stays here.",
            "properties": {
              "id": {
                "type": "integer"
              },
              "kind": {
                "type": "string"
              },
              "payload_ref": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "title_hint": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "channel": {
                "type": "string"
              },
              "status": {
                "type": "string"
              },
              "recipe_id": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "parse_attempts": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "last_parse_error": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "created_at": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "structured_at": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          },
          "recipe": {
            "type": [
              "object",
              "null"
            ],
            "description": "Present on kept. Structured qty/unit/name ingredients and steps-only. Null on miss/saved.",
            "additionalProperties": true
          },
          "already_in_cookbook": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "On kept: true when this household already had the recipe."
          }
        },
        "required": [
          "status",
          "source"
        ]
      },
      "ShopBuildResult": {
        "type": "object",
        "description": "The `data` from POST /api/shop/build — the built list plus the provenance to verify it before telling the household groceries are ready.",
        "properties": {
          "built": {
            "type": "boolean",
            "description": "Whether any meals were in scope to shop for."
          },
          "preview": {
            "type": "boolean"
          },
          "item_count": {
            "type": "integer",
            "description": "Number of aggregated lines produced."
          },
          "created": {
            "type": "integer"
          },
          "removed": {
            "type": "integer",
            "description": "Prior plan-built rows replaced (0 on preview)."
          },
          "preserved_manual": {
            "type": "integer",
            "description": "Manually/agent-added rows left untouched."
          },
          "excluded_count": {
            "type": "integer",
            "description": "Meals the default scope left out (cooked/skipped/past). 0 for explicit meal_ids / date-window builds."
          },
          "checkoffs_preserved": {
            "type": "boolean",
            "description": "True when every still-belonging checkoff could be kept. False when matching was dishonest (mixed checks under one ingredient) and the list was rebuilt unchecked."
          },
          "selected": {
            "type": "array",
            "description": "The meals shopped.",
            "items": {
              "type": "object",
              "properties": {
                "meal_id": {
                  "type": "integer"
                },
                "recipe_id": {
                  "type": [
                    "integer",
                    "null"
                  ]
                },
                "title": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "date": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "format": "date"
                },
                "meal_type": {
                  "type": "string"
                }
              }
            }
          },
          "items": {
            "type": "array",
            "description": "Each aggregated line with the recipes/meals it came from (trace an item back to its meal).",
            "items": {
              "type": "object",
              "properties": {
                "name": {
                  "type": "string"
                },
                "quantity": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "unit": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "aisle": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "recipe_ids": {
                  "type": "array",
                  "items": {
                    "type": "integer"
                  }
                },
                "meal_ids": {
                  "type": "array",
                  "items": {
                    "type": "integer"
                  }
                }
              }
            }
          }
        },
        "required": [
          "built",
          "item_count"
        ]
      },
      "TriageResult": {
        "type": "object",
        "description": "The `data` from a recommendation triage (plan/save/dismiss).",
        "properties": {
          "id": {
            "type": "integer",
            "description": "The recommendation id."
          },
          "status": {
            "type": "string",
            "description": "New status, e.g. planned | saved | rejected."
          },
          "recipe_id": {
            "type": "integer"
          }
        }
      },
      "CreatedBy": {
        "type": "object",
        "description": "Provenance of a record. `human` = a person via the web app; `agent` = a connected agent (carries `agent_name` + `pat_id` of the PAT that wrote it); `plan` = derived from the meal plan (shopping lines built by POST /api/shop/build).",
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "human",
              "agent",
              "plan"
            ]
          },
          "agent_name": {
            "type": "string",
            "description": "Present for `agent` records — the writing PAT's name."
          },
          "pat_id": {
            "type": "integer",
            "description": "Present for `agent` records — id of the PAT that wrote it."
          }
        },
        "required": [
          "type"
        ]
      },
      "CookbookEntry": {
        "type": "object",
        "description": "A cookbook grid card: `id` is the recipe id; `is_favorite`/`is_creator`/`is_deleted` are this household's flags; `meal_types` is null when untagged. A ghost entry (recipe since deleted) carries `is_deleted: true` and null recipe fields.",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Recipe id."
          },
          "title": {
            "type": [
              "string",
              "null"
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "added_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "This household's cookbook tags on the entry."
          },
          "is_deleted": {
            "type": "boolean"
          },
          "is_favorite": {
            "type": "boolean"
          },
          "is_creator": {
            "type": "boolean"
          },
          "average_rating": {
            "type": [
              "number",
              "null"
            ]
          },
          "make_again_percent": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Share of recorded verdicts that would make it again (0-100). Null when nobody has recorded one — an absence, not a 0%."
          },
          "created_by": {
            "$ref": "#/components/schemas/CreatedBy"
          },
          "image_path": {
            "type": [
              "string",
              "null"
            ]
          },
          "image_thumb_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "source": {
            "type": [
              "object",
              "null"
            ]
          },
          "source_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "source_domain": {
            "type": [
              "string",
              "null"
            ]
          },
          "slug": {
            "type": [
              "string",
              "null"
            ]
          },
          "creator_username": {
            "type": [
              "string",
              "null"
            ]
          },
          "preparation_time": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Minutes."
          },
          "cooking_time": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Minutes."
          },
          "servings": {
            "type": [
              "integer",
              "null"
            ]
          },
          "meal_types": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            },
            "description": "breakfast/lunch/dinner; null when untagged."
          },
          "make_again_yes": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Make-Again system: households whose latest public verdict is 'make it again'. Deduped one vote per household. Null when no household has recorded a verdict."
          },
          "make_again_total": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Make-Again system: households with a recorded public make-again verdict (the denominator for make_again_yes). Null when none."
          }
        }
      },
      "RecipeDetail": {
        "type": "object",
        "description": "The full recipe object returned by GET /api/recipe/{id} (under `data.recipe`). Times are minutes. `ingredients` is the structured list; `created_by`/`last_edited_by` carry provenance; `web_url`/`api_url` are the canonical links.",
        "properties": {
          "id": {
            "type": "integer"
          },
          "title": {
            "type": "string"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "slug": {
            "type": [
              "string",
              "null"
            ]
          },
          "instructions": {
            "type": [
              "string",
              "null"
            ]
          },
          "preparation_time": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Minutes."
          },
          "cooking_time": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Minutes."
          },
          "servings": {
            "type": [
              "integer",
              "null"
            ]
          },
          "image_path": {
            "type": [
              "string",
              "null"
            ]
          },
          "source": {
            "type": [
              "object",
              "null"
            ],
            "description": "Source descriptor (type + url/note/etc.)."
          },
          "source_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "source_domain": {
            "type": [
              "string",
              "null"
            ]
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "meal_types": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            }
          },
          "created_by": {
            "$ref": "#/components/schemas/CreatedBy"
          },
          "last_edited_by": {
            "type": [
              "object",
              "null"
            ]
          },
          "can_revert": {
            "type": "boolean"
          },
          "suppressed": {
            "type": "boolean"
          },
          "suppression_mode": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "updated_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "user_id": {
            "type": "integer"
          },
          "web_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "api_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "ingredients": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Ingredient"
            }
          }
        }
      },
      "MealPlanRecipe": {
        "type": "object",
        "description": "A meal-plan slot: the recipe fields plus the slot's own `meal_id`/`meal_date`/`meal_type` and its cooked/skipped state. `meal_id` is the durable slot handle (use it for reschedule/cooked/verdict); `meal_date` is null for an undated 'Up next' slot. The full `instructions` are omitted from this week-view payload — read them from GET /api/recipe/{id}.",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Recipe id."
          },
          "suppressed": {
            "type": "boolean",
            "description": "True when the recipe has been withdrawn under a copyright takedown. The slot keeps its place so plan history stays coherent, but `description` is replaced with a neutral notice and `image_path`/`image_thumb_url` are null. Render the same removed-state treatment the recipe detail page uses."
          },
          "meal_id": {
            "type": "integer",
            "description": "Meal-plan slot id — the handle for reschedule/cooked/verdict."
          },
          "cooked_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "skipped_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "title": {
            "type": "string"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "preparation_time": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Minutes."
          },
          "cooking_time": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Minutes."
          },
          "average_rating": {
            "type": [
              "number",
              "null"
            ]
          },
          "user_rating": {
            "type": [
              "number",
              "null"
            ]
          },
          "image_path": {
            "type": [
              "string",
              "null"
            ]
          },
          "image_thumb_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Per-viewer thumbnail; null when the lead image is the scraped fallback."
          },
          "created_by": {
            "$ref": "#/components/schemas/CreatedBy"
          },
          "meal_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Null for an undated 'Up next' slot."
          },
          "meal_type": {
            "type": [
              "string",
              "null"
            ],
            "description": "breakfast | lunch | dinner (null if untagged)."
          },
          "make_again_yes": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Make-Again system: households whose latest public verdict is 'make it again'. Deduped one vote per household. Null when no household has recorded a verdict."
          },
          "make_again_total": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Make-Again system: households with a recorded public make-again verdict (the denominator for make_again_yes). Null when none."
          }
        }
      },
      "ShoppingListItem": {
        "type": "object",
        "description": "A shopping-list line. A merged line (built from several recipes) carries the full `recipe_ids`/`meal_ids` it came from and a `recipe_count`; the scalar `recipe_id` is set only for a single-source line. `recipe_ids`/`meal_ids` are null on manually-added items.",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "quantity": {
            "type": [
              "string",
              "null"
            ]
          },
          "unit": {
            "type": [
              "string",
              "null"
            ]
          },
          "checked": {
            "type": "boolean"
          },
          "aisle": {
            "type": [
              "string",
              "null"
            ]
          },
          "recipe_id": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Set only for a single-source line; null when merged from several recipes (see `recipe_ids`)."
          },
          "recipe_count": {
            "type": "integer",
            "description": "Number of distinct recipes that contributed to this line."
          },
          "recipe_ids": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "integer"
            },
            "description": "Every recipe this line was aggregated from; null for manual items."
          },
          "meal_ids": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "integer"
            },
            "description": "Every meal-plan slot this line was aggregated from; null for manual items."
          },
          "created_by": {
            "$ref": "#/components/schemas/CreatedBy"
          }
        }
      },
      "Ingredient": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "quantity": {
            "type": [
              "string",
              "null"
            ]
          },
          "unit": {
            "type": [
              "string",
              "null"
            ]
          },
          "section_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "sort_order": {
            "type": "integer"
          }
        },
        "required": [
          "id",
          "name"
        ]
      },
      "DiscoverItem": {
        "type": "object",
        "description": "A ranked discovery card: the shared recipe-card fields plus the ranking signal.",
        "properties": {
          "id": {
            "type": "integer"
          },
          "slug": {
            "type": [
              "string",
              "null"
            ]
          },
          "title": {
            "type": "string"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "meal_types": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string",
              "enum": [
                "breakfast",
                "lunch",
                "dinner"
              ]
            }
          },
          "image_path": {
            "type": [
              "string",
              "null"
            ]
          },
          "image_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Deprecated alias of image_path"
          },
          "image_thumb_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "source": {
            "type": [
              "object",
              "null"
            ]
          },
          "source_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "source_domain": {
            "type": [
              "string",
              "null"
            ]
          },
          "signal": {
            "type": "object",
            "properties": {
              "save_count": {
                "type": "integer"
              },
              "cook_count": {
                "type": "integer"
              },
              "average_rating": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "cooccurrence": {
                "type": "integer"
              },
              "is_synthetic": {
                "type": "boolean"
              },
              "verified": {
                "type": "boolean"
              },
              "make_again_yes": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Make-Again system: households whose latest public verdict is 'make it again'. Deduped one vote per household. Null when no household has recorded a verdict."
              },
              "make_again_total": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Make-Again system: households with a recorded public make-again verdict (the denominator for make_again_yes). Null when none."
              }
            }
          },
          "score": {
            "type": "number"
          }
        }
      },
      "PopularItem": {
        "type": "object",
        "description": "A popularity card: the shared recipe-card fields plus the global average rating.",
        "properties": {
          "id": {
            "type": "integer"
          },
          "slug": {
            "type": [
              "string",
              "null"
            ]
          },
          "title": {
            "type": "string"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "meal_types": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string",
              "enum": [
                "breakfast",
                "lunch",
                "dinner"
              ]
            }
          },
          "image_path": {
            "type": [
              "string",
              "null"
            ]
          },
          "image_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Deprecated alias of image_path"
          },
          "image_thumb_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "source": {
            "type": [
              "object",
              "null"
            ]
          },
          "source_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "source_domain": {
            "type": [
              "string",
              "null"
            ]
          },
          "average_rating": {
            "type": [
              "number",
              "null"
            ]
          },
          "make_again_percent": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Rounded make_again_yes/make_again_total percent; null when no verdicts."
          },
          "make_again_yes": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Make-Again system: households whose latest public verdict is 'make it again'. Deduped one vote per household. Null when no household has recorded a verdict."
          },
          "make_again_total": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Make-Again system: households with a recorded public make-again verdict (the denominator for make_again_yes). Null when none."
          }
        }
      },
      "RecipePhoto": {
        "type": "object",
        "description": "One household's photo of a recipe. `url` and `thumb_url` are **presigned and expire** (currently 24h) — fetch them fresh from the gallery rather than storing them.",
        "properties": {
          "id": {
            "type": "integer"
          },
          "url": {
            "type": "string",
            "description": "Presigned full-resolution URL."
          },
          "thumb_url": {
            "type": "string",
            "description": "Presigned thumbnail URL."
          },
          "uploaded_by": {
            "type": "integer",
            "description": "User id of the household that uploaded it."
          },
          "uploaded_by_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "That household's username, for attribution."
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "url",
          "thumb_url",
          "uploaded_by"
        ]
      }
    },
    "parameters": {
      "since": {
        "name": "since",
        "in": "query",
        "required": false,
        "description": "ISO 8601 datetime. Returns only records updated/added after this timestamp.",
        "schema": {
          "type": "string",
          "format": "date-time"
        },
        "example": "2026-03-01T00:00:00Z"
      },
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "description": "Client-chosen unique key (e.g. a UUID) making this write safe to retry. Replaying the same key returns the original stored response instead of acting twice; the replay carries the `Idempotency-Replayed: true` response header. Keys are scoped per user + endpoint.",
        "schema": {
          "type": "string",
          "maxLength": 255
        },
        "example": "3f1c9b2e-0a4d-4c7e-9b1a-2f6d8e0c1a55"
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Missing or invalid credentials. `/me` aliases return 401 (not 404) when unauthenticated — the route exists; the caller must authenticate.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      }
    },
    "headers": {
      "Idempotency-Replayed": {
        "description": "Present and `true` when this response is a replay of an earlier request with the same `Idempotency-Key` (the write was not performed again).",
        "schema": {
          "type": "string",
          "enum": [
            "true"
          ]
        }
      },
      "X-Last-Modified": {
        "description": "ISO 8601 timestamp of the most recent record in the response. Use as the `since` value for next delta query.",
        "schema": {
          "type": "string",
          "format": "date-time"
        }
      },
      "X-RateLimit-Limit": {
        "description": "Maximum requests allowed in the current window.",
        "schema": {
          "type": "integer"
        }
      },
      "X-RateLimit-Remaining": {
        "description": "Requests remaining in the current window.",
        "schema": {
          "type": "integer"
        }
      },
      "X-RateLimit-Reset": {
        "description": "Unix timestamp when the rate limit window resets.",
        "schema": {
          "type": "integer"
        }
      }
    }
  },
  "paths": {
    "/mcp": {
      "get": {
        "operationId": "mcpProbe",
        "summary": "Hosted MCP probe",
        "description": "Unauthenticated GET is 401 with WWW-Authenticate pointing at OAuth resource metadata (not a 404). Authenticated GET is 405 Allow: POST.",
        "tags": [
          "Hosted MCP"
        ],
        "security": [],
        "responses": {
          "401": {
            "description": "Complete OAuth; WWW-Authenticate names the resource metadata."
          },
          "405": {
            "description": "Use POST for JSON-RPC."
          }
        }
      },
      "post": {
        "operationId": "mcpRpc",
        "summary": "Hosted MCP (streamable HTTP)",
        "description": "JSON-RPC. Requires an OAuth access token issued by this host (not a PAT). Tools: whoami, remember_recipe_from_url.",
        "tags": [
          "Hosted MCP"
        ],
        "security": [
          {
            "mcpOAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "jsonrpc",
                  "method"
                ],
                "properties": {
                  "jsonrpc": {
                    "type": "string",
                    "const": "2.0"
                  },
                  "id": {},
                  "method": {
                    "type": "string"
                  },
                  "params": {
                    "type": "object"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "JSON-RPC result",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "jsonrpc"
                  ],
                  "properties": {
                    "jsonrpc": {
                      "type": "string"
                    },
                    "id": {},
                    "result": {
                      "type": "object"
                    },
                    "error": {
                      "type": "object"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing or non-OAuth token."
          },
          "202": {
            "description": "JSON-RPC notification accepted."
          }
        }
      }
    },
    "/.well-known/oauth-protected-resource": {
      "get": {
        "operationId": "oauthProtectedResource",
        "summary": "OAuth protected-resource metadata",
        "tags": [
          "Hosted MCP"
        ],
        "security": [],
        "responses": {
          "200": {
            "description": "RFC 9728 resource metadata",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/.well-known/oauth-protected-resource/mcp": {
      "get": {
        "operationId": "oauthProtectedResourceMcp",
        "summary": "OAuth protected-resource metadata for /mcp",
        "tags": [
          "Hosted MCP"
        ],
        "security": [],
        "responses": {
          "200": {
            "description": "RFC 9728 resource metadata",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/.well-known/oauth-authorization-server": {
      "get": {
        "operationId": "oauthAuthorizationServer",
        "summary": "OAuth authorization-server metadata",
        "tags": [
          "Hosted MCP"
        ],
        "security": [],
        "responses": {
          "200": {
            "description": "RFC 8414 authorization-server metadata",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/mcp/oauth/register": {
      "post": {
        "operationId": "mcpOauthRegister",
        "summary": "Dynamic client registration",
        "tags": [
          "Hosted MCP"
        ],
        "security": [],
        "responses": {
          "201": {
            "description": "Registered public client",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/mcp/oauth/authorize": {
      "get": {
        "operationId": "mcpOauthAuthorize",
        "summary": "Authorization-code + PKCE",
        "tags": [
          "Hosted MCP"
        ],
        "security": [],
        "responses": {
          "302": {
            "description": "Redirect with code, or to Auth0."
          },
          "400": {
            "description": "Invalid request."
          }
        }
      }
    },
    "/mcp/oauth/token": {
      "post": {
        "operationId": "mcpOauthToken",
        "summary": "OAuth token",
        "tags": [
          "Hosted MCP"
        ],
        "security": [],
        "responses": {
          "200": {
            "description": "Access + refresh token",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/api/auth/whoami": {
      "get": {
        "operationId": "whoami",
        "summary": "Get current auth context",
        "tags": [
          "Auth"
        ],
        "responses": {
          "200": {
            "description": "Auth context",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "description": "JWT auth returns `auth_method`/`user_id`/`username`; PAT auth additionally returns `pat_id`, `pat_name`, `agent_name`, and `scopes`; hosted MCP returns `auth_method=mcp` with start scopes.",
                      "properties": {
                        "auth_method": {
                          "type": "string",
                          "enum": [
                            "jwt",
                            "pat",
                            "mcp"
                          ]
                        },
                        "user_id": {
                          "type": "integer"
                        },
                        "username": {
                          "type": [
                            "string",
                            "null"
                          ]
                        },
                        "scopes": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        },
                        "pat_id": {
                          "type": [
                            "integer",
                            "null"
                          ],
                          "description": "PAT auth only — id of the presented token."
                        },
                        "pat_name": {
                          "type": [
                            "string",
                            "null"
                          ]
                        },
                        "agent_name": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "description": "PAT auth only — the token's name."
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/cookbook/": {
      "get": {
        "operationId": "listCookbook",
        "summary": "List cookbook recipes",
        "description": "Returns the user's cookbook entries with recipe metadata. Supports delta queries via `?since=`.",
        "tags": [
          "Cookbook"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/since"
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 100
            }
          },
          {
            "name": "offset",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 0
            }
          },
          {
            "name": "include_deleted",
            "in": "query",
            "schema": {
              "type": "boolean",
              "default": false
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Cookbook entries",
            "headers": {
              "X-Last-Modified": {
                "$ref": "#/components/headers/X-Last-Modified"
              },
              "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": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "recipes": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/CookbookEntry"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/cookbook/add": {
      "post": {
        "operationId": "addToCookbook",
        "summary": "Add recipe to cookbook (idempotent)",
        "tags": [
          "Cookbook"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "recipe_id": {
                    "type": "integer"
                  },
                  "tags": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "is_favorite": {
                    "type": "boolean"
                  },
                  "notes": {
                    "type": "string"
                  }
                },
                "required": [
                  "recipe_id"
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Added or restored",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "added": {
                          "type": "boolean"
                        },
                        "already_exists": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/cookbook/remove": {
      "post": {
        "operationId": "removeFromCookbook",
        "summary": "Soft-delete recipe from cookbook",
        "tags": [
          "Cookbook"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "recipe_id": {
                    "type": "integer"
                  }
                },
                "required": [
                  "recipe_id"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Removed",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {},
                      "description": "Empty on success."
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/plan/meal_plan/me": {
      "get": {
        "operationId": "getMyMealPlan",
        "summary": "Get current user's meal plan",
        "description": "Returns the caller's meal plan. Supports delta queries via `?since=`. Requires authentication: an unauthenticated `/me` call is **401** `UNAUTHORIZED`, not 404.",
        "tags": [
          "Meal Plan"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/since"
          }
        ],
        "responses": {
          "200": {
            "description": "Meal plan recipes",
            "headers": {
              "X-Last-Modified": {
                "$ref": "#/components/headers/X-Last-Modified"
              },
              "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": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "recipes": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/MealPlanRecipe"
                          }
                        },
                        "username": {
                          "type": [
                            "string",
                            "null"
                          ]
                        },
                        "is_own_plan": {
                          "type": "boolean"
                        },
                        "current_user": {
                          "type": [
                            "object",
                            "null"
                          ],
                          "description": "The authenticated caller. `id` is an integer, matching whoami's `user_id`.",
                          "properties": {
                            "id": {
                              "type": "integer"
                            },
                            "username": {
                              "type": [
                                "string",
                                "null"
                              ]
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/plan/add_to_meal_plan": {
      "post": {
        "operationId": "addToMealPlan",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "summary": "Add recipe to the plan UNDATED (the \"Up next\" tray; idempotent)",
        "tags": [
          "Meal Plan"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "recipe_id": {
                    "type": "integer"
                  },
                  "recipe_name": {
                    "type": "string"
                  },
                  "recipe_description": {
                    "type": "string"
                  },
                  "recipe_categories": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "source_page": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Added or already exists",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "added": {
                          "type": "boolean"
                        },
                        "revived": {
                          "type": "boolean"
                        },
                        "already_exists": {
                          "type": "boolean"
                        },
                        "meal_id": {
                          "type": "integer"
                        },
                        "meal_date": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "format": "date"
                        },
                        "meal_type": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "description": "Adds a recipe to the meal plan **undated** — it lands in the \"Up next\" tray with `meal_date: null`. This endpoint does NOT accept `date`/`meal_type` (they are ignored if sent). Dated writes: `POST /api/plan/add_to_slot` (one meal; occupied slot → 409 `SLOT_TAKEN`) and `POST /api/plan/batch` (upsert per slot — re-sending a slot replaces it). Idempotent on the recipe's live presence. Requires scope `plan:write`."
      }
    },
    "/api/plan/add_to_slot": {
      "post": {
        "operationId": "addToSlot",
        "summary": "Add a recipe into a specific (date, meal_type) slot",
        "description": "The single dated placement. Body `{recipe_id, date, meal_type?}` (`meal_type` defaults to `dinner`). An occupied slot holding a different recipe is **409** `SLOT_TAKEN` (`details.conflict` has the occupant) — never a silent overwrite. Re-adding the same recipe to its own cell is idempotent (`already_exists: true`). For \"no day yet\" use `POST /api/plan/add_to_meal_plan` (undated). To overwrite a week of slots use `POST /api/plan/batch`. Does not rebuild the shopping list — GET shop keeps the previous lines and sets `plan_changed_since_list` until an explicit `POST /api/shop/build`. `checkoffs_preserved` is true because the list was left unchanged. Requires scope `plan:write`.",
        "tags": [
          "Meal Plan"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "recipe_id": {
                    "type": "integer"
                  },
                  "date": {
                    "type": "string",
                    "format": "date"
                  },
                  "meal_type": {
                    "type": "string",
                    "enum": [
                      "breakfast",
                      "lunch",
                      "dinner",
                      "snack"
                    ],
                    "default": "dinner"
                  }
                },
                "required": [
                  "recipe_id",
                  "date"
                ]
              },
              "example": {
                "recipe_id": 1337,
                "date": "2026-06-10",
                "meal_type": "dinner"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Added or already in that slot",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "added": {
                          "type": "boolean"
                        },
                        "already_exists": {
                          "type": "boolean"
                        },
                        "meal_id": {
                          "type": "integer"
                        },
                        "meal_date": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "format": "date"
                        },
                        "meal_type": {
                          "type": "string"
                        },
                        "checkoffs_preserved": {
                          "type": "boolean",
                          "description": "Always true: this write does not rebuild the shopping list, so existing checkoffs stay as they were. Refresh with POST /api/shop/build."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "Slot already occupied by a different recipe",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/plan/manage_recipes": {
      "post": {
        "operationId": "manageRecipes",
        "summary": "Remove or rotate recipes in meal plan",
        "tags": [
          "Meal Plan"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "recipe_ids": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    }
                  },
                  "action": {
                    "type": "string",
                    "enum": [
                      "remove",
                      "rotate"
                    ]
                  }
                },
                "required": [
                  "recipe_ids",
                  "action"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Action applied",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "checkoffs_preserved": {
                          "type": "boolean",
                          "description": "Always true: this write does not rebuild the shopping list, so existing checkoffs stay as they were. Refresh with POST /api/shop/build."
                        }
                      },
                      "description": "The existing shopping list is left unchanged; GET shop sets plan_changed_since_list."
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/plan/batch": {
      "post": {
        "operationId": "batchUpsertMealPlan",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "summary": "Upsert dated meal-plan slots (the primary planning write)",
        "description": "Upsert up to 50 meal entries by their (date, meal_type) slot in one call — the idiomatic way for an agent to lay down or update a week. **This is the overwrite path**: re-sending a slot replaces its contents (idempotent by slot), so retries are safe and a deliberate swap just re-sends the slot. Does not rebuild the shopping list — GET shop keeps the previous lines and sets `plan_changed_since_list` until an explicit `POST /api/shop/build`. By contrast, placing a single undated meal or a recommendation into an already-occupied slot (`/api/recommendations/{id}/plan` with a date) *rejects* with `409 SLOT_TAKEN` rather than overwriting — so an agent never destroys a meal it didn't mean to. Choose `batch` when you intend to set the slot; expect `SLOT_TAKEN` when you intend to fill only an empty one.",
        "tags": [
          "Meal Plan"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "entries": {
                    "type": "array",
                    "maxItems": 50,
                    "items": {
                      "type": "object",
                      "properties": {
                        "date": {
                          "type": "string",
                          "format": "date"
                        },
                        "meal_type": {
                          "type": "string",
                          "enum": [
                            "breakfast",
                            "lunch",
                            "dinner"
                          ],
                          "default": "dinner"
                        },
                        "recipe_id": {
                          "type": "integer"
                        },
                        "notes": {
                          "type": [
                            "string",
                            "null"
                          ]
                        }
                      },
                      "required": [
                        "date",
                        "recipe_id"
                      ]
                    }
                  }
                },
                "required": [
                  "entries"
                ]
              },
              "example": {
                "entries": [
                  {
                    "date": "2026-07-20",
                    "meal_type": "dinner",
                    "recipe_id": 4412,
                    "notes": "Lily's pick"
                  },
                  {
                    "date": "2026-07-21",
                    "meal_type": "dinner",
                    "recipe_id": 4419
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Slots upserted",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "created": {
                          "type": "integer"
                        },
                        "updated": {
                          "type": "integer"
                        },
                        "checkoffs_preserved": {
                          "type": "boolean",
                          "description": "Always true: this write does not rebuild the shopping list, so existing checkoffs stay as they were. Refresh with POST /api/shop/build."
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/plan/cooked": {
      "post": {
        "operationId": "markMealCooked",
        "summary": "Mark a planned meal cooked (the verified-cook signal)",
        "description": "Sets the slot's `cooked_at` and verifies the recipe — the household's strongest 'we actually made this' signal, which feeds discovery and the cook journal (`GET /api/plan/history`). This is the feedback primitive for 'we cooked it'; ratings/make-again ride on `POST /api/plan/{meal_id}/verdict`.",
        "tags": [
          "Meal Plan"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "date": {
                    "type": "string",
                    "format": "date"
                  },
                  "meal_type": {
                    "type": "string",
                    "enum": [
                      "breakfast",
                      "lunch",
                      "dinner"
                    ],
                    "default": "dinner"
                  }
                },
                "required": [
                  "date"
                ]
              },
              "example": {
                "date": "2026-07-20",
                "meal_type": "dinner"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Marked cooked",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "cooked": {
                          "type": "boolean"
                        },
                        "recipe_id": {
                          "type": "integer"
                        },
                        "cooked_at": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "format": "date-time"
                        },
                        "verified": {
                          "type": "boolean"
                        },
                        "cook_member_id": {
                          "type": [
                            "integer",
                            "null"
                          ]
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/plan/{meal_id}/verdict": {
      "post": {
        "operationId": "recordMealVerdict",
        "summary": "Record cooked + make-again verdict in one call",
        "description": "One-tap cook verdict for a planned meal slot: `made=true` marks the slot cooked and, when a rating signal is present, upserts the household's make-again verdict for that recipe; `made=false` records nothing. This is the richer sibling of `/cooked` (which only marks cooked). Actor-aware — works for the owner (JWT) or a household member (member token).",
        "tags": [
          "Meal Plan"
        ],
        "parameters": [
          {
            "name": "meal_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "made": {
                    "type": "boolean",
                    "description": "Did the household actually make it?"
                  },
                  "make_again": {
                    "type": [
                      "boolean",
                      "null"
                    ],
                    "description": "Would they make it again? (the make-again signal)"
                  },
                  "rating": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "Optional 1–100 rating."
                  }
                },
                "required": [
                  "made"
                ]
              },
              "example": {
                "made": true,
                "make_again": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Verdict recorded",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "recorded": {
                          "type": "boolean"
                        },
                        "meal_id": {
                          "type": "integer"
                        },
                        "cooked": {
                          "type": "boolean"
                        },
                        "cooked_at": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "format": "date-time"
                        },
                        "skipped": {
                          "type": "boolean"
                        },
                        "skipped_at": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "format": "date-time"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Not this household's meal",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/plan/history": {
      "get": {
        "operationId": "getCookJournal",
        "summary": "The household's cook journal (meals actually cooked)",
        "description": "Meals with a `cooked_at`, newest first, with a household-visibility rating summary. The durable record of what this household cooks and how it landed — read this to learn tastes instead of keeping your own local notes.",
        "tags": [
          "Meal Plan"
        ],
        "parameters": [
          {
            "name": "start",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "description": "Bound the cooked_at axis (YYYY-MM-DD)."
          },
          {
            "name": "end",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Cooked meals, newest first",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "history": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "meal_id": {
                                "type": "integer"
                              },
                              "meal_date": {
                                "type": [
                                  "string",
                                  "null"
                                ],
                                "format": "date"
                              },
                              "meal_type": {
                                "type": [
                                  "string",
                                  "null"
                                ]
                              },
                              "recipe_id": {
                                "type": "integer"
                              },
                              "title": {
                                "type": [
                                  "string",
                                  "null"
                                ]
                              },
                              "cooked_at": {
                                "type": [
                                  "string",
                                  "null"
                                ],
                                "format": "date-time"
                              },
                              "cook_member_id": {
                                "type": [
                                  "integer",
                                  "null"
                                ]
                              },
                              "notes": {
                                "type": [
                                  "string",
                                  "null"
                                ]
                              },
                              "rating_summary": {
                                "type": [
                                  "object",
                                  "null"
                                ]
                              }
                            }
                          }
                        },
                        "count": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/shop/list/me": {
      "get": {
        "operationId": "getMyShoppingList",
        "summary": "Get current user's shopping list",
        "description": "Returns the caller's shopping list plus `plan_changed_since_list` (true iff a plan-derived list exists and the meal plan has changed since it was built; false when they match or there is no list). Shop-scope today is the household's local day, not UTC. Slot writes do not rewrite this list. Refresh with `POST /api/shop/build`. Supports delta queries via `?since=`. Requires authentication: an unauthenticated `/me` call is **401** `UNAUTHORIZED`, not 404.",
        "tags": [
          "Shopping List"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/since"
          }
        ],
        "responses": {
          "200": {
            "description": "Shopping list items",
            "headers": {
              "X-Last-Modified": {
                "$ref": "#/components/headers/X-Last-Modified"
              },
              "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": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "shopping_list": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/ShoppingListItem"
                          }
                        },
                        "plan_changed_since_list": {
                          "type": "boolean",
                          "description": "True iff a plan-derived list exists and the meal plan has changed since this list was built. Comparison uses the household's local shop-scope day, not UTC. False when they match or there is no list."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/shop/build": {
      "post": {
        "operationId": "buildShoppingList",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "summary": "Build the shopping list from the active plan",
        "description": "The shop verb: compose (or refresh) the list from the household's **active** plan. Slot writes leave the existing list in place; call this after GET shop returns `plan_changed_since_list: true`, or to preview / target a window. **Scope:** by default only unresolved meals that are undated ('Up next') or dated the household's local today-or-later (not UTC) — cooked, skipped, and past meals are excluded. Rebuilding is idempotent: it replaces the plan-built rows, preserves manually/agent-added items, and keeps a checkoff only if that item still belongs (`checkoffs_preserved`). After a persisted rebuild, GET shop's `plan_changed_since_list` is false. Body is optional. Returns provenance so you can verify before telling the household groceries are ready.",
        "tags": [
          "Shopping List"
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "from": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date",
                    "description": "Inclusive start of a dated window (undated meals excluded when a window is given)."
                  },
                  "to": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date",
                    "description": "Inclusive end of the dated window."
                  },
                  "meal_types": {
                    "type": [
                      "array",
                      "null"
                    ],
                    "items": {
                      "type": "string",
                      "enum": [
                        "breakfast",
                        "lunch",
                        "dinner"
                      ]
                    },
                    "description": "Narrow to these slot types."
                  },
                  "meal_ids": {
                    "type": [
                      "array",
                      "null"
                    ],
                    "items": {
                      "type": "integer"
                    },
                    "description": "Shop exactly these meals (overrides the date/resolved filters)."
                  },
                  "preview": {
                    "type": "boolean",
                    "default": false,
                    "description": "Compute the would-be list + provenance WITHOUT persisting."
                  }
                }
              },
              "example": {
                "from": "2026-07-20",
                "to": "2026-07-26",
                "meal_types": [
                  "dinner"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Built (or previewed). Empty non-preview selection is a 404.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/ShopBuildResult"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid body — e.g. a reversed date window (`from` after `to`) or an unknown `meal_types` value. Code `VALIDATION_ERROR`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "404": {
            "description": "Nothing in scope to shop for (non-preview)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/shop/clear": {
      "post": {
        "operationId": "clearShoppingList",
        "summary": "Clear entire shopping list",
        "tags": [
          "Shopping List"
        ],
        "responses": {
          "200": {
            "description": "Shopping list cleared",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {},
                      "description": "Empty on success."
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/shop/manual_add": {
      "post": {
        "operationId": "addItemsManually",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "summary": "Manually add items to shopping list",
        "tags": [
          "Shopping List"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "ingredients": {
                    "type": "array",
                    "maxItems": 200,
                    "description": "At most 200 items per request, mirroring POST /api/shop/batch. Longer lists are rejected with 400.",
                    "items": {
                      "type": "object",
                      "properties": {
                        "name": {
                          "type": "string",
                          "maxLength": 255
                        },
                        "quantity": {
                          "type": "string",
                          "maxLength": 50
                        },
                        "unit": {
                          "type": "string",
                          "maxLength": 50
                        },
                        "aisle": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "name"
                      ]
                    }
                  }
                },
                "required": [
                  "ingredients"
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Items added",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "added_count": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/recipe/": {
      "get": {
        "operationId": "listRecipes",
        "summary": "List recipe titles",
        "tags": [
          "Recipe"
        ],
        "parameters": [
          {
            "name": "include_deleted",
            "in": "query",
            "schema": {
              "type": "boolean",
              "default": false
            }
          },
          {
            "name": "search",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Recipe list",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "recipes": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "id": {
                                "type": "integer"
                              },
                              "title": {
                                "type": "string"
                              },
                              "slug": {
                                "type": "string"
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/recipe/{recipe_id}": {
      "get": {
        "operationId": "getRecipe",
        "summary": "Get recipe details",
        "tags": [
          "Recipe"
        ],
        "parameters": [
          {
            "name": "recipe_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Full recipe object",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "recipe": {
                          "$ref": "#/components/schemas/RecipeDetail"
                        },
                        "average_rating": {
                          "type": [
                            "number",
                            "null"
                          ]
                        },
                        "user_rating": {
                          "type": [
                            "number",
                            "null"
                          ]
                        },
                        "creator_username": {
                          "type": [
                            "string",
                            "null"
                          ]
                        },
                        "ratings": {
                          "type": "array",
                          "items": {
                            "type": "object"
                          }
                        },
                        "make_again": {
                          "type": "object",
                          "properties": {
                            "count": {
                              "type": "integer",
                              "description": "Visible ratings with a recorded make-again verdict (the percent's denominator)."
                            },
                            "percent": {
                              "type": [
                                "number",
                                "null"
                              ]
                            },
                            "yes": {
                              "type": [
                                "integer",
                                "null"
                              ],
                              "description": "Visible verdicts saying 'make it again' (viewer-scoped)."
                            },
                            "total": {
                              "type": [
                                "integer",
                                "null"
                              ],
                              "description": "Visible ratings with a recorded verdict; equals count."
                            }
                          }
                        },
                        "user_state": {
                          "type": "object",
                          "properties": {
                            "is_creator": {
                              "type": "boolean"
                            },
                            "is_favorited": {
                              "type": "boolean"
                            },
                            "is_in_cookbook": {
                              "type": "boolean"
                            },
                            "cookbook_tags": {
                              "type": "array",
                              "items": {
                                "type": "string"
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "put": {
        "operationId": "editRecipe",
        "summary": "Edit recipe",
        "tags": [
          "Recipe"
        ],
        "parameters": [
          {
            "name": "recipe_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "title": {
                    "type": "string"
                  },
                  "description": {
                    "type": "string"
                  },
                  "instructions": {
                    "type": "string"
                  },
                  "ingredients": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/Ingredient"
                    }
                  },
                  "preparation_time": {
                    "type": "string"
                  },
                  "cooking_time": {
                    "type": "string"
                  },
                  "servings": {
                    "type": "integer"
                  },
                  "tags": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                },
                "required": [
                  "title",
                  "description",
                  "instructions",
                  "ingredients"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Recipe updated",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "recipe_id": {
                          "type": "integer"
                        },
                        "slug": {
                          "type": [
                            "string",
                            "null"
                          ]
                        },
                        "slug_url": {
                          "type": [
                            "string",
                            "null"
                          ]
                        },
                        "creator_username": {
                          "type": [
                            "string",
                            "null"
                          ]
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/recipe/manual": {
      "post": {
        "operationId": "createRecipe",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "summary": "Create a minimal recipe (human simple-create)",
        "description": "The web app's bare title+description create — it takes only `recipe_name`/`recipe_description`. Agents should prefer `POST /api/recipe/structured` (the canonical path with full ingredients/instructions + dedup) or `/from-url`; use `/manual` only for a deliberately sparse stub.",
        "tags": [
          "Recipe"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "recipe_name": {
                    "type": "string"
                  },
                  "recipe_description": {
                    "type": "string"
                  }
                },
                "required": [
                  "recipe_name"
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Recipe created",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/RecipeWriteResult"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/recipe/from-photo": {
      "post": {
        "operationId": "captureRecipeFromPhoto",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "summary": "Ingest a recipe from photo(s) (ingredients + steps only)",
        "description": "Save one or more photos of a single recipe, then digitize ingredients + steps. Same job as the human Capture door. Quantity and unit are split at ingest (never dump a whole line into name). Printed title if readable; otherwise empty — never an invented cute name. Multi-photo merges into one keep; two recipes in the set is the two-recipe miss. Honest misses (not_food, two_recipes, blurry, partial) create no recipe card; the photo stays saved. Not a dinner generator. Requires scope `recipes:write`.",
        "tags": [
          "Recipe"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "images": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1,
                    "maxItems": 6,
                    "description": "Base64 data URLs (JPEG, PNG, GIF, or WebP)."
                  },
                  "original": {
                    "type": "boolean",
                    "description": "True only when the human is the recipe's author."
                  },
                  "author": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "work_title": {
                    "type": [
                      "string",
                      "null"
                    ]
                  }
                },
                "required": [
                  "images"
                ]
              },
              "example": {
                "images": [
                  "data:image/jpeg;base64,..."
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Kept — photo saved and a keepable recipe returned.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/PhotoIngestResult"
                    }
                  }
                }
              }
            }
          },
          "200": {
            "description": "Honest miss (status=miss, miss code set) or parser failure (status=saved). Photo stays saved. No recipe card on a miss.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/PhotoIngestResult"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/recipe/from-url": {
      "post": {
        "operationId": "captureRecipeFromUrl",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "summary": "Ingest a recipe from a URL (deterministic, no LLM)",
        "description": "Deterministic schema.org (JSON-LD) capture from a URL — no server-side LLM. 201 when the page exposes structured recipe data (deduped to the canonical recipe); 422 NO_STRUCTURED_DATA with `error.details.page_text` when it does not — parse that with your own tokens and POST /api/recipe/structured. If the origin blocks or times out our fetch (502 FETCH_FAILED / 504 FETCH_TIMEOUT), fetch the page yourself and retry this call with the content in `page_text` (or `html`) — the URL is still required (canonical dedup + attribution) but is not fetched when content is supplied. Requires scope `recipes:write`.",
        "tags": [
          "Recipe"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "url": {
                    "type": "string",
                    "format": "uri"
                  },
                  "html": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "maxLength": 2000000,
                    "description": "Raw HTML of the page, fetched by the caller. Supply when the origin blocks the server-side fetch (502 FETCH_FAILED); the server extracts schema.org from it and never fetches. `html` wins if both are sent."
                  },
                  "page_text": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "maxLength": 2000000,
                    "description": "Page content fetched by the caller; parsed like `html`."
                  }
                },
                "required": [
                  "url"
                ]
              },
              "example": {
                "url": "https://www.example.com/recipes/weeknight-chicken"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Recipe ingested (or existing recipe returned on dedup)",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/RecipeWriteResult"
                    }
                  }
                }
              }
            }
          },
          "422": {
            "description": "No structured recipe data — parse the returned text and use /api/recipe/structured",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "502": {
            "description": "FETCH_FAILED — the origin blocked the server-side fetch. Not a dead end: `error.details.retry_with` names the fields (`page_text`, `html`) to resend with caller-fetched content; `error.details.url` echoes the URL.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "504": {
            "description": "FETCH_TIMEOUT — the origin timed out. Same retry contract as 502 (resend with `page_text`/`html`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/recipe/structured": {
      "post": {
        "operationId": "createStructuredRecipe",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "summary": "Create a fully-structured recipe in one call",
        "description": "The canonical agent write for a recipe you've already structured (e.g. after parsing a page `/from-url` returned as `NO_STRUCTURED_DATA`). Deduplicated by content hash — resubmitting the same recipe returns the existing one. Requires scope `recipes:write`.",
        "tags": [
          "Recipe"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "title": {
                    "type": "string"
                  },
                  "description": {
                    "type": [
                      "string",
                      "null"
                    ]
                  },
                  "ingredients": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "name": {
                          "type": "string"
                        },
                        "quantity": {
                          "type": [
                            "string",
                            "null"
                          ]
                        },
                        "unit": {
                          "type": [
                            "string",
                            "null"
                          ]
                        }
                      },
                      "required": [
                        "name"
                      ]
                    }
                  },
                  "instructions": {
                    "oneOf": [
                      {
                        "type": "string"
                      },
                      {
                        "type": "array",
                        "items": {
                          "type": "string"
                        }
                      }
                    ]
                  },
                  "preparation_time": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "Minutes."
                  },
                  "cooking_time": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "Minutes."
                  },
                  "servings": {
                    "type": [
                      "integer",
                      "null"
                    ]
                  },
                  "tags": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "meal_types": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "breakfast",
                        "lunch",
                        "dinner"
                      ]
                    },
                    "description": "Tag at ingest so meal-type filters stay accurate."
                  }
                },
                "required": [
                  "title"
                ]
              },
              "example": {
                "title": "Weeknight Chicken Traybake",
                "description": "One-pan chicken thighs with lemon and potatoes.",
                "ingredients": [
                  {
                    "name": "chicken thighs",
                    "quantity": "6",
                    "unit": ""
                  },
                  {
                    "name": "baby potatoes",
                    "quantity": "1",
                    "unit": "lb"
                  },
                  {
                    "name": "lemon",
                    "quantity": "1",
                    "unit": ""
                  }
                ],
                "instructions": [
                  "Heat oven to 425F.",
                  "Toss everything on a sheet pan.",
                  "Roast 35 minutes."
                ],
                "cooking_time": 35,
                "servings": 4,
                "meal_types": [
                  "dinner"
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Recipe created (or existing recipe returned on dedup)",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/RecipeWriteResult"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/agent/events": {
      "get": {
        "operationId": "getEventStream",
        "summary": "Poll the household event stream (cursor-based)",
        "description": "The polling fallback to webhooks. Returns events oldest-first after your `since` cursor, plus a `cursor` to pass next poll. Prefer `POST /api/agent/webhooks` (push) for anything latency-sensitive. Requires scope `events:read`.",
        "tags": [
          "Agent"
        ],
        "parameters": [
          {
            "name": "since",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 0
            },
            "description": "Return events with id greater than this cursor."
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 100,
              "maximum": 200
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Events oldest-first",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "events": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/WebhookEvent"
                          }
                        },
                        "cursor": {
                          "type": "integer"
                        },
                        "count": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/agent/webhooks": {
      "get": {
        "operationId": "listWebhooks",
        "summary": "List active webhooks",
        "description": "Secrets are never returned. Requires scope `webhooks:write`.",
        "tags": [
          "Agent"
        ],
        "responses": {
          "200": {
            "description": "Active webhooks",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "webhooks": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "id": {
                                "type": "integer"
                              },
                              "url": {
                                "type": "string"
                              },
                              "events": {
                                "type": [
                                  "array",
                                  "null"
                                ],
                                "items": {
                                  "type": "string"
                                }
                              },
                              "consecutive_failures": {
                                "type": "integer"
                              },
                              "disabled_at": {
                                "type": [
                                  "string",
                                  "null"
                                ],
                                "format": "date-time"
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createWebhook",
        "summary": "Subscribe to pushed events (preferred over polling)",
        "description": "Register an https endpoint to receive HMAC-signed event pushes. The signing secret is returned **once** in the response — store it and verify the `X-SmartPlate-Signature` header on delivery. Omit `events` (or send an empty list) to receive everything; otherwise list the `type` values you want (see the `WebhookEvent` schema catalog). A webhook that fails 15 consecutive delivery drains is auto-disabled. Requires scope `webhooks:write`.",
        "tags": [
          "Agent"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "url": {
                    "type": "string",
                    "format": "uri",
                    "description": "https endpoint to receive events."
                  },
                  "events": {
                    "type": [
                      "array",
                      "null"
                    ],
                    "items": {
                      "type": "string"
                    },
                    "description": "Event types to receive; null/empty = all."
                  }
                },
                "required": [
                  "url"
                ]
              },
              "example": {
                "url": "https://my-agent.example.com/hooks/smartplate",
                "events": [
                  "meal.cooked",
                  "meal.swapped",
                  "recipe.rated",
                  "proposal.approved"
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Webhook registered; signing secret returned once.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "id": {
                          "type": "integer"
                        },
                        "url": {
                          "type": "string"
                        },
                        "events": {
                          "type": [
                            "array",
                            "null"
                          ],
                          "items": {
                            "type": "string"
                          }
                        },
                        "secret": {
                          "type": "string",
                          "description": "The HMAC signing secret — returned ONCE here; store it, it can't be retrieved again."
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/agent/webhooks/{webhook_id}": {
      "delete": {
        "operationId": "deleteWebhook",
        "summary": "Deactivate a webhook",
        "tags": [
          "Agent"
        ],
        "parameters": [
          {
            "name": "webhook_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Webhook deactivated",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "deleted": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/recommendations/": {
      "get": {
        "operationId": "getRecommendations",
        "summary": "The current 'New for you' set (Discover)",
        "description": "Exactly three recommendations from outside the cookbook. A batch lives for its UTC generation day: same-day leftovers persist (viewing does not consume); leftover cards expire overnight so tomorrow still brings a new 3; a fully-acted same-day set serializes empty until the next UTC day. Requires scope `recommendations:read`.",
        "tags": [
          "Discover"
        ],
        "responses": {
          "200": {
            "description": "Recommendation set",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "recommendations": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Recommendation"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/recommendations/shelves": {
      "get": {
        "operationId": "getDiscoverShelves",
        "summary": "Discover reminder shelves",
        "description": "The two reminder shelves beside 'New for you': `saved_not_planned` (cookbook recipes never planned) and `loved_dormant` (favorites not cooked in a while), plus `found_for_you` (agent-submitted web finds). Planning an item retires it from its shelf. Requires scope `recommendations:read`.",
        "tags": [
          "Discover"
        ],
        "responses": {
          "200": {
            "description": "Shelves: found_for_you, saved_not_planned, loved_dormant",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "found_for_you": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Recommendation"
                          }
                        },
                        "saved_not_planned": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Recommendation"
                          }
                        },
                        "loved_dormant": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Recommendation"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/recommendations/online-match": {
      "post": {
        "operationId": "submitWebFind",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "summary": "Submit a recipe you found on the web → 'Found for you'",
        "description": "A connected agent submits a recipe it found on the open web and thinks the household will like. Give a corpus `recipe_id`, OR a `url` we ingest deterministically (schema.org) — a URL with no structured data is handed back with its status so you parse it with your own tokens and resubmit by `recipe_id`. `note` is your short 'why' (the taste match), shown as the card's provenance. Idempotent per (household, recipe). Surfaces in Discover's 'Found for you', triageable like any recommendation. Requires scope `recommendations:write`.",
        "tags": [
          "Discover"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "recipe_id": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "A recipe already in the corpus."
                  },
                  "url": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uri",
                    "description": "A web page to ingest (schema.org)."
                  },
                  "note": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Short 'why the household will like this'."
                  }
                }
              },
              "example": {
                "url": "https://www.example.com/miso-glazed-salmon",
                "note": "You loved the sheet-pan salmon in June — same weeknight-easy, umami-forward profile."
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Added to Found for you",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "reco_id": {
                          "type": "integer"
                        },
                        "recipe_id": {
                          "type": "integer"
                        },
                        "already_exists": {
                          "type": "boolean",
                          "description": "True when this (household, recipe) was already a Found-for-you card (idempotent)."
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/recommendations/{reco_id}/plan": {
      "post": {
        "operationId": "planRecommendation",
        "summary": "Triage a recommendation into the plan",
        "description": "Optional body `{date, meal_type}` places it in a specific slot (an occupied slot is a 409 `SLOT_TAKEN`); an empty body lands it undated in 'Up next'. Requires scope `recommendations:write`.",
        "tags": [
          "Discover"
        ],
        "parameters": [
          {
            "name": "reco_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "date": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date"
                  },
                  "meal_type": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "enum": [
                      "breakfast",
                      "lunch",
                      "dinner",
                      null
                    ]
                  }
                }
              },
              "example": {
                "date": "2026-07-22",
                "meal_type": "dinner"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Planned",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/TriageResult"
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "Target slot occupied (SLOT_TAKEN)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/recommendations/{reco_id}/save": {
      "post": {
        "operationId": "saveRecommendation",
        "summary": "Save a recommendation to the cookbook",
        "tags": [
          "Discover"
        ],
        "parameters": [
          {
            "name": "reco_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Saved to cookbook",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/TriageResult"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/recommendations/{reco_id}/dismiss": {
      "post": {
        "operationId": "dismissRecommendation",
        "summary": "Pass on a recommendation",
        "tags": [
          "Discover"
        ],
        "parameters": [
          {
            "name": "reco_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Dismissed",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/TriageResult"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/preferences/": {
      "get": {
        "operationId": "getPreferences",
        "summary": "Household food preferences (diet, allergies, cuisines)",
        "description": "The household's free-form food preferences — the diet/allergy/cuisine context an agent should honor when planning or recommending. Read this before composing a week. Requires scope `preferences:read`.",
        "tags": [
          "Preferences"
        ],
        "responses": {
          "200": {
            "description": "Preferences",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "preferences": {
                          "type": [
                            "string",
                            "object",
                            "null"
                          ],
                          "description": "The household's stated food preferences."
                        },
                        "username": {
                          "type": "string"
                        },
                        "user_id": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "savePreferences",
        "summary": "Update household food preferences",
        "description": "Write the household's food preferences. Requires scope `preferences:write`.",
        "tags": [
          "Preferences"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "preferences": {
                    "type": [
                      "string",
                      "object"
                    ]
                  }
                }
              },
              "example": {
                "preferences": "No pork. Peanut allergy (severe). Loves Thai and Mexican; weeknights ≤30 min."
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Preferences saved",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "action_type": {
                          "type": "string",
                          "description": "Which preference write was applied."
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/preferences/user_info": {
      "get": {
        "operationId": "getPreferencesUserInfo",
        "summary": "User + preferences context",
        "description": "Basic user info plus whether preferences are set and their text. Requires scope `preferences:read`.",
        "tags": [
          "Preferences"
        ],
        "responses": {
          "200": {
            "description": "User info + preferences",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "user": {
                          "type": "object",
                          "properties": {
                            "id": {
                              "type": "integer"
                            },
                            "username": {
                              "type": "string"
                            },
                            "preferences": {
                              "type": [
                                "string",
                                "null"
                              ]
                            },
                            "has_preferences": {
                              "type": "boolean"
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/preferences/dials": {
      "get": {
        "operationId": "getPreferenceDials",
        "summary": "Household preference dials (key → 0–100)",
        "description": "Structured dials the household has set, as an array of `{key, value, updated_at}` objects (0–100 values, e.g. `servings`). Requires scope `preferences:read`.",
        "tags": [
          "Preferences"
        ],
        "responses": {
          "200": {
            "description": "Dials",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "dials": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "key": {
                                "type": "string"
                              },
                              "value": {
                                "type": "integer",
                                "minimum": 0,
                                "maximum": 100
                              },
                              "updated_at": {
                                "type": [
                                  "string",
                                  "null"
                                ],
                                "format": "date-time"
                              }
                            },
                            "required": [
                              "key",
                              "value"
                            ]
                          }
                        },
                        "count": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "put": {
        "operationId": "putPreferenceDials",
        "summary": "Upsert preference dials",
        "description": "Upsert named dials (an array of `{key, value}`; dials not named are left untouched). Returns the full current dial set. Requires scope `preferences:write`.",
        "tags": [
          "Preferences"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "dials": {
                    "type": "array",
                    "minItems": 1,
                    "items": {
                      "type": "object",
                      "properties": {
                        "key": {
                          "type": "string"
                        },
                        "value": {
                          "type": "integer",
                          "minimum": 0,
                          "maximum": 100
                        }
                      },
                      "required": [
                        "key",
                        "value"
                      ]
                    }
                  }
                },
                "required": [
                  "dials"
                ]
              },
              "example": {
                "dials": [
                  {
                    "key": "servings",
                    "value": 4
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Dials saved",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "dials": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "key": {
                                "type": "string"
                              },
                              "value": {
                                "type": "integer",
                                "minimum": 0,
                                "maximum": 100
                              },
                              "updated_at": {
                                "type": [
                                  "string",
                                  "null"
                                ],
                                "format": "date-time"
                              }
                            },
                            "required": [
                              "key",
                              "value"
                            ]
                          }
                        },
                        "count": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/preferences/selections": {
      "get": {
        "operationId": "getPreferenceSelections",
        "summary": "Household diet + allergy selections",
        "description": "The household's structured diet and allergy selections (lists of strings). Prefer this over parsing diet/allergy out of the free-text `preferences_md`. Requires scope `preferences:read`.",
        "tags": [
          "Preferences"
        ],
        "responses": {
          "200": {
            "description": "Selections",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "diets": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        },
                        "allergies": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "put": {
        "operationId": "putPreferenceSelections",
        "summary": "Upsert diet + allergy selections",
        "description": "Partial upsert: an omitted field is left untouched, a provided list replaces that field's selections (trimmed, de-duped, capped). Requires scope `preferences:write`.",
        "tags": [
          "Preferences"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "diets": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "allergies": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              },
              "example": {
                "diets": [
                  "Mediterranean"
                ],
                "allergies": [
                  "Peanuts",
                  "Shellfish"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Selections saved",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "diets": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        },
                        "allergies": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/pat/": {
      "get": {
        "operationId": "listTokens",
        "summary": "List personal access tokens",
        "tags": [
          "PAT"
        ],
        "responses": {
          "200": {
            "description": "Token list",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "tokens": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "id": {
                                "type": "integer"
                              },
                              "name": {
                                "type": "string"
                              },
                              "token_prefix": {
                                "type": "string"
                              },
                              "scopes": {
                                "type": "array",
                                "items": {
                                  "type": "string"
                                }
                              },
                              "created_at": {
                                "type": [
                                  "string",
                                  "null"
                                ]
                              },
                              "expires_at": {
                                "type": [
                                  "string",
                                  "null"
                                ],
                                "format": "date-time"
                              },
                              "last_used_at": {
                                "type": [
                                  "string",
                                  "null"
                                ],
                                "format": "date-time"
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createToken",
        "summary": "Create a personal access token",
        "tags": [
          "PAT"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "expires_in_days": {
                    "type": "integer"
                  },
                  "scopes": {
                    "type": "array",
                    "description": "Grant only what the agent needs. `recommendations:*` covers Discover + submitting web finds (`online-match`); `events:read` + `webhooks:write` cover the event stream and webhook subscriptions. `feedback:write` covers filing feedback and +1s. Omit to grant the advertised default set (not `follows:*` — those verbs are not shipped).",
                    "items": {
                      "type": "string",
                      "enum": [
                        "recipes:read",
                        "recipes:write",
                        "preferences:read",
                        "preferences:write",
                        "plan:read",
                        "plan:write",
                        "cookbook:read",
                        "cookbook:write",
                        "shop:read",
                        "shop:write",
                        "recommendations:read",
                        "recommendations:write",
                        "events:read",
                        "webhooks:write",
                        "feedback:write"
                      ]
                    }
                  }
                },
                "required": [
                  "name",
                  "scopes"
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Token created. Save the `token` field — it cannot be retrieved again.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "id": {
                          "type": "integer"
                        },
                        "name": {
                          "type": "string"
                        },
                        "token": {
                          "type": "string",
                          "description": "The secret token — shown ONCE. Store it now; it cannot be retrieved again."
                        },
                        "token_prefix": {
                          "type": "string"
                        },
                        "scopes": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        },
                        "created_at": {
                          "type": [
                            "string",
                            "null"
                          ]
                        },
                        "expires_at": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "format": "date-time"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/pat/{token_id}": {
      "delete": {
        "operationId": "revokeToken",
        "summary": "Revoke a personal access token",
        "tags": [
          "PAT"
        ],
        "parameters": [
          {
            "name": "token_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Token revoked",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {},
                      "description": "Empty on success."
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/plan/": {
      "get": {
        "operationId": "getMealPlanRoot",
        "summary": "The caller's meal plan (alias of /api/plan/meal_plan/me)",
        "description": "Thin alias: identical to `GET /api/plan/meal_plan/me` for the authenticated caller. Requires scope `plan:read`. Requires authentication: an unauthenticated `/me` call is **401** `UNAUTHORIZED`, not 404.",
        "tags": [
          "Meal Plan"
        ],
        "responses": {
          "200": {
            "description": "Meal plan recipes",
            "headers": {
              "X-Last-Modified": {
                "$ref": "#/components/headers/X-Last-Modified"
              },
              "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": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "recipes": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/MealPlanRecipe"
                          }
                        },
                        "username": {
                          "type": [
                            "string",
                            "null"
                          ]
                        },
                        "is_own_plan": {
                          "type": "boolean"
                        },
                        "current_user": {
                          "type": [
                            "object",
                            "null"
                          ],
                          "description": "The authenticated caller. `id` is an integer, matching whoami's `user_id`.",
                          "properties": {
                            "id": {
                              "type": "integer"
                            },
                            "username": {
                              "type": [
                                "string",
                                "null"
                              ]
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/recipe/discover": {
      "get": {
        "operationId": "discoverRecipes",
        "summary": "Ranked discovery over the public canonical corpus",
        "description": "Rank public canonical recipes by real-world signal (saves, cooks, ratings) blended with a collaborative-filtering boost for the requesting household. Retrieval is SmartPlate's; the choosing is yours. An empty result carries `empty_reason` + `hint` instead of a silent []. Requires scope `recipes:read`.",
        "tags": [
          "Recipes"
        ],
        "parameters": [
          {
            "name": "query",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Optional text filter (`q` is an accepted alias)"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 20,
              "minimum": 1,
              "maximum": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Ranked results; when empty, `empty_reason` and `hint` say why and what to try next",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "results": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/DiscoverItem"
                          }
                        },
                        "count": {
                          "type": "integer"
                        },
                        "empty_reason": {
                          "type": "string",
                          "enum": [
                            "no_matches",
                            "thin_corpus"
                          ],
                          "description": "Present only when `results` is empty: why (query matched nothing vs. the corpus itself is thin)."
                        },
                        "hint": {
                          "type": "string",
                          "description": "Present only when `results` is empty: a next call to try (e.g. GET /api/recipe/popular, GET /api/recommendations/shelves, or drop `query`)."
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/recipe/popular": {
      "get": {
        "operationId": "getPopularRecipes",
        "summary": "Top-rated public recipes (no auth required)",
        "description": "Deterministic popularity over the public corpus, ranked by average rating across households. No auth required; a PAT caller still needs scope `recipes:read`.",
        "tags": [
          "Recipes"
        ],
        "security": [],
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 10,
              "minimum": 1,
              "maximum": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Ranked popular recipes",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "results": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/PopularItem"
                          }
                        },
                        "count": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Non-integer or otherwise invalid `limit`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/recipe/{recipe_id}/photos": {
      "get": {
        "operationId": "listRecipePhotos",
        "summary": "List a recipe's photos + the viewer's lead photo",
        "description": "The recipe's community gallery. A recipe is canonical and shared, so photos are per-household rather than owner-only: `photos` carries every household's photo, and `primary` is the lead chosen **for this viewer** (their own most-recent → the originator's → the earliest community photo), so two households can see different leads over the same gallery. Suppressed photos are excluded. Readable anonymously.",
        "tags": [
          "Recipe"
        ],
        "parameters": [
          {
            "name": "recipe_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The gallery",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "primary": {
                          "oneOf": [
                            {
                              "$ref": "#/components/schemas/RecipePhoto"
                            },
                            {
                              "type": "null"
                            }
                          ],
                          "description": "The viewer's lead photo, or null if there are none."
                        },
                        "photos": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/RecipePhoto"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "addRecipePhoto",
        "summary": "Add the household's own photo to a recipe",
        "description": "Upload a photo of the household's own cooked dish. **Not owner-gated** — any authenticated household may add its own photo to a recipe it can see. Capped at 20 photos per household per recipe; a 21st is a `409`.",
        "tags": [
          "Recipe"
        ],
        "parameters": [
          {
            "name": "recipe_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "properties": {
                  "image": {
                    "type": "string",
                    "format": "binary",
                    "description": "The image file."
                  }
                },
                "required": [
                  "image"
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Photo added",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/RecipePhoto"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "No image uploaded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "409": {
            "description": "This household already has 20 photos on this recipe",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/recipe/{recipe_id}/photos/{photo_id}": {
      "delete": {
        "operationId": "deleteRecipePhoto",
        "summary": "Delete one of your household's own photos",
        "description": "**Own photos only** — deleting another household's is a `403`. Abusive photos from other households are removed by an admin after a report, not by you.",
        "tags": [
          "Recipe"
        ],
        "parameters": [
          {
            "name": "recipe_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "photo_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Photo deleted",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "id": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "Not this household's photo",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "404": {
            "description": "No such photo",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/recipe/{recipe_id}/photos/{photo_id}/report": {
      "post": {
        "operationId": "reportRecipePhoto",
        "summary": "Report a photo (hides it immediately)",
        "description": "Any authenticated household may report a photo on this recipe. The report **auto-suppresses the photo everywhere at once** and queues it for admin review — imagery is hidden first and judged after. `404` if the photo does not exist *or belongs to a different recipe*: photo ids are sequential and publicly visible, so the path binds the two.",
        "tags": [
          "Recipe"
        ],
        "parameters": [
          {
            "name": "recipe_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "photo_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "reason": {
                    "type": "string",
                    "description": "Optional free-text reason for the report."
                  }
                }
              },
              "example": {
                "reason": "Not a photo of this dish"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Photo reported and hidden pending review",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "id": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "No such photo on this recipe",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/shop/batch": {
      "post": {
        "operationId": "batchWriteShoppingList",
        "summary": "Replace or extend the shopping list in one call",
        "description": "The bulk sibling of `/api/shop/manual_add`, and the **overwrite** path: `clear_existing: true` replaces the whole list, otherwise the items are appended. At most 200 items per request (and at least one); a longer or empty list is rejected with `400`.",
        "tags": [
          "Shopping List"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "items": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 200,
                    "items": {
                      "type": "object",
                      "properties": {
                        "name": {
                          "type": "string"
                        },
                        "quantity": {
                          "type": [
                            "string",
                            "null"
                          ]
                        },
                        "unit": {
                          "type": [
                            "string",
                            "null"
                          ]
                        },
                        "checked": {
                          "type": "boolean",
                          "default": false
                        }
                      },
                      "required": [
                        "name"
                      ]
                    }
                  },
                  "clear_existing": {
                    "type": "boolean",
                    "default": false,
                    "description": "Replace the whole list instead of appending."
                  }
                },
                "required": [
                  "items"
                ]
              },
              "example": {
                "items": [
                  {
                    "name": "olive oil",
                    "quantity": "1",
                    "unit": "bottle"
                  },
                  {
                    "name": "garlic",
                    "quantity": "2",
                    "unit": "heads"
                  }
                ],
                "clear_existing": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Items written",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "created": {
                          "type": "integer",
                          "description": "How many items were written."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Empty list, or more than 200 items",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          }
        }
      }
    },
    "/api/openapi.json": {
      "get": {
        "operationId": "getOpenApiSpec",
        "summary": "This spec",
        "description": "The machine-readable contract, served unauthenticated so an agent can read it before it has a token. Listed here so the spec describes its own discovery surface.",
        "tags": [
          "Agent"
        ],
        "security": [],
        "responses": {
          "200": {
            "description": "The OpenAPI document",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/api/feedback": {
      "post": {
        "operationId": "fileFeedback",
        "summary": "File a structured feedback ticket",
        "description": "The agent form of the public feedback ledger (one ledger, two forms — the web widget writes the same table). Requires scope `feedback:write`; supports `Idempotency-Key`. When an existing live item has the same category+endpoint+title identity, responds 409 DUPLICATE_SUSPECTED with `error.details.looks_like` and a hint — POST /api/feedback/{item_id}/plus-one to +1 it, or retry with `force: true` if it is genuinely different. You'll hear back: state changes emit `feedback.*` events on the event stream, `notify_email` gets a plain-email announcement, and the public board is always a GET away.",
        "tags": [
          "Feedback"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/FeedbackCreateRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The filed ticket (state `new`).",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/FeedbackOwnItem"
                    }
                  }
                }
              }
            }
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            },
            "description": "DUPLICATE_SUSPECTED — an existing live item looks the same; +1 it or retry with force=true."
          }
        }
      }
    },
    "/api/feedback/board": {
      "get": {
        "operationId": "getFeedbackBoard",
        "summary": "The public feedback board (no auth)",
        "description": "The transparent ledger: open items sort paying-households-first, then by +1 count, then recency; completed items stay on the board with their disposition notes and PR links as a changelog. Identity-safe rows only. Rate-limited per IP.",
        "tags": [
          "Feedback"
        ],
        "security": [],
        "parameters": [
          {
            "name": "state",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "new",
                "triaged",
                "accepted",
                "in_progress",
                "shipped",
                "closed",
                "duplicate",
                "needs_clarification",
                "declined"
              ]
            }
          },
          {
            "name": "category",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "bug",
                "friction",
                "capability-gap",
                "docs"
              ]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 50,
              "maximum": 100
            }
          },
          {
            "name": "before_id",
            "in": "query",
            "schema": {
              "type": "integer"
            },
            "description": "Pagination cursor: return items with id below this."
          }
        ],
        "responses": {
          "200": {
            "description": "Board rows, paid-first.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "items": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/FeedbackBoardItem"
                          }
                        },
                        "count": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/feedback/mine": {
      "get": {
        "operationId": "getMyFeedback",
        "summary": "Your own feedback items (full shape)",
        "description": "Every item this household filed, including the private fields the public board withholds. Requires scope `feedback:write` (the filing identity reads its own filings).",
        "tags": [
          "Feedback"
        ],
        "responses": {
          "200": {
            "description": "Your items, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "items": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/FeedbackOwnItem"
                          }
                        },
                        "count": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/feedback/{item_id}": {
      "get": {
        "operationId": "getFeedbackItem",
        "summary": "One public feedback item (no auth)",
        "description": "The identity-safe public view with full disposition history — the poll floor for tracking a ticket you filed.",
        "tags": [
          "Feedback"
        ],
        "security": [],
        "parameters": [
          {
            "name": "item_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The item.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/FeedbackBoardItem"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            },
            "description": "Unknown item."
          }
        }
      }
    },
    "/api/feedback/{item_id}/plus-one": {
      "post": {
        "operationId": "plusOneFeedback",
        "summary": "+1 an existing feedback item",
        "description": "The right write when filing says \"looks like #N\". One per household — a repeat returns the unchanged count. Requires scope `feedback:write`.",
        "tags": [
          "Feedback"
        ],
        "parameters": [
          {
            "name": "item_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The updated demand count.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    }
                  ],
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "id": {
                          "type": "integer"
                        },
                        "plus_one_count": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            },
            "description": "Unknown item."
          }
        }
      }
    }
  }
}
