{
  "openapi": "3.1.0",
  "info": {
    "title": "EaseClaw API",
    "version": "1.2.1",
    "description": "EaseClaw is the AI agent for LinkedIn outreach. It finds warm buyers, drafts invites and follow-ups in the owner's voice, and runs the campaigns that book meetings, inside the limits the account owner sets. This API has NO endpoint that approves or sends: every invite and message leaves only after the owner approves it, or inside their campaign's own autopilot limits, and public replies are always posted by a human. Shaping a campaign's playbook (the sequence path) only decides what it will say when that moment comes. Campaign paths require a key minted with the outreach scope; the lead and draft paths work with any key. Same key works on the MCP server at https://www.easeclaw.com/api/mcp (streamable HTTP). Get a key at https://www.easeclaw.com/leads/api.",
    "contact": { "email": "support@easeclaw.com" }
  },
  "servers": [{ "url": "https://www.easeclaw.com" }],
  "security": [{ "bearerAuth": [] }],
  "paths": {
    "/api/v1/projects": {
      "get": {
        "summary": "List the businesses the account's agent works for",
        "description": "Each project's website, the buyer-intent keywords being watched, and the sources the agent covers.",
        "operationId": "listProjects",
        "responses": {
          "200": {
            "description": "The account's projects",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "projects": { "type": "array", "items": { "$ref": "#/components/schemas/Project" } } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Error" },
          "402": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/leads": {
      "get": {
        "summary": "List warm leads and the people your agent found, best first",
        "description": "Two lists, each ordered by score descending, plus a summary counting both. `people` are the people the agent found for its outreach campaigns (name, title, company, score, why they fit, the drafted invitation and first message, and their state in the sequence); by default rejected, stopped and failed people are left out, and an explicit `state` reaches them. `leads` are post-leads: default view (no min_score) is post-leads scored >= 40 by buying intent PLUS all LinkedIn person-leads (commenters, surfaced from score 15 with a signal label). Post bodies are truncated to 500 chars in list results. Fetch a single lead or person for the full record. Read only.",
        "operationId": "listLeads",
        "parameters": [
          { "name": "project_id", "in": "query", "schema": { "type": "string", "format": "uuid" } },
          { "name": "status", "in": "query", "schema": { "$ref": "#/components/schemas/LeadStatus" } },
          { "name": "min_score", "in": "query", "description": "Minimum score filter. For `leads` it applies uniformly to ALL leads (clamped to >= 40); OMIT it to get the default view: post-leads scoring >= 40 plus all LinkedIn person-leads (commenters, surfaced from score 15 with a signal label). Passing any value, including 40, excludes person-leads below it. For `people` it applies as given.", "schema": { "type": "integer", "minimum": 0, "maximum": 100 } },
          { "name": "since", "in": "query", "description": "ISO 8601 timestamp: only leads found after this moment", "schema": { "type": "string", "format": "date-time" } },
          { "name": "limit", "in": "query", "description": "Max results per list", "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 } },
          { "name": "campaign_id", "in": "query", "description": "Only people from this outreach campaign (filters `people`)", "schema": { "type": "string", "format": "uuid" } },
          { "name": "state", "in": "query", "description": "Only people in this state (filters `people`). Default: everyone except rejected, stopped and failed.", "schema": { "$ref": "#/components/schemas/ProspectState" } }
        ],
        "responses": {
          "200": {
            "description": "Scored leads and the people the agent found",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "leads": { "type": "array", "items": { "$ref": "#/components/schemas/Lead" } },
                    "people": { "type": "array", "items": { "$ref": "#/components/schemas/AgentPerson" } },
                    "summary": { "type": "object", "properties": { "leads": { "type": "integer" }, "people": { "type": "integer" } } }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/Error" },
          "401": { "$ref": "#/components/responses/Error" },
          "402": { "$ref": "#/components/responses/Error" },
          "404": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/leads/{id}": {
      "get": {
        "summary": "One lead in full (complete post body + draft), or one person your agent found",
        "description": "Answers `{ lead }` when the id is a lead, or `{ person }` when it is a person the agent found for an outreach campaign.",
        "operationId": "getLead",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
        "responses": {
          "200": {
            "description": "The lead, or the person",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "lead": { "$ref": "#/components/schemas/Lead" }, "person": { "$ref": "#/components/schemas/AgentPerson" } } } } }
          },
          "401": { "$ref": "#/components/responses/Error" },
          "402": { "$ref": "#/components/responses/Error" },
          "404": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "patch": {
        "summary": "Update a lead's pipeline status and/or write back an edited draft",
        "description": "Marking a lead 'contacted' records that the human sent the reply. This endpoint never posts anything itself.",
        "operationId": "updateLead",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "status": { "$ref": "#/components/schemas/LeadStatus" },
                  "draft_reply": { "type": "string", "maxLength": 4000 }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The applied patch",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "lead": { "type": "object" } } } } }
          },
          "400": { "$ref": "#/components/responses/Error" },
          "401": { "$ref": "#/components/responses/Error" },
          "402": { "$ref": "#/components/responses/Error" },
          "404": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/leads/{id}/draft": {
      "post": {
        "summary": "Generate a reply draft in the owner's voice",
        "description": "Costs AI budget: drafting is capped by the plan's per-account daily budget and rate-limited to 30/hour. On 429 with code budget_reached, the Retry-After header says when the budget resets (midnight UTC).",
        "operationId": "draftLead",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "tone": { "type": "string", "maxLength": 60, "description": "e.g. \"friendly\", \"concise and technical\"" },
                  "length": { "type": "string", "enum": ["short", "medium", "long"] },
                  "note": { "type": "string", "maxLength": 300, "description": "revision instruction, e.g. \"mention pricing\"" },
                  "n": { "type": "integer", "minimum": 1, "maximum": 3, "default": 1, "description": "number of variants; each costs budget" }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The draft (first variant is persisted to the lead)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "draft_reply": { "type": "string" },
                    "replies": { "type": "array", "items": { "type": "string" } },
                    "draft_status": { "type": "string", "enum": ["ready"] }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Error" },
          "402": { "$ref": "#/components/responses/Error" },
          "404": { "$ref": "#/components/responses/Error" },
          "409": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "502": { "$ref": "#/components/responses/Error" },
          "503": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/api/v1/activity": {
      "get": {
        "summary": "Recent runs by the account's agent",
        "description": "When it last looked, which sources it covered, how many posts it screened, and how many qualified as leads.",
        "operationId": "getActivity",
        "parameters": [
          { "name": "project_id", "in": "query", "schema": { "type": "string", "format": "uuid" } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 50, "default": 10 } }
        ],
        "responses": {
          "200": {
            "description": "Scan runs, newest first",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "runs": { "type": "array", "items": { "$ref": "#/components/schemas/ScanRun" } } } } } }
          },
          "401": { "$ref": "#/components/responses/Error" },
          "402": { "$ref": "#/components/responses/Error" },
          "404": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/campaigns": {
      "get": {
        "summary": "List outreach campaigns with their funnel counts",
        "description": "Every campaign on the account, each with live counts: queued, awaiting approval, invited, connected, replied, meetings. Requires an outreach-scope API key (Pro plan or higher).",
        "operationId": "listCampaigns",
        "responses": {
          "200": {
            "description": "The account's campaigns",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "campaigns": {
                      "type": "array",
                      "items": {
                        "allOf": [
                          { "$ref": "#/components/schemas/Campaign" },
                          { "type": "object", "properties": { "stats": { "oneOf": [{ "$ref": "#/components/schemas/CampaignStats" }, { "type": "null" }] } } }
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Error" },
          "402": { "$ref": "#/components/responses/Error" },
          "403": { "$ref": "#/components/responses/ScopeRequired" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/Error" }
        }
      },
      "post": {
        "summary": "Create an outreach campaign",
        "description": "Creates a campaign against a project the caller owns: who it reaches, what the invite note and first message say, and whether every batch waits for the owner (copilot) or the campaign runs inside the limits they set (autopilot). Creating a campaign sends nothing. People it later collects wait in a review queue. Rate-limited to 20/hour per account, shared with the dashboard. Requires an outreach-scope API key (Pro plan or higher).",
        "operationId": "createCampaign",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["project_id", "name"],
                "properties": {
                  "project_id": { "type": "string", "format": "uuid", "description": "The project this campaign belongs to" },
                  "name": { "type": "string", "maxLength": 120 },
                  "source_type": { "$ref": "#/components/schemas/CampaignSourceType" },
                  "source_url": { "type": "string", "description": "competitor_harvest: the LinkedIn company page, profile or post to mine. sales_navigator/recruiter: the saved search URL, stored byte for byte." },
                  "source_params": { "type": "object", "description": "basic_search only: the search filters to re-run. Required for that kind." },
                  "signal_min_score": { "type": "integer", "minimum": 15, "maximum": 100, "description": "signal campaigns: the intent bar a person with an intent signal must clear to be fed in; engagers are admitted on ICP fit instead" },
                  "icp": { "type": "object", "description": "This campaign's own targeting. Omit to inherit the project's ICP unchanged." },
                  "mode": { "$ref": "#/components/schemas/CampaignMode" },
                  "invite_note_mode": { "type": "string", "enum": ["none", "custom"], "default": "none" },
                  "invite_note_template": { "type": "string", "maxLength": 300 },
                  "first_message_template": { "type": "string", "description": "The post-acceptance first message. {{first_name}} and {{meeting_link}} substitute at approval." },
                  "meeting_link": { "type": "string", "format": "uri", "description": "https booking link that {{meeting_link}} substitutes" },
                  "accept_wait_days": { "type": "integer", "minimum": 3, "maximum": 45 },
                  "daily_quota": { "type": "integer", "description": "Cap on how many of this campaign's people move per day" }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The created campaign",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "campaign": { "$ref": "#/components/schemas/Campaign" },
                    "source": { "type": ["object", "null"], "description": "How the campaign's source was parsed, when it has one" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/Error" },
          "401": { "$ref": "#/components/responses/Error" },
          "402": { "$ref": "#/components/responses/Error" },
          "403": { "$ref": "#/components/responses/ScopeRequired" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/api/v1/campaigns/{id}": {
      "get": {
        "summary": "One campaign in full, with a page of its people",
        "description": "Its settings, funnel counts, where its people came from, and a page of prospects carrying each person's drafted invite note and first message. Filter by state to see exactly what is waiting on a decision. Requires an outreach-scope API key (Pro plan or higher).",
        "operationId": "getCampaign",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } },
          { "name": "state", "in": "query", "description": "Only prospects in this state (default: all). An unknown state is a 400, never a silent empty list.", "schema": { "$ref": "#/components/schemas/ProspectState" } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 } },
          { "name": "offset", "in": "query", "schema": { "type": "integer", "minimum": 0, "default": 0 } }
        ],
        "responses": {
          "200": {
            "description": "The campaign, its counts, and one page of prospects",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "campaign": { "$ref": "#/components/schemas/Campaign" },
                    "stats": { "oneOf": [{ "$ref": "#/components/schemas/CampaignStats" }, { "type": "null" }] },
                    "sourceStats": { "type": ["array", "null"], "description": "The same counts grouped by where each person came from. NULL means the view could not be read, distinct from an empty list.", "items": { "type": "object" } },
                    "prospects": { "type": "array", "items": { "$ref": "#/components/schemas/CampaignProspect" } }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/Error" },
          "401": { "$ref": "#/components/responses/Error" },
          "402": { "$ref": "#/components/responses/Error" },
          "403": { "$ref": "#/components/responses/ScopeRequired" },
          "404": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/Error" }
        }
      },
      "patch": {
        "summary": "Change a campaign's settings",
        "description": "Its targeting, pacing, handoff, booking link, or who approves. Message text and the waits between steps are NOT settings. They belong to the campaign's playbook, whose single writer is the playbook builder, so invite_note_mode, invite_note_template, first_message_template and accept_wait_days are refused here. The campaign's status, project and source are not editable here either. Use the stop endpoint to stop it. A finished campaign answers 409: its settings are the record of what it ran with. Rate-limited to 60/hour per account, shared with the dashboard. Requires an outreach-scope API key (Pro plan or higher).",
        "operationId": "updateCampaign",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": { "type": "string", "maxLength": 120 },
                  "mode": { "$ref": "#/components/schemas/CampaignMode" },
                  "followup_wait_days": { "type": "integer", "minimum": 1, "maximum": 14 },
                  "withdraw_after_days": { "type": ["integer", "null"], "minimum": 7, "maximum": 45, "description": "Withdraw invitations unanswered this long. null turns auto-withdraw off." },
                  "email_handoff_enabled": { "type": "boolean" },
                  "email_handoff_campaign_id": { "type": ["string", "null"] },
                  "meeting_link": { "type": ["string", "null"], "format": "uri" },
                  "signal_min_score": { "type": "integer", "minimum": 15, "maximum": 100 },
                  "icp": { "type": ["object", "null"], "description": "null resets targeting to the project's ICP" },
                  "goal": { "type": ["string", "null"], "enum": ["meeting", "reply", "share_link", null], "description": "What the AI writes toward. null = not set." },
                  "tone": { "type": ["string", "null"], "enum": ["friendly", "professional", "direct", "casual", null], "description": "null = not set." },
                  "language": { "type": ["string", "null"], "enum": ["en-US", "en-GB", "fr", "de", "es", "nl", null], "description": "The language the AI writes in. null = follow the prospect's own language." },
                  "exclude_first_degree": { "type": "boolean", "description": "Skip people you're already connected with on LinkedIn." },
                  "split_messages": { "type": "boolean", "description": "Stored only. Not acted on yet." },
                  "email_fallback_days": { "type": ["integer", "null"], "minimum": 1, "maximum": 30, "description": "Stored only. null = off. The playbook's waits decide when an email step runs." }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The campaign as it now stands",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "campaign": { "$ref": "#/components/schemas/Campaign" } } } } }
          },
          "400": { "$ref": "#/components/responses/Error" },
          "401": { "$ref": "#/components/responses/Error" },
          "402": { "$ref": "#/components/responses/Error" },
          "403": { "$ref": "#/components/responses/ScopeRequired" },
          "404": { "$ref": "#/components/responses/Error" },
          "409": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/api/v1/campaigns/{id}/sequence": {
      "get": {
        "summary": "One campaign's playbook",
        "description": "The steps the campaign runs, in order: the invitation, the wait for an acceptance, the messages that follow, the branches between them, with the exact text each step carries. This is where what a campaign SAYS lives; the campaign object carries the settings around it. A finished campaign's playbook stays readable: it is the record of what it ran with. Requires an outreach-scope API key (Pro plan or higher).",
        "operationId": "getSequence",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
        "responses": {
          "200": {
            "description": "The playbook as the builder draws it",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "graph": { "$ref": "#/components/schemas/SequenceGraph" },
                    "step_count": { "type": "integer" },
                    "newest_step_at": { "type": ["string", "null"], "format": "date-time", "description": "When the newest step was CREATED. Not a last-edited stamp: editing a step's text moves nothing here." }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Error" },
          "402": { "$ref": "#/components/responses/Error" },
          "403": { "$ref": "#/components/responses/ScopeRequired" },
          "404": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/Error" }
        }
      },
      "put": {
        "summary": "Replace a campaign's playbook",
        "description": "Replaces the WHOLE playbook with the graph posted. Read the current one first and post it back changed; this does not merge. Shaping a playbook configures what the campaign will send once the owner approves it or their autopilot limits allow it: it approves nothing, sends nothing, and recalls nobody already queued. A graph that could not run answers 422 carrying `issues`, one per fault, in the validator's stable order. A finished campaign answers 409 (its playbook is the record of what it ran with), and so does an edit that removes a step people are currently standing on. Rate-limited to 60/hour per account, shared with the dashboard's builder. Requires an outreach-scope API key (Pro plan or higher).",
        "operationId": "setSequence",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["graph"],
                "properties": { "graph": { "$ref": "#/components/schemas/SequenceGraph" } }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The saved playbook, its server-side ids, and any warnings it still carries",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "graph": { "$ref": "#/components/schemas/SequenceGraph" },
                    "step_count": { "type": "integer" },
                    "newest_step_at": { "type": ["string", "null"], "format": "date-time" },
                    "ids": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Posted node id → the id it now has on the server. Adopt these rather than re-posting your own." },
                    "issues": { "type": "array", "items": { "$ref": "#/components/schemas/GraphIssue" }, "description": "Warnings only: a save that landed can still name people the engine will hold back." }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/Error" },
          "401": { "$ref": "#/components/responses/Error" },
          "402": { "$ref": "#/components/responses/Error" },
          "403": { "$ref": "#/components/responses/ScopeRequired" },
          "404": { "$ref": "#/components/responses/Error" },
          "409": { "$ref": "#/components/responses/Error" },
          "422": {
            "description": "The request is well-formed but the SEQUENCE is not runnable (code invalid_graph). `issues` carries every error and every warning at once, each naming the fault and its fix, so the whole graph can be repaired in one pass.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": { "type": "string" },
                    "code": { "type": "string", "enum": ["invalid_graph"] },
                    "issues": { "type": "array", "items": { "$ref": "#/components/schemas/GraphIssue" } }
                  },
                  "required": ["error", "issues"]
                }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/api/v1/campaigns/{id}/stop": {
      "post": {
        "summary": "Stop a campaign and recall everything still queued",
        "description": "The off switch: stops the campaign and pulls back everything queued, including messages already approved but not yet sent. Idempotent. A second call moves nothing and answers zeros. Never rate-limited. Requires an outreach-scope API key (Pro plan or higher).",
        "operationId": "stopCampaign",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
        "responses": {
          "200": {
            "description": "What the stop moved",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "stopped_prospects": { "type": "integer" },
                    "cancelled_jobs": { "type": "integer" }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Error" },
          "402": { "$ref": "#/components/responses/Error" },
          "403": { "$ref": "#/components/responses/ScopeRequired" },
          "404": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/api/v1/campaigns/{id}/prospects": {
      "post": {
        "summary": "Put one lead's author into a campaign",
        "description": "One lead per call, deliberately. This is the lane for a person somebody individually decided about. The person lands in the review queue; nothing goes out until the owner approves them or their autopilot limits allow it. Only works when the lead resolves to a real LinkedIn profile. `added: false` with a `code` is a 200 and a success: a dedupe rule (already in a campaign, already contacted) did its job. Rate-limited to 200/hour per account, shared with the dashboard. Requires an outreach-scope API key (Pro plan or higher).",
        "operationId": "addProspect",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["lead_id"],
                "properties": { "lead_id": { "type": "string", "format": "uuid", "description": "The lead whose author to add" } }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The person was added and is awaiting approval",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AddProspectResult" } } }
          },
          "200": {
            "description": "A dedupe rule skipped them. `code` says which",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AddProspectResult" } } }
          },
          "400": { "$ref": "#/components/responses/Error" },
          "401": { "$ref": "#/components/responses/Error" },
          "402": { "$ref": "#/components/responses/Error" },
          "403": { "$ref": "#/components/responses/ScopeRequired" },
          "404": { "$ref": "#/components/responses/Error" },
          "422": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/api/v1/campaigns/{id}/prospects/{pid}": {
      "patch": {
        "summary": "Work one person in a campaign",
        "description": "Three instructions, never two at once: edit exactly what will go out for this person (null hands them back to the campaign template), record that a meeting was booked once their first message has gone out, or reject them before anything is committed (undoable while they have not been contacted). Editing a draft never sends it, the owner's approval, or their autopilot limits, is still the only door out. 409 when the batch was already approved. Rate-limited to 300/hour per account, shared with the dashboard. Requires an outreach-scope API key (Pro plan or higher).",
        "operationId": "updateProspect",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } },
          { "name": "pid", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "reject": { "type": "boolean", "description": "true rejects this person from the campaign; false undoes that while they have not been contacted (answers {restored: 0|1}). Cannot be combined with the other fields." },
                  "reject_reason": { "type": "string", "enum": ["wrong_title", "company_not_fit", "existing_customer", "competitor", "other"], "description": "Why they are not a fit, with reject: true. Defaults to other." },
                  "reject_reason_text": { "type": "string", "maxLength": 280, "description": "Your own words, only with reject_reason other" },
                  "meeting_booked": { "type": "boolean", "description": "Mark (or un-mark) a booked meeting. Only once their first message has gone out." },
                  "invite_note": { "type": ["string", "null"], "description": "This person's invite note; null restores the campaign template" },
                  "first_message": { "type": ["string", "null"], "description": "This person's first message; null restores the campaign template" }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The prospect as it now stands",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "prospect": { "$ref": "#/components/schemas/CampaignProspect" } } } } }
          },
          "400": { "$ref": "#/components/responses/Error" },
          "401": { "$ref": "#/components/responses/Error" },
          "402": { "$ref": "#/components/responses/Error" },
          "403": { "$ref": "#/components/responses/ScopeRequired" },
          "404": { "$ref": "#/components/responses/Error" },
          "409": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/api/v1/campaigns/{id}/source": {
      "post": {
        "summary": "Fill a campaign with people",
        "description": "Mine a competitor's audience, resolve a pasted list of profile URLs, import CSV rows, or re-run the campaign's saved search. `kind` must match how the campaign collects its people. Spends the account's daily data budget and is capped at 10 runs/hour per account, shared with the dashboard. A run that already spent money answers 200 with a partial summary (`summary.stopped` says why it ended); 402 is reserved for a refusal that spent nothing. Nothing sourced here is contacted until it passes the owner's approval or their autopilot limits. Requires an outreach-scope API key (Pro plan or higher).",
        "operationId": "sourceCampaign",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["kind"],
                "properties": {
                  "kind": { "type": "string", "enum": ["competitor_harvest", "profile_urls", "csv", "basic_search"] },
                  "source_url": { "type": "string", "description": "competitor_harvest: a one-off page to mine instead of the campaign's stored source" },
                  "slugs": { "type": "array", "items": { "type": "string" }, "description": "profile_urls: the LinkedIn profile URLs or slugs to resolve" },
                  "rows": {
                    "type": "array",
                    "description": "csv: rows to import. `url` is the only required column",
                    "items": {
                      "type": "object",
                      "required": ["url"],
                      "properties": {
                        "url": { "type": "string" },
                        "fullName": { "type": "string" },
                        "company": { "type": "string" },
                        "position": { "type": "string" }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "What the run found, filtered and queued for review",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "summary": {
                      "type": "object",
                      "description": "Counts for the run: how many people were found, how many the targeting filtered out, how many were added, what it cost, and `stopped` (null when it ran to completion; otherwise why it ended early, e.g. budget).",
                      "properties": {
                        "added": { "type": "integer" },
                        "gated_out": { "type": "integer" },
                        "cost_usd": { "type": "number" },
                        "stopped": { "type": ["string", "null"] }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/Error" },
          "401": { "$ref": "#/components/responses/Error" },
          "402": { "$ref": "#/components/responses/Error" },
          "403": { "$ref": "#/components/responses/ScopeRequired" },
          "404": { "$ref": "#/components/responses/Error" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/Error" },
          "503": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/api/v1/inbox/replies": {
      "get": {
        "summary": "Everyone who wrote back, account-wide",
        "description": "Newest first, each with their campaign, their timeline stamps, and the person's name, headline and profile link. A reply ends that person's sequence automatically. No plan floor. This reads the account's own finished history and costs nothing. Requires an outreach-scope API key.",
        "operationId": "listReplies",
        "parameters": [
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 100 } },
          { "name": "offset", "in": "query", "schema": { "type": "integer", "minimum": 0, "default": 0 } }
        ],
        "responses": {
          "200": {
            "description": "One page of replies",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "replies": { "type": "array", "items": { "$ref": "#/components/schemas/InboxReply" } },
                    "total": { "type": "integer" },
                    "limit": { "type": "integer" },
                    "offset": { "type": "integer" },
                    "hasMore": { "type": "boolean" },
                    "nextOffset": { "type": ["integer", "null"] }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Error" },
          "402": { "$ref": "#/components/responses/Error" },
          "403": { "$ref": "#/components/responses/ScopeRequired" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/Error" }
        }
      }
    },
    "/api/v1/senders": {
      "get": {
        "summary": "Whether a LinkedIn sender is connected",
        "description": "Read-only status of the account's LinkedIn sender. Its state and when it last reported in. Campaigns can be built, filled and edited without one; approved outreach only leaves once the owner connects a sender in their dashboard. Connecting and disconnecting stay human: there is no endpoint here that does either. No plan floor. Requires an outreach-scope API key.",
        "operationId": "getSenderStatus",
        "responses": {
          "200": {
            "description": "The sender's status",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "sender": {
                      "type": "object",
                      "properties": {
                        "status": { "type": "string", "enum": ["not_connected", "connecting", "connected", "checkpoint", "disconnected"] },
                        "country": { "type": ["string", "null"] },
                        "connectedAt": { "type": ["string", "null"], "format": "date-time" },
                        "lastEventAt": { "type": ["string", "null"], "format": "date-time" },
                        "stale": { "type": "boolean", "description": "True when a sign-in has sat in 'connecting' for 30+ minutes with nothing heard back: it is not going to land, start it again" }
                      }
                    },
                    "available": { "type": "boolean", "description": "Whether the cloud sending path is configured at all" }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Error" },
          "402": { "$ref": "#/components/responses/Error" },
          "403": { "$ref": "#/components/responses/ScopeRequired" },
          "500": { "$ref": "#/components/responses/Error" },
          "503": { "$ref": "#/components/responses/Error" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "API key from https://www.easeclaw.com/leads/api, format ec_live_ + 64 hex chars. 300 requests/hour per account. A key is minted with one of two scopes: 'leads' (read leads, see projects and activity, draft replies) or 'outreach', which adds the campaign paths. The scope is fixed for the key's life, and keys minted by the claude.ai connector or by pairing the Chrome extension are always 'leads'."
      }
    },
    "schemas": {
      "LeadStatus": { "type": "string", "enum": ["new", "saved", "dismissed", "contacted", "won", "lost"] },
      "CampaignMode": {
        "type": "string",
        "enum": ["autopilot", "copilot"],
        "default": "autopilot",
        "description": "Who approves what goes out: autopilot: the agent approves inside the owner's limits; copilot: every batch waits for the owner. Neither mode is reachable from this API: it decides who approves, and this surface is not either of them."
      },
      "CampaignSourceType": {
        "type": "string",
        "enum": ["competitor_harvest", "signal", "profile_urls", "csv", "basic_search", "sales_navigator", "recruiter", "my_network"],
        "description": "Where a campaign's people come from. Defaults to competitor_harvest."
      },
      "ProspectState": {
        "type": "string",
        "enum": [
          "sourced",
          "qualified",
          "rejected",
          "awaiting_approval",
          "invite_approved",
          "invite_sent",
          "accepted",
          "dm_awaiting_approval",
          "dm_approved",
          "dm_sent",
          "done",
          "stale",
          "withdrawn",
          "stopped",
          "failed"
        ],
        "description": "Where one person stands in a campaign."
      },
      "Campaign": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "project_id": { "type": "string", "format": "uuid" },
          "name": { "type": "string" },
          "source_type": { "$ref": "#/components/schemas/CampaignSourceType" },
          "source_ref": { "type": ["string", "null"], "description": "What this campaign sources from, per source_type. Null for signal campaigns (their source is the project) and for the multi-ref kinds, where provenance is recorded per person instead." },
          "source_params": { "type": ["object", "null"], "description": "basic_search only: the search descriptor it re-runs from" },
          "signal_min_score": { "type": ["integer", "null"] },
          "icp": { "type": ["object", "null"], "description": "This campaign's own targeting; null means it inherits the project's ICP" },
          "status": { "type": "string", "enum": ["draft", "sourcing", "review", "active", "paused", "done", "stopped"] },
          "mode": { "$ref": "#/components/schemas/CampaignMode" },
          "invite_note_mode": { "type": "string", "enum": ["none", "custom"] },
          "invite_note_template": { "type": ["string", "null"] },
          "first_message_template": { "type": ["string", "null"] },
          "accept_wait_days": { "type": "integer" },
          "followup_wait_days": { "type": "integer" },
          "withdraw_after_days": { "type": ["integer", "null"], "description": "null = auto-withdraw off" },
          "email_handoff_enabled": { "type": "boolean" },
          "email_handoff_campaign_id": { "type": ["string", "null"] },
          "meeting_link": { "type": ["string", "null"], "format": "uri" },
          "goal": { "type": ["string", "null"], "enum": ["meeting", "reply", "share_link", null] },
          "tone": { "type": ["string", "null"], "enum": ["friendly", "professional", "direct", "casual", null] },
          "language": { "type": ["string", "null"], "enum": ["en-US", "en-GB", "fr", "de", "es", "nl", null] },
          "exclude_first_degree": { "type": "boolean" },
          "split_messages": { "type": "boolean" },
          "email_fallback_days": { "type": ["integer", "null"], "description": "null = email fallback off" },
          "daily_quota": { "type": ["integer", "null"] },
          "created_at": { "type": "string", "format": "date-time" },
          "updated_at": { "type": "string", "format": "date-time" }
        }
      },
      "SequenceNode": {
        "type": "object",
        "description": "One step of a playbook. `next` is a union, not two optional fields: a branching step carries { yes, no }, everything else carries { default }. A null child is a legitimate end of a path, not a dangling pointer.",
        "properties": {
          "id": { "type": "string", "description": "Client-generated on create, server-stable after save. Use the `ids` map a save returns rather than re-posting your own." },
          "kind": { "type": "string", "description": "Which step this is (an invitation, a wait, a message, a branch, …). The step registry owns the list and may grow it." },
          "config": { "type": "object", "description": "Per-kind settings, validated by the registry." },
          "template": { "type": ["string", "null"], "description": "The text a text-bearing step carries; null on every other kind, and on a no-note invitation." },
          "next": { "type": "object", "description": "{ \"default\": id|null }, or { \"yes\": id|null, \"no\": id|null } on a branching step." }
        }
      },
      "SequenceGraph": {
        "type": "object",
        "description": "A campaign's whole playbook: the steps it runs and how they connect. Posting one REPLACES the playbook. It does not merge.",
        "properties": {
          "version": { "type": "integer", "enum": [1] },
          "root": { "type": ["string", "null"], "description": "The first node, or null for an empty playbook." },
          "nodes": { "type": "object", "additionalProperties": { "$ref": "#/components/schemas/SequenceNode" } }
        }
      },
      "GraphIssue": {
        "type": "object",
        "description": "One thing the validator has to say about a playbook. An `error` refuses the save; a `warning` never does. It names something the engine would hold a person back on later.",
        "properties": {
          "severity": { "type": "string", "enum": ["error", "warning"] },
          "code": { "type": "string", "description": "Stable kebab-case identity. Switch on this; the message is what a person reads." },
          "nodeId": { "type": "string", "description": "The step it belongs to. Absent on issues about the playbook itself." },
          "message": { "type": "string", "description": "A sentence naming the mistake AND the fix." }
        }
      },
      "CampaignStats": {
        "type": "object",
        "description": "The funnel, counted. `replied` and `meetings` are counted off their own timestamps rather than off a state. A reply moves somebody to `done`, and a meeting is marked by a human.",
        "properties": {
          "campaign_id": { "type": "string", "format": "uuid" },
          "prospects": { "type": "integer" },
          "sourced": { "type": "integer" },
          "qualified": { "type": "integer" },
          "rejected": { "type": "integer" },
          "awaiting_approval": { "type": "integer" },
          "invite_approved": { "type": "integer" },
          "invite_sent": { "type": "integer" },
          "accepted": { "type": "integer" },
          "dm_awaiting_approval": { "type": "integer" },
          "dm_approved": { "type": "integer" },
          "dm_sent": { "type": "integer" },
          "done": { "type": "integer" },
          "stale": { "type": "integer" },
          "stopped": { "type": "integer" },
          "failed": { "type": "integer" },
          "withdrawn": { "type": "integer" },
          "replied": { "type": "integer" },
          "handed_off": { "type": "integer" },
          "awaiting_email": { "type": "integer" },
          "meetings": { "type": "integer" },
          "last_activity_at": { "type": ["string", "null"], "format": "date-time" }
        }
      },
      "ContactIdentity": {
        "type": "object",
        "description": "The person, as this surface may see them. Email and phone are deliberately absent.",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "full_name": { "type": ["string", "null"] },
          "headline": { "type": ["string", "null"] },
          "profile_url": { "type": ["string", "null"] },
          "platform_uid": { "type": ["string", "null"] }
        }
      },
      "CampaignProspect": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "state": { "$ref": "#/components/schemas/ProspectState" },
          "score": { "type": ["integer", "null"], "description": "How well this person fits the campaign's targeting" },
          "signal": { "type": ["string", "null"] },
          "intent_score": { "type": ["integer", "null"] },
          "source_meta": { "type": ["object", "null"], "description": "Where this person came from" },
          "invite_note": { "type": ["string", "null"], "description": "What will go out for this person; null means the campaign template" },
          "first_message": { "type": ["string", "null"] },
          "state_changed_at": { "type": ["string", "null"], "format": "date-time" },
          "invite_sent_at": { "type": ["string", "null"], "format": "date-time" },
          "replied_at": { "type": ["string", "null"], "format": "date-time" },
          "email_handoff_at": { "type": ["string", "null"], "format": "date-time" },
          "meeting_at": { "type": ["string", "null"], "format": "date-time", "description": "Marked by a human. There is no calendar integration" },
          "created_at": { "type": "string", "format": "date-time" },
          "contact": { "oneOf": [{ "$ref": "#/components/schemas/ContactIdentity" }, { "type": "null" }] }
        }
      },
      "InboxReply": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "campaign_id": { "type": "string", "format": "uuid" },
          "campaign_name": { "type": "string" },
          "state": { "$ref": "#/components/schemas/ProspectState" },
          "score": { "type": ["integer", "null"] },
          "invite_note": { "type": ["string", "null"] },
          "first_message": { "type": ["string", "null"] },
          "state_changed_at": { "type": ["string", "null"], "format": "date-time" },
          "invite_sent_at": { "type": ["string", "null"], "format": "date-time" },
          "replied_at": { "type": ["string", "null"], "format": "date-time" },
          "email_handoff_at": { "type": ["string", "null"], "format": "date-time" },
          "meeting_at": { "type": ["string", "null"], "format": "date-time" },
          "has_chat_ref": { "type": "boolean", "description": "The reply was matched by an exact conversation id rather than by a fallback. Not a confidence score. A false is still a real reply." },
          "contact": { "oneOf": [{ "$ref": "#/components/schemas/ContactIdentity" }, { "type": "null" }] }
        }
      },
      "AddProspectResult": {
        "type": "object",
        "properties": {
          "added": { "type": "boolean" },
          "skipped": { "type": ["object", "null"], "description": "Which dedupe rules fired, by count" },
          "code": { "type": ["string", "null"], "description": "Null when added. Otherwise the dedupe rule that skipped them." }
        }
      },
      "Lead": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "project_id": { "type": "string", "format": "uuid" },
          "score": { "type": "integer", "minimum": 15, "maximum": 100, "description": "buying-intent score; higher = hotter. Post-leads are always >= 40; LinkedIn person-leads surface from 15." },
          "reason": { "type": ["string", "null"], "description": "why the AI scored it this way" },
          "signal": { "type": ["string", "null"], "enum": ["hand_raise", "question", "pain", "commented", null], "description": "person-leads only (LinkedIn commenters): hand_raise | question | pain | commented; null on post-leads" },
          "draft_reply": { "type": ["string", "null"], "description": "the drafted reply (person-leads: the LinkedIn connect note)" },
          "draft_dm": { "type": ["string", "null"], "description": "person-leads only: the opener DM draft; null on post-leads" },
          "draft_status": { "type": "string", "enum": ["none", "generating", "ready"] },
          "status": { "$ref": "#/components/schemas/LeadStatus" },
          "created_at": { "type": "string", "format": "date-time" },
          "match": {
            "type": ["object", "null"],
            "description": "the source post",
            "properties": {
              "source": { "type": ["string", "null"] },
              "permalink": { "type": ["string", "null"] },
              "title": { "type": ["string", "null"] },
              "body": { "type": ["string", "null"] },
              "author": { "type": ["string", "null"] },
              "posted_at": { "type": ["string", "null"], "format": "date-time" }
            }
          }
        }
      },
      "AgentPerson": {
        "type": "object",
        "description": "A person the agent found for one of the account's outreach campaigns. Carries no email or phone.",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "campaign_id": { "type": "string", "format": "uuid" },
          "campaign_name": { "type": ["string", "null"] },
          "project_id": { "type": ["string", "null"], "format": "uuid" },
          "state": { "$ref": "#/components/schemas/ProspectState" },
          "score": { "type": ["integer", "null"], "minimum": 0, "maximum": 100, "description": "fit score; higher = better fit" },
          "intent_score": { "type": ["integer", "null"] },
          "signal": { "type": ["string", "null"] },
          "why": { "type": ["string", "null"], "description": "why the agent thinks they fit, the line the app shows" },
          "name": { "type": ["string", "null"] },
          "first_name": { "type": ["string", "null"] },
          "headline": { "type": ["string", "null"] },
          "company": { "type": ["string", "null"] },
          "position": { "type": ["string", "null"] },
          "location": { "type": ["string", "null"] },
          "profile_url": { "type": ["string", "null"] },
          "invite_note": { "type": ["string", "null"], "description": "the drafted connection note" },
          "first_message": { "type": ["string", "null"], "description": "the drafted message for after they accept; null when the campaign's template applies" },
          "invite_sent_at": { "type": ["string", "null"], "format": "date-time" },
          "accepted_at": { "type": ["string", "null"], "format": "date-time" },
          "replied_at": { "type": ["string", "null"], "format": "date-time" },
          "meeting_at": { "type": ["string", "null"], "format": "date-time" },
          "created_at": { "type": "string", "format": "date-time" },
          "locked": { "type": "boolean", "description": "true on a trial account for a person the agent has not reached yet; their identifying fields are null" }
        }
      },
      "Project": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "name": { "type": ["string", "null"] },
          "website": { "type": ["string", "null"] },
          "business_summary": { "type": ["string", "null"] },
          "icp": { "type": ["string", "null"] },
          "keywords": { "type": "array", "items": { "type": "string" } },
          "sources": { "type": "array", "items": { "type": "string" } },
          "last_scanned_at": {
            "type": ["string", "null"],
            "format": "date-time",
            "description": "The scan queue's rotation clock: stamped whenever a pass reaches the project, including passes that did no work. Use last_run_at for when the agent last worked."
          },
          "last_run_at": {
            "type": ["string", "null"],
            "format": "date-time",
            "description": "When the agent last actually scanned this project (its newest scan run); null if it has never run. Omitted when the run history could not be read (not the same as null)."
          },
          "created_at": { "type": "string", "format": "date-time" }
        }
      },
      "ScanRun": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "project_id": { "type": "string", "format": "uuid" },
          "ran_at": { "type": "string", "format": "date-time" },
          "lane": { "type": "string", "enum": ["wave", "fast"] },
          "status": { "type": "string", "enum": ["ok", "error", "skipped_spend", "skipped_budget"] },
          "fetched": { "type": "integer" },
          "claimed": { "type": "integer" },
          "survivors": { "type": "integer" },
          "qualified": { "type": "integer" },
          "drafted": { "type": "integer" },
          "sources": { "type": "array", "items": { "type": "string" } }
        }
      }
    },
    "responses": {
      "Error": {
        "description": "Error",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": { "error": { "type": "string" }, "code": { "type": "string" } },
              "required": ["error"]
            }
          }
        }
      },
      "ScopeRequired": {
        "description": "The key is not outreach-scoped (code outreach_scope_required). Campaign paths need a key minted with the outreach scope at https://www.easeclaw.com/leads/api. The scope cannot be widened on an existing key.",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": { "error": { "type": "string" }, "code": { "type": "string", "enum": ["outreach_scope_required"] } },
              "required": ["error"]
            }
          }
        }
      },
      "RateLimited": {
        "description": "Rate limited (300/hour per account; drafting also 30/hour + daily AI budget). Retry-After header set when known.",
        "headers": { "Retry-After": { "schema": { "type": "integer" }, "description": "seconds until the limit resets" } },
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": { "error": { "type": "string" }, "code": { "type": "string" } }
            }
          }
        }
      }
    }
  }
}
