{
  "generatedBy": "tools/build-docs.mjs",
  "source": "MCP tools/list over a stdio transport against mcp/hermoso-mcp.mjs",
  "note": "tools/list on a live connection is always authoritative; this file is a static snapshot of it.",
  "count": 301,
  "tools": [
    {
      "name": "hermoso_capabilities",
      "title": "Hermoso capabilities",
      "description": "Probe what this Hermoso account can do RIGHT NOW: available image/video model ids + their exact credit costs, aspect ratios, video durations, the recipe ids, and the canEdit/canAvatar/canPublish flags. Call this FIRST so you generate with valid model ids and known costs. Read-only, free.",
      "group": "workspace",
      "section": "read-only / discovery",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "hermoso_credits",
      "title": "Credit balance",
      "description": "Return the account credit balance, credits used this session, and recent priced calls. Check before kicking off paid generation.",
      "group": "workspace",
      "section": "read-only / discovery",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "buy_credits",
      "title": "Buy credits",
      "description": "Out of credits? Top up with a credit PACK. Call with no argument to list the available packs (id · credits · price). If the account has a saved card and you have billing-admin rights, calling with `pack` quotes the exact charge and calling again with confirm:true AND the quote's quote_token charges the saved card instantly (same one-click top-up as the app — no redirect). If there's no saved card yet, you get a Stripe checkout URL to hand your human for the FIRST purchase; their card saves for one-click after that. Packs only; subscriptions are managed by a person in Settings → Billing. To stop running out entirely, turn on auto-reload with set_auto_reload (admin) — low balances then top themselves up from the saved card automatically.",
      "group": "workspace",
      "section": "read-only / discovery",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "pack": {
            "description": "the pack id to buy (e.g. pack-2k) — omit to list the available packs first",
            "type": "string"
          },
          "confirm": {
            "description": "set true to actually charge the saved card for `pack` (required for the one-click charge; ignored on the checkout-link path)",
            "type": "boolean"
          },
          "quote_token": {
            "description": "the quoteToken returned by the quote step — REQUIRED (with confirm:true) to charge; it binds the exact pack + price you quoted (10-minute validity) and makes a retried confirm idempotent",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "report_bug",
      "title": "Report a bug",
      "description": "Report a bug in Hermoso to the team. Use this when something in Hermoso genuinely misbehaves — a tool errors unexpectedly, returns a wrong or malformed result, a render comes back broken, or documented behaviour doesn't match what happened. Include what you were trying to do, the exact tool call and arguments, and what came back. Do NOT use it for out-of-credits, a policy refusal, or a missing capability (use request_feature for that). Free, no credits.",
      "group": "workspace",
      "section": "FEEDBACK: let the AGENT report a bug or ask for a capability we don't have",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "summary": {
            "type": "string",
            "description": "one-line summary of the bug"
          },
          "details": {
            "type": "string",
            "description": "what you were doing, the tool + arguments you called, what you expected, and what actually happened (paste the exact error)"
          },
          "severity": {
            "description": "high = blocks the task or loses paid work; medium = wrong output but workable; low = cosmetic",
            "type": "string",
            "enum": [
              "low",
              "medium",
              "high"
            ]
          }
        },
        "required": [
          "summary",
          "details"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "request_feature",
      "title": "Request a feature",
      "description": "Ask the Hermoso team for a capability that doesn't exist yet. Use this when you need something Hermoso genuinely can't do — an unsupported platform or channel, a missing model, an export format, a tool that would have completed the user's task but isn't available. Say what the user was trying to achieve, not just the feature name — the use case is what gets built. Free, no credits.",
      "group": "workspace",
      "section": "FEEDBACK: let the AGENT report a bug or ask for a capability we don't have",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "summary": {
            "type": "string",
            "description": "one line: the capability you need"
          },
          "details": {
            "type": "string",
            "description": "what the user was actually trying to achieve, why the current tools couldn't do it, and what you'd expect the capability to do"
          }
        },
        "required": [
          "summary",
          "details"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "billing_status",
      "title": "Billing status",
      "description": "Show this account's billing at a glance: current plan (id + label + monthly price), credit balance, whether auto-reload is on, whether a card is on file, and whether YOU (this key) have ADMIN rights to change billing. Read-only, free. Call it before upgrade_plan / set_auto_reload to know what's possible — members have read-only billing. IN A SHARED TEAM WORKSPACE A MEMBER SEES THE PLAN AND THE BALANCE ONLY: the workspace owner's payment card and auto-reload belong to them and are not reported (billingScope:'member'). Never tell a member there is no card on file — the honest answer is that you cannot see it.",
      "group": "workspace",
      "section": "FEEDBACK: let the AGENT report a bug or ask for a capability we don't have",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "upgrade_plan",
      "title": "Upgrade plan",
      "description": "Change this account's SUBSCRIPTION plan (admin only). Call with no argument to list the plans (id · monthly price · monthly credits); call again with `plan` set to a plan id. A NEW subscriber gets a ready-to-pay Stripe Checkout URL to hand your human — THEY pay on Stripe (agents never spend money directly). If the account already has a paid plan, or you're DOWNGRADING, the change is made by a person in the app (Settings → Billing) and the tool returns exactly what to do. Members (read-only billing) get an honest 'ask an admin' message. Nothing is charged until your human pays.",
      "group": "workspace",
      "section": "FEEDBACK: let the AGENT report a bug or ask for a capability we don't have",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "plan": {
            "description": "the plan id to move to (e.g. pro) — omit to list the available plans first",
            "type": "string"
          },
          "period": {
            "description": "billing cadence — monthly (default) or yearly (2 months free)",
            "type": "string",
            "enum": [
              "mo",
              "yr"
            ]
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_auto_reload",
      "title": "Set auto-reload",
      "description": "Turn automatic credit reloads on or off (admin only): when the balance drops below a threshold, the card on file is charged for a top-up pack — SERVER-SIDE, even with no app open. Requires a saved card, added once in the app at first checkout/top-up; if there's none the tool tells you exactly where to add it. After that one-time card setup, agents can manage auto-reload, top-ups and plan links fully. Members (read-only billing) get an 'ask an admin' message.",
      "group": "workspace",
      "section": "FEEDBACK: let the AGENT report a bug or ask for a capability we don't have",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "enabled": {
            "type": "boolean",
            "description": "true to turn auto-reload on, false to turn it off"
          },
          "thresholdCredits": {
            "description": "reload when the balance drops below this many credits",
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          },
          "reloadCredits": {
            "description": "how many credits to add each reload — must match a credit pack size (see buy_credits)",
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          }
        },
        "required": [
          "enabled"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_brands",
      "title": "List brands",
      "description": "List every brand on this account (id + name) and which one this connection currently acts on, PLUS any brand another account shared with you (a team workspace). Multi-brand accounts: call this, then use_brand to switch. A SHARED workspace is switched into the SAME way — pass its name or the profile id printed here to use_brand. If a brand looks empty (no connected accounts, no Library) when the app shows it full, you are almost certainly acting on a different workspace: call this first. Read-only, free.",
      "group": "workspace",
      "section": "FEEDBACK: let the AGENT report a bug or ask for a capability we don't have",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "use_brand",
      "title": "Switch brand",
      "description": "Pin which brand this connection acts on (multi-brand accounts). Pass the brand id or exact name from list_brands. Works for a brand on your own account AND for a workspace another account SHARED with you — for a shared one pass its name or the profile id list_brands prints, and access is verified against your real membership before it is pinned. Persists for this API key until changed, on every surface (hosted connector included — no environment variables, no restart).",
      "group": "workspace",
      "section": "FEEDBACK: let the AGENT report a bug or ask for a capability we don't have",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "brand": {
            "type": "string",
            "description": "brand id (e.g. default / p_xxx), its exact name from list_brands, or the profile id of a workspace shared with you"
          }
        },
        "required": [
          "brand"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_brand",
      "title": "Create a brand workspace",
      "description": "Add a NEW brand workspace (a separate brand/client on this account) and switch to it. Each workspace has its OWN brand profile, memory, swipefile, Library, avatars, skills, playbooks and connectors — nothing leaks between them. Use this for a second brand or a new client; use draft_brand to FILL a workspace, and update_brand to edit one. Re-running with the same name returns the existing workspace instead of a duplicate. Free (the ~50-credit research cascade only starts when you then run draft_brand).",
      "group": "workspace",
      "section": "BRAND WORKSPACE LIFECYCLE. draft_brand OVERWRITES the active workspace's brand; it does not mint one — so",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "the brand / client name for the new workspace"
          },
          "activate": {
            "description": "switch this connection to the new brand (default true) — everything you do next scopes to it",
            "type": "boolean"
          }
        },
        "required": [
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_brand",
      "title": "Delete a brand workspace",
      "description": "PERMANENTLY delete a brand workspace and EVERYTHING in it — brand profile, memory, swipefile, Library creations, generated assets, avatars, skills, playbooks, chats — and disconnect its connected accounts. Irreversible, and it applies to everyone the workspace is shared with. Call it WITHOUT confirm first: it reports exactly what that workspace holds. Show the user that inventory verbatim, get an unambiguous yes, then call again with confirm:true — plus, if the workspace is not empty, confirmName set to its exact name and confirmConnectors set to the number of connected accounts it reported. Those two exist because confirming INTENT does not prove you picked the right WORKSPACE, and a wrong target is how a live brand was destroyed. The account's FIRST/anchor brand cannot be deleted this way (it holds the workspace's root storage) — that one is replaced from the app.",
      "group": "workspace",
      "section": "BRAND WORKSPACE LIFECYCLE. draft_brand OVERWRITES the active workspace's brand; it does not mint one — so",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "brand": {
            "type": "string",
            "description": "brand id or exact name from list_brands"
          },
          "confirm": {
            "description": "REQUIRED true — this destroys the whole workspace and cannot be undone",
            "type": "boolean"
          },
          "confirmName": {
            "description": "the workspace's EXACT name, required when it is not empty — copy it from the inventory this tool returned, after the user has agreed to it",
            "type": "string"
          },
          "confirmConnectors": {
            "description": "the number of connected accounts the inventory reported, required when there is at least one — the user must specifically agree to losing them, because reconnecting each needs a browser and no agent can do it",
            "type": "number"
          }
        },
        "required": [
          "brand"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "meta_page_insights",
      "title": "Facebook Page + Instagram insights",
      "description": "Organic performance for the brand’s connected Facebook Page — views and unique reach (page_media_view / page_total_media_view_unique, Meta’s own replacements for the impressions family it retired), post engagements, video views, daily follows, plus follower and Page-like counts — with the linked Instagram account’s headline numbers alongside. This is ORGANIC reach; use meta_insights for paid ad performance, and instagram_insights for the full Instagram set and its audience demographics. Any metric Meta returns no value for is named as MISSING data, which must never be reported as zero.",
      "group": "publish",
      "section": "META engagement + insights (organic performance and the comment thread under a post)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "pageId": {
            "description": "Page id — omit when the brand has exactly one Page connected",
            "type": "string"
          },
          "period": {
            "description": "window (default week)",
            "type": "string",
            "enum": [
              "day",
              "week",
              "days_28"
            ]
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "meta_post_insights",
      "title": "Insights for one Facebook/Instagram post",
      "description": "Performance for a single organic post — on Facebook views/reach (post_media_view, post_total_media_view_unique — Meta’s own replacements for the retired impressions family), clicks, reactions and video watch time; on Instagram views, reach, likes, comments, saves, shares, total interactions and (where the media type has them) follows, profile visits, story navigation and reel watch time. Use it to find which organic posts earned their reach before turning one into a paid ad. A metric Meta returns no value for is reported by name as MISSING — never read it as zero.",
      "group": "publish",
      "section": "META engagement + insights (organic performance and the comment thread under a post)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "postId": {
            "type": "string",
            "description": "post/media id returned by post_to_meta"
          },
          "target": {
            "description": "which metric set to ask for (default facebook)",
            "type": "string",
            "enum": [
              "facebook",
              "instagram"
            ]
          },
          "pageId": {
            "description": "Page id — omit when only one Page is connected",
            "type": "string"
          }
        },
        "required": [
          "postId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "instagram_insights",
      "title": "Instagram account insights + audience demographics",
      "description": "ACCOUNT-level performance for the brand’s connected Instagram Business account — views, reach, accounts engaged, total interactions, likes, comments, shares, saves, profile link taps, replies, reposts and follows/unfollows — plus the AUDIENCE DEMOGRAPHICS (follower_demographics and engaged_audience_demographics, broken down by age, city, country or gender), which is the read that says WHO the content reached rather than how many. Use meta_post_insights for one post and meta_page_insights for the Facebook Page. THERE IS NO \"impressions\": Meta deprecated it for every API version on 2025-04-21 and replaced it with \"views\" — an unknown metric is refused by name rather than quietly dropped. Instagram returns NO demographics for an account under 100 followers (or under 100 engagements in the window), and an absent block means exactly that, never an empty audience. Read-only, 0 credits. Needs Meta connected with an Instagram Business account linked to the Page.",
      "group": "publish",
      "section": "META engagement + insights (organic performance and the comment thread under a post)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "metrics": {
            "description": "account metrics (default: views, reach, accounts_engaged, total_interactions, likes, comments, shares, saves, profile_links_taps). Add follower_demographics or engaged_audience_demographics for the audience, which also needs a breakdown.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "breakdown": {
            "description": "contact_button_type / follow_type / media_product_type for account metrics; age / city / country / gender for the demographic metrics (exactly one)",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "timeframe": {
            "description": "window for the demographic metrics only (default last_30_days)",
            "type": "string",
            "enum": [
              "last_14_days",
              "last_30_days",
              "last_90_days",
              "prev_month",
              "this_month",
              "this_week"
            ]
          },
          "period": {
            "description": "aggregation for reach, the one time-series metric (default day)",
            "type": "string",
            "enum": [
              "day",
              "week",
              "days_28"
            ]
          },
          "since": {
            "description": "YYYY-MM-DD window start",
            "type": "string"
          },
          "until": {
            "description": "YYYY-MM-DD window end",
            "type": "string"
          },
          "pageId": {
            "description": "Facebook Page id the Instagram account is linked to — omit when only one Page is connected",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_instagram_media",
      "title": "List the brand’s Instagram posts",
      "description": "The connected Instagram Business account’s own recent media — id, caption, media type (feed / reel / story-era), permalink, timestamp, like and comment counts. This is where the media id every other Instagram tool needs comes from: resolve “my latest reel” yourself instead of asking the user for a link, then pass the id to meta_post_insights. Read-only, 0 credits.",
      "group": "publish",
      "section": "META engagement + insights (organic performance and the comment thread under a post)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "limit": {
            "description": "how many (1–50, default 15)",
            "type": "number"
          },
          "pageId": {
            "description": "Facebook Page id — omit when only one Page is connected",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_meta_comments",
      "title": "Read comments on a Meta post",
      "description": "Read the comments under a Facebook Page post or Instagram media object — customer questions, objections and the exact language real people use about the product. Good raw material for ad copy, and the first step before replying or moderating.",
      "group": "publish",
      "section": "META engagement + insights (organic performance and the comment thread under a post)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "postId": {
            "type": "string",
            "description": "post/media id"
          },
          "pageId": {
            "description": "Page id — omit when only one Page is connected",
            "type": "string"
          },
          "limit": {
            "description": "how many comments (1–50, default 25)",
            "type": "number"
          }
        },
        "required": [
          "postId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "reply_to_meta_comment",
      "title": "Reply to a Facebook/Instagram comment",
      "description": "Post a public reply to a comment on the brand’s Facebook or Instagram post. This is PUBLIC and posted as the brand — show the user the exact wording and get their go-ahead first.",
      "group": "publish",
      "section": "META engagement + insights (organic performance and the comment thread under a post)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "commentId": {
            "type": "string",
            "description": "comment id from list_meta_comments"
          },
          "message": {
            "type": "string",
            "description": "reply text"
          },
          "pageId": {
            "description": "Page id — omit when only one Page is connected",
            "type": "string"
          }
        },
        "required": [
          "commentId",
          "message"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "moderate_meta_comment",
      "title": "Hide, unhide or delete a Meta comment",
      "description": "Moderate a comment on the brand’s Facebook or Instagram post. Prefer hide over delete — hiding is reversible and invisible to the commenter. Deleting is PERMANENT and requires confirm:true after the user has agreed.",
      "group": "publish",
      "section": "META engagement + insights (organic performance and the comment thread under a post)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "commentId": {
            "type": "string",
            "description": "comment id from list_meta_comments"
          },
          "action": {
            "description": "default hide",
            "type": "string",
            "enum": [
              "hide",
              "unhide",
              "delete"
            ]
          },
          "confirm": {
            "description": "required (true) only for delete",
            "type": "boolean"
          },
          "pageId": {
            "description": "Page id — omit when only one Page is connected",
            "type": "string"
          }
        },
        "required": [
          "commentId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_threads_posts",
      "title": "List your Threads posts",
      "description": "List recent posts on the brand’s connected Threads account (id, text, media, permalink, timestamp). Use it to find a post id for threads_insights, list_threads_replies, reply_to_thread or delete_thread.",
      "group": "publish",
      "section": "THREADS read + manage (needs a connected Threads account; insights/replies/delete need Meta review)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "limit": {
            "description": "how many posts (1–50, default 15)",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "threads_insights",
      "title": "Threads insights",
      "description": "Performance for ONE Threads post (views, likes, replies, reposts, quotes, shares) when postId is given, or for the whole ACCOUNT when it is omitted — views, likes, replies, reposts, quotes, LINK CLICKS, follower count, and follower_demographics broken down by country, city, age or gender. Note the two metric sets differ: \"clicks\" exists only at account level and \"shares\" only on a single post, and an unknown metric is refused by name rather than dropped. since/until narrow the account window (Threads has no data before 2024-04-13, and followers_count / follower_demographics are lifetime metrics that ignore a window — the reply says so when that happens). Threads returns no demographics below 100 followers; an absent block means the account is under Meta’s floor, NOT that the audience is empty.",
      "group": "publish",
      "section": "THREADS read + manage (needs a connected Threads account; insights/replies/delete need Meta review)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "postId": {
            "description": "post id from list_threads_posts — omit for account-level insights",
            "type": "string"
          },
          "metrics": {
            "description": "account metrics: views, likes, replies, reposts, quotes, clicks, followers_count, follower_demographics",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "breakdown": {
            "description": "country / city / age / gender — required by follower_demographics, exactly one",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "since": {
            "description": "YYYY-MM-DD window start (account scope)",
            "type": "string"
          },
          "until": {
            "description": "YYYY-MM-DD window end (account scope)",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_threads_replies",
      "title": "List replies on a Threads post",
      "description": "Read the replies on a Threads post. Set conversation:true to walk the entire thread rather than only direct replies. Use before reply_to_thread so you answer with the actual conversation in view.",
      "group": "publish",
      "section": "THREADS read + manage (needs a connected Threads account; insights/replies/delete need Meta review)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "postId": {
            "type": "string",
            "description": "post id from list_threads_posts"
          },
          "conversation": {
            "description": "true = the whole thread, not just direct replies",
            "type": "boolean"
          },
          "limit": {
            "description": "how many replies (1–50, default 25)",
            "type": "number"
          }
        },
        "required": [
          "postId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "reply_to_thread",
      "title": "Reply on Threads",
      "description": "Post a reply to a Threads post — the brand’s own or someone else’s. This PUBLISHES publicly under the brand’s account, so show the user the exact wording and get their go-ahead first.",
      "group": "publish",
      "section": "THREADS read + manage (needs a connected Threads account; insights/replies/delete need Meta review)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "replyToId": {
            "type": "string",
            "description": "the post id being replied to"
          },
          "text": {
            "type": "string",
            "description": "reply text (max 500 characters)"
          }
        },
        "required": [
          "replyToId",
          "text"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "hide_thread_reply",
      "title": "Hide or unhide a Threads reply",
      "description": "Hide a reply on the brand’s Threads post (or unhide it with hide:false) — for spam and abuse moderation.",
      "group": "publish",
      "section": "THREADS read + manage (needs a connected Threads account; insights/replies/delete need Meta review)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "replyId": {
            "type": "string",
            "description": "reply id from list_threads_replies"
          },
          "hide": {
            "description": "false to UNHIDE (default true)",
            "type": "boolean"
          }
        },
        "required": [
          "replyId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_thread",
      "title": "Delete a Threads post",
      "description": "Permanently delete one of the brand’s Threads posts. IRREVERSIBLE — you must confirm with the user first, then pass confirm:true.",
      "group": "publish",
      "section": "THREADS read + manage (needs a connected Threads account; insights/replies/delete need Meta review)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "postId": {
            "type": "string",
            "description": "post id from list_threads_posts"
          },
          "confirm": {
            "type": "boolean",
            "description": "must be true; only set it after the user has explicitly agreed to the deletion"
          }
        },
        "required": [
          "postId",
          "confirm"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_threads_mentions",
      "title": "Threads mentions of the brand",
      "description": "Posts where someone MENTIONED the brand on Threads — anywhere, not just under your own posts. This is brand listening: real objections, questions and the exact language customers use, which is strong raw material for ad copy and for mine_angles. Use list_threads_replies instead when you want the conversation under one specific post.",
      "group": "publish",
      "section": "THREADS read + manage (needs a connected Threads account; insights/replies/delete need Meta review)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "limit": {
            "description": "how many mentions (1–50, default 25)",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "search_threads_keyword",
      "title": "Search Threads by keyword",
      "description": "Search PUBLIC Threads posts for a keyword or topic — competitor listening, finding what people say about a product, or sourcing real customer language for ad copy. Distinct from search_threads, which reads a specific profile.",
      "group": "publish",
      "section": "THREADS read + manage (needs a connected Threads account; insights/replies/delete need Meta review)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "q": {
            "type": "string",
            "description": "keyword or phrase"
          },
          "searchType": {
            "description": "TOP (default) or RECENT",
            "type": "string",
            "enum": [
              "TOP",
              "RECENT"
            ]
          }
        },
        "required": [
          "q"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_meta_pages",
      "title": "List Meta pages & ad accounts",
      "description": "List the Facebook Pages (with any linked Instagram business account) and ad accounts on the connected Meta account — use before post_to_meta / create_meta_campaign to pick the target. Requires the user to have connected Meta (Settings ▸ Connectors ▸ Meta); returns a connect hint if not.",
      "group": "publish",
      "section": "META publishing + ads management (needs a connected Meta account: Settings ▸ Connectors ▸ Meta)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "upload_file",
      "title": "Upload a local file → durable public URL",
      "description": "Persist an ARBITRARY user file (image or video, up to 150MB) into Hermoso and get back a durable public URL that EVERY publish, schedule and ad-build tool accepts — post_to_meta / post_to_linkedin / post_to_linkedin_page / post_to_youtube / post_to_tiktok / post_to_pinterest / post_to_x / post_to_reddit / post_to_google_business / schedule_post / upload_meta_asset / upload_google_ads_asset / create_meta_ad / create_linkedin_ads_creative / set_youtube_thumbnail / save_to_drive / save_to_onedrive. THIS IS THE BRING-YOUR-OWN-CREATIVE PATH: it is for files that have NOTHING to do with a Hermoso render (media on the user's desktop, an agency's finished ad, a photo they shot), and it means you can publish, schedule and run ads through Hermoso without generating anything here. Provide exactly ONE source: `path` (a local file — works ONLY when Hermoso runs locally over stdio/CLI; the hosted connector can't see the user's machine), or `dataUri` (a base64 data: URI — keep under ~15MB on the hosted connector). If the file is ALREADY at a public https URL, the Meta, Reddit and ChatGPT-Ads tools take it directly and re-host it safely — but LinkedIn (posts and ad creatives), Pinterest, the YouTube thumbnail and upload_google_ads_asset upload the BYTES themselves and therefore refuse an external host, so run it through here first and pass the URL this returns. When in doubt, use this: its URL works everywhere. Returns {url, kind, bytes}.",
      "group": "publish",
      "section": "META publishing + ads management (needs a connected Meta account: Settings ▸ Connectors ▸ Meta)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "path": {
            "description": "local filesystem path (stdio/CLI only — refused on the hosted connector)",
            "type": "string"
          },
          "dataUri": {
            "description": "base64 data: URI of the file bytes (data:<mime>;base64,<…>)",
            "type": "string"
          },
          "name": {
            "description": "original file name — helps pick the right extension",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "search_threads_locations",
      "title": "Find a place to tag on Threads",
      "description": "Search Threads’ public place index by name (or by latitude+longitude) and get location ids. Use this when the brand has a PHYSICAL location — a restaurant, salon, gym, store — so the post can be geotagged to it. Pass the chosen id as post_to_meta(locationId) with target:\"threads\".",
      "group": "publish",
      "section": "META publishing + ads management (needs a connected Meta account: Settings ▸ Connectors ▸ Meta)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "q": {
            "description": "place name to search, e.g. \"Osteria Francescana\"",
            "type": "string"
          },
          "latitude": {
            "description": "latitude (use with longitude to search near a point)",
            "type": "number"
          },
          "longitude": {
            "description": "longitude",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "post_to_meta",
      "title": "Post to Facebook, Instagram or Threads",
      "description": "Publish to a connected Facebook Page, its linked Instagram, OR the brand’s Threads account — text/link/image/VIDEO/CAROUSEL. A MULTI-SLIDE creative is a CAROUSEL, not several posts: pass the slides in order as imageUrls[] and they publish as ONE swipeable post (Instagram album, Threads carousel, Facebook multi-photo post). Never publish slide 1 of a deck on its own — the creative tells the viewer to swipe. target:\"facebook\" (default) posts to the Page; target:\"instagram\" publishes a photo or Reel to the linked IG business account (needs an image or video); target:\"threads\" posts to the connected Threads account (text, image, or video). Works with ANY media — a finished Hermoso ad OR an arbitrary user file: imageUrl/videoUrl accept a public https URL, a data: URI, or a Hermoso /generated path; for a LOCAL file (e.g. on the user’s desktop) call upload_file first and pass the url it returns. This PUBLISHES immediately — confirm the copy + media with the user first. Needs a connected Meta account (Settings ▸ Connectors ▸ Meta) with posting permission; Threads needs its own connection.",
      "group": "publish",
      "section": "META publishing + ads management (needs a connected Meta account: Settings ▸ Connectors ▸ Meta)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "hook": {
            "description": "WHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer \"which hooks work\", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. \"direct_callout\", \"mid_problem\", \"before_after\") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.",
            "type": "string"
          },
          "subject": {
            "description": "WHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. \"winter coat\", \"free trial\", \"founder story\"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.",
            "type": "string"
          },
          "message": {
            "description": "post text / caption",
            "type": "string"
          },
          "imageUrl": {
            "description": "public https URL, a data: URI, or a Hermoso /generated path (upload_file gives you one for a local file)",
            "type": "string"
          },
          "videoUrl": {
            "description": "public https URL, data: URI, or /generated path — FB video post / IG Reel",
            "type": "string"
          },
          "imageUrls": {
            "description": "CAROUSEL — an ORDERED list of image (and, where the channel allows, video) URLs published as ONE post the viewer swipes through. THIS IS NOT “post several” — it is a single post with several slides, which is what a multi-slide creative (a listicle, a “1/6 · SWIPE” deck) actually needs; publishing only its first slide tells the viewer to swipe at something that cannot. The ORDER is the product. Limits per channel: Instagram 2–10 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "idempotencyKey": {
            "description": "SAFE RETRIES. Publishing can take minutes (a video upload, a carousel of ten slides) and a transport can time out while the post SUCCEEDS — retrying blind is how the same thing gets posted twice. Pass any stable string here and a repeat of the SAME publish returns the ORIGINAL post id instead of posting again (24h). You do not have to: an identical publish is auto-recognised for 10 minutes anyway. If a call times out or errors ambiguously, CALL AGAIN WITH THE SAME KEY — that is the safe move, and it will either report the original post or publish it for the first time. It never posts twice.",
            "type": "string"
          },
          "allowDuplicate": {
            "description": "post it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land.",
            "type": "boolean"
          },
          "async": {
            "description": "publish in the BACKGROUND and return a job id to poll with get_job, instead of waiting. USE THIS FOR VIDEO: a Facebook or Instagram video publish routinely outlives an agent transport, and a timeout on the synchronous path leaves you unable to tell whether the post is live. With async:true nothing can time out — the job reports the post id and url when it lands.",
            "type": "boolean"
          },
          "link": {
            "description": "a URL to attach (FB text post only)",
            "type": "string"
          },
          "target": {
            "description": "default facebook; instagram → the Page’s linked IG; threads → the brand’s connected Threads account",
            "type": "string",
            "enum": [
              "facebook",
              "instagram",
              "threads"
            ]
          },
          "scheduleAt": {
            "description": "FACEBOOK ONLY — schedule instead of posting now. ISO timestamp (2026-08-01T09:00:00Z) or unix seconds; must be 10 minutes to 30 days ahead. Facebook holds the post and publishes it at that time, so nothing has to stay running on our side. Instagram and Threads have NO scheduling in Meta’s API — passing this for them is refused rather than silently posted immediately.",
            "type": "string"
          },
          "locationId": {
            "description": "Threads only — a place id from search_threads_locations, to geotag the post to a physical location (restaurant, storefront)",
            "type": "string"
          },
          "pageId": {
            "description": "target Page id (from list_meta_pages); omit = first Page",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "schedule_post",
      "title": "Schedule a post for later",
      "description": "Queue a post to go out at a future time, to one or more connected channels at once (facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, google_business). A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. This is how you run a content calendar: schedule now, and Hermoso publishes at the time you set — you do not need to be around. Either name the exact time in `at`, or pass `useQueue:true` to take the brand’s next free POSTING SLOT (its saved posting times, skipping any already occupied) — that is what “just queue it” means and it saves the user picking a minute. Pass a Hermoso render URL as imageUrl/videoUrl (or an upload_file URL for external media). PINTEREST AND YOUTUBE ALSO SHOW A TITLE: pass `title` (max 100 chars) — omit it and Hermoso derives one from the caption’s first sentence rather than truncating the caption mid-word, which is what a Pin headline used to be. A YOUTUBE ITEM’S CAPTION IS ITS TITLE, NOT ITS DESCRIPTION: pass `description` (≤5000 chars) for the box under the video — the links, the CTA and everything YouTube search reads — plus `tags` (up to 30). Omit them and the upload lands with an empty description, which is not recoverable by the time anyone notices. Use `captions` to give each channel its own wording; anything not listed falls back to `message`. PER-CHANNEL SETTINGS, all carried straight through to the real publisher: TIKTOK takes the paid-partnership disclosure (`brandedContent`) and the own-brand one (`yourBrand`) — set them whenever the post is commercial, they are compliance declarations — plus `disableComment` and, on a video, `disableDuet` / `disableStitch` / `coverTimestampMs`. GOOGLE BUSINESS takes `topicType` (STANDARD / EVENT / OFFER / ALERT) with `event` and `offer`, and a real `actionType` button instead of the hard-coded Learn more. X takes a whole `thread`, a `poll`, `replySettings` and `madeWithAi`. Channels are attempted INDEPENDENTLY, so one failing channel never blocks the others. SOME CHANNELS MUST BE TOLD WHICH ACCOUNT, and Hermoso never guesses one: a Pinterest pin needs `boardId` (list_pinterest_boards) or it is refused outright; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages) and without it the post goes to the connected person’s own profile; a brand with more than one connected Facebook Page needs `pageId` (list_meta_pages) and an account managing more than one Google Business listing needs `locationId` (list_business_locations) — resolve those FIRST and let the user pick, because with several to choose from and no id the post is refused when it fires, hours later. A scheduled post GOES LIVE PUBLICLY by default on every channel — that is what scheduling means, and nothing is ever quietly downgraded to a draft or an unlisted upload. If the user genuinely wants something staged instead, set `visibility` (or `visibilityByChannel` for just one channel): ‘public’ (default, live) · ‘unlisted’ (YouTube only — link-only) · ‘private’ (YouTube private, or TikTok posted SELF_ONLY) · ‘draft’ (TikTok drafts, or an unpublished Facebook Page post for a human to publish). Ask for a weaker visibility only if the user asked for one. If a channel cannot do the visibility requested, the call is REFUSED right now with the reason, rather than posting something weaker later. Most channels publish publicly and nothing else: only YouTube has unlisted/private, only TikTok has private/draft, and only Facebook has draft.",
      "group": "publish",
      "section": "SCHEDULING (2026-07-30). ONE mechanism for every channel — our durable queue, not a per-platform special case.",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "hook": {
            "description": "WHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer \"which hooks work\", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. \"direct_callout\", \"mid_problem\", \"before_after\") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.",
            "type": "string"
          },
          "subject": {
            "description": "WHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. \"winter coat\", \"free trial\", \"founder story\"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.",
            "type": "string"
          },
          "channels": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "facebook",
                "instagram",
                "threads",
                "tiktok",
                "youtube",
                "linkedin",
                "x",
                "pinterest",
                "google_business"
              ]
            },
            "description": "one or more channels to post to at that time"
          },
          "at": {
            "description": "when to post — ISO timestamp (2026-08-01T09:00:00Z) or epoch milliseconds. Must be in the future, at most 365 days out. Give this OR useQueue, never both.",
            "type": "string"
          },
          "useQueue": {
            "description": "instead of naming a minute, drop this into the brand’s POSTING QUEUE: Hermoso takes the earliest of its saved posting times that is still free (skipping any slot another queued post already holds). This is the natural answer to “just queue it” / “post it at my next opening”. Mutually exclusive with `at` — passing both is refused rather than one being silently preferred. If the brand has no posting times set, or every slot for the next 90 days is taken, it is refused by name and nothing is scheduled.",
            "type": "boolean"
          },
          "timezone": {
            "description": "IANA zone for the queue, e.g. \"America/New_York\" — only meaningful with useQueue, and it overrides the brand’s saved zone for this one post. A saved slot of \"09:00\" is a WALL-CLOCK time, so the zone is what turns it into an instant; without either the brand’s saved zone or this, the queue resolves in UTC.",
            "type": "string"
          },
          "message": {
            "description": "the caption/text used for every channel unless overridden in captions",
            "type": "string"
          },
          "captions": {
            "description": "per-channel caption overrides, e.g. { \"instagram\": \"…\", \"threads\": \"…\" } — platforms want different lengths and hashtag conventions",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {
              "type": "string"
            }
          },
          "imageUrl": {
            "description": "a Hermoso render URL (a /generated path, or what upload_file returned), a data: URI, or a public https URL. NOTE: only Facebook/Instagram/Threads accept an arbitrary public URL — X, TikTok, YouTube, LinkedIn, Pinterest and Google Business re-host the bytes and REFUSE anything that is not a Hermoso render, so run an external file through upload_file first and schedule that url.",
            "type": "string"
          },
          "videoUrl": {
            "description": "a Hermoso render URL (a /generated path, or what upload_file returned), a data: URI, or a public https URL — required for youtube, and for tiktok unless you pass an imageUrl (TikTok takes a photo post too). Same origin rule as imageUrl: everything except Facebook/Instagram/Threads REFUSES a non-Hermoso URL, so pass external video through upload_file first.",
            "type": "string"
          },
          "imageUrls": {
            "description": "CAROUSEL — an ORDERED list of image URLs to publish as ONE swipeable post on every channel that supports it (Instagram 2–10, Threads 2–20, Facebook, LinkedIn company Pages 2–20, Pinterest 2–5, TikTok up to 35 as a photo post). Use this whenever the creative is a multi-slide deck: a scheduled post carrying only slide 1 of a “1/6 · SWIPE” set is a broken ad that nobody is watching when it fires. THE ORDER IS THE PRODUCT. A channel on this schedule that cannot do carousels — X, YouTube, Google Business Profile — is REFUSED NOW, with the reason, so you can drop it or give it its own single image; it is never quietly downgraded hours later.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "title": {
            "description": "PINTEREST / YOUTUBE — the headline, max 100 characters. Pinterest shows it in search and under the pin; YouTube requires one. Leave it out and Hermoso derives one from that channel’s caption (first sentence, cut on a word boundary, trailing hashtags dropped) — set a real one whenever the caption does not open with a usable headline.",
            "type": "string"
          },
          "description": {
            "description": "YOUTUBE — the video DESCRIPTION, max 5000 characters: the box under the video carrying the links, the CTA and everything YouTube search reads. It is NOT the caption — a scheduled YouTube item’s text becomes its TITLE — so omitting this publishes the video with an empty description.",
            "type": "string"
          },
          "tags": {
            "description": "YOUTUBE — up to 30 search tags for the video (plain words, no #).",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "altText": {
            "description": "ACCESSIBILITY — the screen-reader description of the attached image. X (max 1000 characters), Pinterest (max 500) and LinkedIn COMPANY PAGE posts each attach it to the picture; the schedule is REFUSED if it is longer than the tightest of the channels on it, rather than truncated on the way out. Write one whenever the post carries an image: describe what is IN the picture, never a repeat of the caption, which a screen reader already reads. A LinkedIn post to a PERSONAL profile carries none — LinkedIn’s member posting API has no alt-text field at all.",
            "type": "string"
          },
          "link": {
            "description": "a link to attach (Facebook)",
            "type": "string"
          },
          "brandedContent": {
            "description": "TIKTOK — the PAID-PARTNERSHIP disclosure (TikTok’s brand_content_toggle): true when this post promotes a THIRD-PARTY business. It is a compliance declaration, not a preference — set it whenever the post is sponsored. TikTok only accepts branded content on a public or friends-only post, so it cannot ride a private/SELF_ONLY or draft TikTok item and the schedule is refused with the reason.",
            "type": "boolean"
          },
          "yourBrand": {
            "description": "TIKTOK — the OWN-BRAND disclosure (brand_organic_toggle): true when the post promotes the creator’s own business. TikTok asks for at least one of this and brandedContent once a post is commercial.",
            "type": "boolean"
          },
          "disableComment": {
            "description": "TIKTOK — turn comments off on this post.",
            "type": "boolean"
          },
          "disableDuet": {
            "description": "TIKTOK VIDEO ONLY — block Duets. TikTok’s photo-post API has no Duets, so this is refused on a photo/slideshow item rather than silently dropped.",
            "type": "boolean"
          },
          "disableStitch": {
            "description": "TIKTOK VIDEO ONLY — block Stitches. Same photo-post rule as disableDuet.",
            "type": "boolean"
          },
          "coverTimestampMs": {
            "description": "TIKTOK VIDEO ONLY — which frame TikTok uses as the cover, in milliseconds from the start. Omit and TikTok uses the first frame.",
            "type": "number"
          },
          "topicType": {
            "description": "GOOGLE BUSINESS — the KIND of Post. STANDARD is the default; EVENT and OFFER both REQUIRE `event` (title + start date), and OFFER also takes `offer`.",
            "type": "string",
            "enum": [
              "STANDARD",
              "EVENT",
              "OFFER",
              "ALERT"
            ]
          },
          "actionType": {
            "description": "GOOGLE BUSINESS — the call-to-action button. Every button except CALL needs `link` (CALL dials the number on the listing and takes none). Google IGNORES the button link on an OFFER post — put the destination in offer.redeemOnlineUrl. Omit and a post carrying a link gets LEARN_MORE.",
            "type": "string",
            "enum": [
              "BOOK",
              "ORDER",
              "SHOP",
              "LEARN_MORE",
              "SIGN_UP",
              "CALL"
            ]
          },
          "event": {
            "description": "GOOGLE BUSINESS — required for an EVENT or OFFER post: {title, startDate:\"YYYY-MM-DD\", endDate, startTime:\"HH:MM\", endTime}. `title` is the EVENT’s headline, a different thing from the post `title` (which is the Pinterest/YouTube one). Google documents its TimeInterval as needing all four date/time parts to be valid, so send the times whenever you know them.",
            "type": "object",
            "properties": {
              "title": {
                "type": "string"
              },
              "startDate": {
                "type": "string"
              },
              "startTime": {
                "type": "string"
              },
              "endDate": {
                "type": "string"
              },
              "endTime": {
                "type": "string"
              }
            }
          },
          "offer": {
            "description": "GOOGLE BUSINESS — OFFER posts only: {couponCode, redeemOnlineUrl, termsConditions}. redeemOnlineUrl is where an offer actually sends people, since the button link is ignored on an Offer.",
            "type": "object",
            "properties": {
              "couponCode": {
                "type": "string"
              },
              "redeemOnlineUrl": {
                "type": "string"
              },
              "termsConditions": {
                "type": "string"
              }
            }
          },
          "thread": {
            "description": "X — publish a THREAD, one entry per post, each replying to the one before (at most 25, each ≤280 characters). It REPLACES the X caption: with a thread set, `message`/`captions.x` is not sent to X at all. A thread cannot carry a poll.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "poll": {
            "description": "X — attach a poll: {options:[\"…\",\"…\"], durationMinutes}. 2–4 options of at most 25 characters each; voting runs 5–10080 minutes (7 days), default 1440. X makes a poll MUTUALLY EXCLUSIVE with media, so an item carrying an image or video is refused — schedule the poll as its own X-only post.",
            "type": "object",
            "properties": {
              "options": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "durationMinutes": {
                "type": "number"
              }
            },
            "required": [
              "options"
            ]
          },
          "replySettings": {
            "description": "X — who may reply. Omit for everyone, which is the right default for a brand post.",
            "type": "string",
            "enum": [
              "following",
              "mentionedUsers",
              "subscribers",
              "verified"
            ]
          },
          "madeWithAi": {
            "description": "X — X’s AI-media label on this post. Opt-in: X treats it as the poster’s own claim about their media, so it is never set on the user’s behalf.",
            "type": "boolean"
          },
          "boardId": {
            "description": "PINTEREST — REQUIRED whenever pinterest is a channel: the board the Pin goes on, from list_pinterest_boards. The user picks it; a Pin on the wrong board is a public mistake. Scheduling pinterest without one is refused immediately.",
            "type": "string"
          },
          "linkedinOrganizationId": {
            "description": "LINKEDIN — publish as a COMPANY PAGE instead of the connected personal profile. The organization id from list_linkedin_pages. Omit and it posts as the person: an unset id means the profile, never “probably the company”. A Page can also carry VIDEO, which a personal profile cannot.",
            "type": "string"
          },
          "pageId": {
            "description": "FACEBOOK / INSTAGRAM / THREADS — which connected Facebook Page (and its linked Instagram) publishes, from list_meta_pages. Needed when the brand has more than one Page connected; with several and no id the post is refused at fire time rather than sent from the wrong brand.",
            "type": "string"
          },
          "locationId": {
            "description": "GOOGLE BUSINESS PROFILE — which listing, e.g. 'locations/123' from list_business_locations. Needed when the account manages more than one storefront; it is never chosen for the user.",
            "type": "string"
          },
          "visibility": {
            "description": "how it should be published — DEFAULT 'public' (live). Only pass something else if the user explicitly asked to stage/hide it. Not every channel supports every value; an impossible combination is refused when you schedule it, with the reason.",
            "type": "string",
            "enum": [
              "public",
              "unlisted",
              "private",
              "draft"
            ]
          },
          "visibilityByChannel": {
            "description": "override visibility for one channel, e.g. { \"tiktok\": \"draft\" } to go live everywhere but stage TikTok for review",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {
              "type": "string"
            }
          }
        },
        "required": [
          "channels"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_scheduled",
      "title": "List scheduled and past posts",
      "description": "Show what is queued to post and what already went out. Each fired item reports PER-CHANNEL outcomes, so you can see that (say) Instagram published and TikTok failed on the same item rather than a single misleading verdict. Read-only, 0 credits.",
      "group": "publish",
      "section": "WHICH ACCOUNT (server-side SCHED_ID_FIELDS). Every one of these is an answer the publish helper REFUSES",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "reschedule_post",
      "title": "Change a scheduled post",
      "description": "Change a post that is still QUEUED — move it to a different time, rewrite the caption, swap the media, add or drop a channel, or change which board / Page / company Page / listing it goes to. PASS ONLY WHAT CHANGES: an omitted field is left exactly as it was, and an explicit empty string CLEARS one (linkedinOrganizationId:\"\" moves a company-Page post back to the person’s own profile). The edited item is re-checked against the identical rules its create passed — visibility the channel can honour, per-channel length, media the channel can carry — so an edit can never slip past a refusal that a create would have caught. Get the id from list_scheduled. Something that already went out cannot be changed: a published post is edited or removed with manage_meta_post / manage_linkedin_post / delete_x_post, not rescheduled.",
      "group": "publish",
      "section": "WHICH ACCOUNT (server-side SCHED_ID_FIELDS). Every one of these is an answer the publish helper REFUSES",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "the scheduled post id from list_scheduled"
          },
          "at": {
            "description": "the new time — ISO timestamp (2026-08-05T09:00:00Z) or epoch milliseconds. Must be in the future, at most 365 days out.",
            "type": "string"
          },
          "message": {
            "description": "replace the caption used for every channel that has no override",
            "type": "string"
          },
          "captions": {
            "description": "replaces the WHOLE per-channel caption map — send every override you want to keep, not just the new one",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {
              "type": "string"
            }
          },
          "channels": {
            "description": "replaces the channel list",
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "facebook",
                "instagram",
                "threads",
                "tiktok",
                "youtube",
                "linkedin",
                "x",
                "pinterest",
                "google_business"
              ]
            }
          },
          "imageUrl": {
            "description": "swap the image; \"\" removes it",
            "type": "string"
          },
          "videoUrl": {
            "description": "swap the video; \"\" removes it",
            "type": "string"
          },
          "imageUrls": {
            "description": "replace the CAROUSEL slides, in order; an empty array [] drops the carousel and goes back to a single image. Omit to leave the slides exactly as they are. The edited item is re-checked against the same carousel rules the create passed, so adding a channel that cannot swipe is refused now rather than posting slide 1 later.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "title": {
            "description": "PINTEREST / YOUTUBE — replace the headline; \"\" clears it and goes back to deriving one from the caption",
            "type": "string"
          },
          "description": {
            "description": "YOUTUBE — replace the video description; \"\" clears it. Remember the caption is the TITLE, not the description.",
            "type": "string"
          },
          "tags": {
            "description": "YOUTUBE — replaces the WHOLE tag list; an empty array [] clears the tags.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "altText": {
            "description": "ACCESSIBILITY — replace the image’s screen-reader description (used by X, Pinterest and LinkedIn company Pages); \"\" clears it.",
            "type": "string"
          },
          "link": {
            "type": "string"
          },
          "brandedContent": {
            "description": "TIKTOK — the paid-partnership disclosure; false turns it off.",
            "type": "boolean"
          },
          "yourBrand": {
            "description": "TIKTOK — the own-brand disclosure; false turns it off.",
            "type": "boolean"
          },
          "disableComment": {
            "description": "TIKTOK — comments off on this post.",
            "type": "boolean"
          },
          "disableDuet": {
            "description": "TIKTOK VIDEO ONLY — block Duets.",
            "type": "boolean"
          },
          "disableStitch": {
            "description": "TIKTOK VIDEO ONLY — block Stitches.",
            "type": "boolean"
          },
          "coverTimestampMs": {
            "description": "TIKTOK VIDEO ONLY — cover frame in milliseconds.",
            "type": "number"
          },
          "topicType": {
            "description": "GOOGLE BUSINESS — the kind of Post; EVENT and OFFER both require `event`.",
            "type": "string",
            "enum": [
              "STANDARD",
              "EVENT",
              "OFFER",
              "ALERT"
            ]
          },
          "actionType": {
            "description": "GOOGLE BUSINESS — the call-to-action button; \"\" clears it.",
            "type": "string",
            "enum": [
              "BOOK",
              "ORDER",
              "SHOP",
              "LEARN_MORE",
              "SIGN_UP",
              "CALL"
            ]
          },
          "event": {
            "description": "GOOGLE BUSINESS — replaces the whole event record {title, startDate, startTime, endDate, endTime}.",
            "type": "object",
            "properties": {
              "title": {
                "type": "string"
              },
              "startDate": {
                "type": "string"
              },
              "startTime": {
                "type": "string"
              },
              "endDate": {
                "type": "string"
              },
              "endTime": {
                "type": "string"
              }
            }
          },
          "offer": {
            "description": "GOOGLE BUSINESS — replaces the whole offer record {couponCode, redeemOnlineUrl, termsConditions}.",
            "type": "object",
            "properties": {
              "couponCode": {
                "type": "string"
              },
              "redeemOnlineUrl": {
                "type": "string"
              },
              "termsConditions": {
                "type": "string"
              }
            }
          },
          "thread": {
            "description": "X — replaces the WHOLE thread; an explicit [] drops back to a single post using the caption.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "poll": {
            "description": "X — replaces the poll; an empty options list removes it.",
            "type": "object",
            "properties": {
              "options": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "durationMinutes": {
                "type": "number"
              }
            },
            "required": [
              "options"
            ]
          },
          "replySettings": {
            "description": "X — who may reply; \"\" goes back to everyone.",
            "type": "string",
            "enum": [
              "following",
              "mentionedUsers",
              "subscribers",
              "verified"
            ]
          },
          "madeWithAi": {
            "description": "X — the AI-media label; false turns it off.",
            "type": "boolean"
          },
          "boardId": {
            "description": "PINTEREST — move the Pin to a different board (list_pinterest_boards)",
            "type": "string"
          },
          "linkedinOrganizationId": {
            "description": "LINKEDIN — target a different company Page, or \"\" to post as the connected person instead",
            "type": "string"
          },
          "pageId": {
            "description": "FACEBOOK / INSTAGRAM / THREADS — publish from a different connected Page (list_meta_pages)",
            "type": "string"
          },
          "locationId": {
            "description": "GOOGLE BUSINESS — a different listing (list_business_locations)",
            "type": "string"
          },
          "visibility": {
            "description": "NOTE: changing this without also naming visibilityByChannel clears any per-channel overrides, so \"make it all draft\" is not a no-op",
            "type": "string",
            "enum": [
              "public",
              "unlisted",
              "private",
              "draft"
            ]
          },
          "visibilityByChannel": {
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {
              "type": "string"
            }
          }
        },
        "required": [
          "id"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "cancel_scheduled",
      "title": "Cancel a scheduled post",
      "description": "Remove a queued post before it goes out. Get the id from list_scheduled. Only works while it is still queued — something already published cannot be unsent (use manage_meta_post to delete a Facebook/Instagram post after the fact).",
      "group": "publish",
      "section": "WHICH ACCOUNT (server-side SCHED_ID_FIELDS). Every one of these is an answer the publish helper REFUSES",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "the scheduled post id from list_scheduled"
          }
        },
        "required": [
          "id"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "get_post_refill",
      "title": "Posting refill status",
      "description": "Show the automatic posting refill for this brand: whether it is on, whether it is in dry-run (preview) mode, how many days ahead it fills, its render budget, when it next runs, and how many posts are queued right now. It also names the channels that CANNOT be posted to and why. Read-only, free.",
      "group": "publish",
      "section": "THE POSTING REFILL (2026-08-03)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_post_refill",
      "title": "Configure the posting refill",
      "description": "Turn the automatic posting refill on or off and set how it behaves. PASS ONLY WHAT CHANGES. `enabled:false` is the PAUSE — it removes the recurring job outright, and posts already queued are left alone (cancel those with cancel_scheduled if you want them gone). It starts in dryRun, which plans and previews without queueing; set dryRun:false only once a human has read a preview from run_post_refill. THE CADENCE IS THE BRAND’S POSTING TIMES, not a number here: three posting times means three posts a day. Raising maxImagesPerDay / maxVideosPerDay / maxCreditsPerDay above 0 lets it SPEND on new creative — at 0 (the default) it only reuses renders already in the Library and costs nothing.",
      "group": "publish",
      "section": "THE POSTING REFILL (2026-08-03)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "enabled": {
            "description": "on/off. false PAUSES it: the recurring job is deleted and nothing new is queued. Already-queued posts are untouched.",
            "type": "boolean"
          },
          "dryRun": {
            "description": "true (the default) = plan and preview only, queue nothing. Set false ONLY after a human has read a preview.",
            "type": "boolean"
          },
          "daysAhead": {
            "description": "how far ahead to keep the queue full, 1–30 (default 7)",
            "type": "number"
          },
          "postsPerDay": {
            "description": "cap the posts per day BELOW the number of posting times. 0 (default) = use every posting time, which is where \"3 a day\" comes from. To post MORE per day, add posting times instead.",
            "type": "number"
          },
          "assetCooldownDays": {
            "description": "how long before a Library render may be posted again (default 30). It never repeats one inside this window — it queues fewer posts and says so.",
            "type": "number"
          },
          "maxImagesPerDay": {
            "description": "how many NEW images a day it may render when the Library runs dry. 0 (default) = none, spend nothing.",
            "type": "number"
          },
          "maxVideosPerDay": {
            "description": "how many NEW videos a day it may render. 0 (default) = none. Video is the expensive one — hundreds of credits each.",
            "type": "number"
          },
          "maxCreditsPerDay": {
            "description": "a hard credit ceiling per day, checked BEFORE any render starts. It binds independently of the counts above.",
            "type": "number"
          },
          "channels": {
            "description": "restrict it to these channels. Omit (or send an empty list) to use every connected channel that can carry each post.",
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "facebook",
                "instagram",
                "threads",
                "tiktok",
                "youtube",
                "linkedin",
                "x",
                "pinterest"
              ]
            }
          },
          "boardId": {
            "description": "PINTEREST — which board Pins go on (list_pinterest_boards). Without one, Pinterest is skipped: a Pin on the wrong board is a public mistake, so it is never guessed.",
            "type": "string"
          },
          "linkedinOrganizationId": {
            "description": "LINKEDIN — which company Page to post as (list_linkedin_pages). A single shared Page is used automatically; a Page that is not shared with this brand is ignored rather than failing the whole post.",
            "type": "string"
          },
          "pageId": {
            "description": "FACEBOOK / INSTAGRAM / THREADS — which connected Page to publish from (list_meta_pages). Omit for the brand’s only Page.",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "run_post_refill",
      "title": "Run the posting refill",
      "description": "Run the refill NOW instead of waiting for its daily turn. DRY BY DEFAULT: it returns the exact posts it WOULD queue — the caption, the creative, the channels and the per-channel visibility — without queueing anything or spending anything on creative. Pass dryRun:false to actually queue them. SHOW THE PREVIEW TO THE USER BEFORE EVER PASSING dryRun:false; these go onto real public accounts. Every caption is screened against the brand’s own voice rules and a failing one is dropped, so a plan can legitimately come back shorter than the cadence — the reason is in the notes.",
      "group": "publish",
      "section": "THE POSTING REFILL (2026-08-03)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "dryRun": {
            "description": "default TRUE (preview only). false actually queues the posts.",
            "type": "boolean"
          },
          "force": {
            "description": "plan even while the refill is switched off — useful for showing someone what it would do before they turn it on. Combined with dryRun:false it still respects a stored dryRun.",
            "type": "boolean"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "post_to_linkedin",
      "title": "Publish to LinkedIn",
      "description": "Publish a post to the user’s connected LinkedIn profile — text, and optionally an image (pass its served URL as imageUrl). The image does NOT have to be something Hermoso generated: LinkedIn is served the bytes from us, so the URL must be Hermoso-hosted, and upload_file turns ANY file the user already has into exactly that. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Needs a connected LinkedIn account (Settings ▸ Connectors ▸ LinkedIn).",
      "group": "publish",
      "section": "THE POSTING REFILL (2026-08-03)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "hook": {
            "description": "WHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer \"which hooks work\", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. \"direct_callout\", \"mid_problem\", \"before_after\") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.",
            "type": "string"
          },
          "subject": {
            "description": "WHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. \"winter coat\", \"free trial\", \"founder story\"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.",
            "type": "string"
          },
          "text": {
            "type": "string",
            "description": "the post text"
          },
          "imageUrl": {
            "description": "a Hermoso-hosted image URL to attach (≤12MB) — a Hermoso render, or ANY file of the user’s own put through upload_file first. An arbitrary external host is refused (we fetch the bytes ourselves).",
            "type": "string"
          },
          "imageUrls": {
            "description": "A CAROUSEL IS NOT AVAILABLE ON A PERSONAL PROFILE — LinkedIn's organic multi-image post publishes from a COMPANY PAGE. Passing several here is refused by name rather than posting slide 1; use post_to_linkedin_page instead.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "idempotencyKey": {
            "description": "SAFE RETRIES. Publishing can take minutes (a video upload, a carousel of ten slides) and a transport can time out while the post SUCCEEDS — retrying blind is how the same thing gets posted twice. Pass any stable string here and a repeat of the SAME publish returns the ORIGINAL post id instead of posting again (24h). You do not have to: an identical publish is auto-recognised for 10 minutes anyway. If a call times out or errors ambiguously, CALL AGAIN WITH THE SAME KEY — that is the safe move, and it will either report the original post or publish it for the first time. It never posts twice.",
            "type": "string"
          },
          "allowDuplicate": {
            "description": "post it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land.",
            "type": "boolean"
          },
          "visibility": {
            "description": "default PUBLIC",
            "type": "string",
            "enum": [
              "PUBLIC",
              "CONNECTIONS"
            ]
          }
        },
        "required": [
          "text"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "post_to_x",
      "title": "Publish a post to X (Twitter)",
      "description": "Publish to the user’s connected X (Twitter) account — a single post, a post with an image or video render attached, a reply to an existing post, or a whole THREAD (pass `thread` as an array and each part is posted as a reply to the one before). This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Can also run a POLL (2-4 options) instead of media, and restrict who may reply. Each post must be 280 characters or fewer; longer text is REFUSED, never truncated — split it into a thread instead. Write altText whenever you attach a render. COSTS CREDITS: X charges per API request, so every post in a thread is billed, and a post containing a LINK costs roughly 13× one without — mention the cost before publishing a long thread. X ADS are a separate product Hermoso cannot reach: this tool posts ORGANICALLY, it does not create an ad campaign. Needs X connected (Settings ▸ Connectors ▸ X).",
      "group": "publish",
      "section": "X / TWITTER (docs/connector-roadmap.md §2). UNLIKE EVERY OTHER CONNECTOR, X BILLS PER CALL — the descriptions",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "hook": {
            "description": "WHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer \"which hooks work\", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. \"direct_callout\", \"mid_problem\", \"before_after\") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.",
            "type": "string"
          },
          "subject": {
            "description": "WHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. \"winter coat\", \"free trial\", \"founder story\"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.",
            "type": "string"
          },
          "text": {
            "description": "the post text, ≤280 characters. Use this OR thread, not both.",
            "type": "string"
          },
          "thread": {
            "description": "a thread: each string is one post (≤280 chars each), published in order, each replying to the previous. Max 25.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "mediaUrl": {
            "description": "a Hermoso render (image or video) to attach to the first post — pass its served URL, or an upload_file url for external media",
            "type": "string"
          },
          "altText": {
            "description": "accessibility description of the attached media, max 1000 characters — write one whenever you attach a render. Costs a small extra amount: X bills the metadata write separately.",
            "type": "string"
          },
          "poll": {
            "description": "run a poll on the post. X does not allow a poll and media on the same post, and a poll cannot ride on a thread.",
            "type": "object",
            "properties": {
              "options": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "2-4 choices, max 25 characters each"
              },
              "durationMinutes": {
                "description": "5 to 10080 minutes (7 days); default 1440 = one day",
                "type": "number"
              }
            },
            "required": [
              "options"
            ]
          },
          "replySettings": {
            "description": "restrict who can reply — omit for everyone, which is the right default for a brand post",
            "type": "string",
            "enum": [
              "following",
              "mentionedUsers",
              "subscribers",
              "verified"
            ]
          },
          "replyToId": {
            "description": "numeric id of an existing X post to reply to",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_x_post",
      "title": "Delete a post on X",
      "description": "Permanently delete one of the connected account’s posts on X. This CANNOT be undone — confirm the exact post with the user first. Costs credits (X bills per API call). Needs X connected.",
      "group": "publish",
      "section": "X / TWITTER (docs/connector-roadmap.md §2). UNLIKE EVERY OTHER CONNECTOR, X BILLS PER CALL — the descriptions",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "the numeric X post id — the last part of the post URL"
          }
        },
        "required": [
          "id"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "x_post_metrics",
      "title": "Read performance of a post on X",
      "description": "Read the PUBLIC metrics of a post on X — impressions, likes, reposts, replies, quotes and bookmarks — to judge whether a hook landed before spending more behind it. For the advertiser numbers (link clicks, video views, profile visits) use x_post_insights instead. Costs a small number of credits (X bills per API read). Needs X connected.",
      "group": "publish",
      "section": "X / TWITTER (docs/connector-roadmap.md §2). UNLIKE EVERY OTHER CONNECTOR, X BILLS PER CALL — the descriptions",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "the numeric X post id — the last part of the post URL"
          }
        },
        "required": [
          "id"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "x_post_insights",
      "title": "Advertiser analytics for your own posts on X",
      "description": "Advertiser-grade analytics for the connected account’s OWN posts on X — impressions, engagements, LINK CLICKS, profile visits, video views and video completion quartiles. This is the read that answers “did the creative work”, which x_post_metrics cannot: public metrics show likes and reposts, never clicks or video retention. Takes up to 25 post ids in one call. COSTS CREDITS PER POST READ, so ask about the posts that matter rather than everything. If X returns no rows, say so — that is missing data, not zero performance. Needs X connected.",
      "group": "publish",
      "section": "X / TWITTER (docs/connector-roadmap.md §2). UNLIKE EVERY OTHER CONNECTOR, X BILLS PER CALL — the descriptions",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "numeric X post ids (max 25) — the last part of each post URL"
          },
          "granularity": {
            "description": "default Total",
            "type": "string",
            "enum": [
              "Total",
              "Daily",
              "Hourly",
              "Weekly"
            ]
          }
        },
        "required": [
          "ids"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "x_post_insights_historical",
      "title": "Advertiser analytics for your own X posts, over any date range",
      "description": "The same advertiser-grade X analytics as x_post_insights — impressions, engagements, LINK CLICKS, profile visits, video views and video completion quartiles — over ANY date range instead of only the last 28 hours. This is the one to use for “how did last week’s post do”, “compare these three posts over the month”, or any retrospective: x_post_insights physically cannot see past yesterday, so asking it about an older post returns nothing and that is not zero performance. Takes up to 25 post ids at once; the window defaults to the last 28 days when you name none, and the window actually queried is reported back. COSTS CREDITS PER POST READ — X bills us per API call — so say the cost before pulling a big batch and ask about the posts that matter. Needs X connected.",
      "group": "publish",
      "section": "X / TWITTER (docs/connector-roadmap.md §2). UNLIKE EVERY OTHER CONNECTOR, X BILLS PER CALL — the descriptions",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "numeric X post ids (max 25) — the last part of each post URL"
          },
          "startDate": {
            "description": "YYYY-MM-DD or a UTC timestamp; defaults to 28 days before the end",
            "type": "string"
          },
          "endDate": {
            "description": "YYYY-MM-DD or a UTC timestamp; defaults to now",
            "type": "string"
          },
          "granularity": {
            "description": "default Total",
            "type": "string",
            "enum": [
              "Total",
              "Daily",
              "Hourly",
              "Weekly"
            ]
          }
        },
        "required": [
          "ids"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "x_mentions",
      "title": "Read who is mentioning you on X",
      "description": "Read the posts mentioning the connected X account — who is talking to the brand, in their own words, newest first. Use it to find what deserves a reply (reply with post_to_x + replyToId) and to mine real objections and customer language for ad copy. COSTS CREDITS PER MENTION RETURNED, plus one account lookup — keep maxResults small (default 10) and tell the user the cost before pulling a big page. Needs X connected.",
      "group": "publish",
      "section": "X / TWITTER (docs/connector-roadmap.md §2). UNLIKE EVERY OTHER CONNECTOR, X BILLS PER CALL — the descriptions",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "maxResults": {
            "description": "how many mentions to pull, 5-100 (default 10) — every one is billed",
            "type": "number"
          },
          "sinceId": {
            "description": "only return mentions newer than this post id",
            "type": "string"
          },
          "paginationToken": {
            "description": "next_token from a previous call, to page further back",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "post_to_reddit",
      "title": "Post to a subreddit",
      "description": "Submit a post to ONE named subreddit as the user’s connected Reddit account — a text post, a link post, or a native image post (pass a Hermoso render URL as imageUrl). This PUBLISHES immediately and PUBLICLY under their username, so show the user the exact subreddit, title and body and get an explicit yes BEFORE calling. REDDIT IS NOT A BROADCAST CHANNEL: it punishes undisclosed self-promotion harder than any other platform, and posting the same or near-identical content to several subreddits breaks Reddit’s own developer policy and gets accounts banned. Post to ONE subreddit, written for that specific community — if the user asks to blast several, tell them this instead of doing it. Subreddits that require post flair are detected before anything is posted and the error lists the valid flairs to pass as flairId. Needs Reddit connected (Settings ▸ Connectors ▸ Reddit).",
      "group": "publish",
      "section": "REDDIT (2026-07-30). The anti-spam framing is deliberately IN the tool description, not left to judgement:",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "hook": {
            "description": "WHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer \"which hooks work\", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. \"direct_callout\", \"mid_problem\", \"before_after\") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.",
            "type": "string"
          },
          "subject": {
            "description": "WHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. \"winter coat\", \"free trial\", \"founder story\"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.",
            "type": "string"
          },
          "subreddit": {
            "type": "string",
            "description": "the ONE subreddit to post to, e.g. \"SideProject\" (an r/ prefix is fine)"
          },
          "title": {
            "type": "string",
            "description": "post title, max 300 characters"
          },
          "kind": {
            "description": "\"self\" = text post (default), \"link\" = share a url, \"image\" = native image upload. Inferred from what you pass if omitted.",
            "type": "string",
            "enum": [
              "self",
              "link",
              "image"
            ]
          },
          "text": {
            "description": "body markdown for a text post",
            "type": "string"
          },
          "url": {
            "description": "the destination url for a link post",
            "type": "string"
          },
          "imageUrl": {
            "description": "a Hermoso render image URL for a native image post (or an upload_file url)",
            "type": "string"
          },
          "flairId": {
            "description": "flair template id — required by some subreddits; the error names the valid ones",
            "type": "string"
          },
          "flairText": {
            "description": "flair text, only where that flair is editable",
            "type": "string"
          },
          "nsfw": {
            "type": "boolean"
          },
          "spoiler": {
            "type": "boolean"
          },
          "resubmit": {
            "description": "post a link Reddit says was already submitted — usually reads as spam, so confirm first",
            "type": "boolean"
          }
        },
        "required": [
          "subreddit",
          "title"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "reddit_post_stats",
      "title": "How a Reddit post did",
      "description": "Read one of the connected account’s Reddit posts back — score (net upvotes), comment count, upvote ratio, flair, and whether the subreddit removed it. Use it for \"how did that post do\" or to judge which framing a community actually rewarded before writing the next one. Read-only, 0 credits. Needs Reddit connected.",
      "group": "publish",
      "section": "REDDIT (2026-07-30). The anti-spam framing is deliberately IN the tool description, not left to judgement:",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "postId": {
            "type": "string",
            "description": "the id returned by post_to_reddit, its t3_… fullname, or the full reddit.com permalink"
          }
        },
        "required": [
          "postId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_pinterest_boards",
      "title": "List Pinterest boards",
      "description": "List the boards on the user’s connected Pinterest account — id, name, privacy and pin count. ALWAYS call this before post_to_pinterest and let the USER pick: Pinterest requires a board and Hermoso never chooses one for them. Read-only, 0 credits. Needs Pinterest connected (Settings ▸ Connectors ▸ Pinterest).",
      "group": "publish",
      "section": "PINTEREST (2026-07-30). Two tools on purpose: the board is a REQUIRED, user-owned choice, and a Pin on the",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "privacy": {
            "description": "filter by board privacy; default is everything the connection can see",
            "type": "string",
            "enum": [
              "ALL",
              "PUBLIC",
              "PROTECTED",
              "SECRET"
            ]
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "pinterest_analytics",
      "title": "Pinterest organic analytics",
      "description": "ORGANIC Pinterest performance — impressions, saves, Pin clicks, outbound clicks and their rates, for the whole ACCOUNT, for the TOP PINS, for the TOP VIDEO PINS (with view-through and average watch time), or for ONE Pin. This is unpaid reach; pinterest_ads_report covers paid. Use scope:\"top_pins\" to answer \"what is actually working on our Pinterest\" — it ranks the account’s own Pins by whichever metric you sort on. NOTE Pinterest keeps only 90 DAYS of organic analytics and refuses a longer window, which is refused here with the reason rather than as an opaque error. A VIDEO Pin takes a different metric set from a static one (pass video:true for scope:\"pin\"). THERE IS NO BOARD ANALYTICS: Pinterest’s v5 API publishes no such endpoint, so board-level performance genuinely does not exist in any API — do not promise it. An unknown metric is refused by name, and a metric Pinterest omits from a row is MISSING data (\"if a column has no value, it may not be returned\"), never a measured zero. Works on Pinterest’s Trial access tier — unlike creating Pins, every read row in Pinterest’s access-tier table is available on Trial. Read-only, 0 credits.",
      "group": "publish",
      "section": "PINTEREST (2026-07-30). Two tools on purpose: the board is a REQUIRED, user-owned choice, and a Pin on the",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "scope": {
            "description": "default account",
            "type": "string",
            "enum": [
              "account",
              "top_pins",
              "top_video_pins",
              "pin"
            ]
          },
          "pinId": {
            "description": "required for scope:\"pin\" — the id post_to_pinterest returned",
            "type": "string"
          },
          "video": {
            "description": "scope:\"pin\" only — true when the Pin is a VIDEO, which has its own metric set",
            "type": "boolean"
          },
          "metricTypes": {
            "description": "which metrics; omit for all of the ones valid at this scope. Unknown values are refused with the valid list.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "sortBy": {
            "description": "top_pins / top_video_pins: the metric to rank by (default the first metric)",
            "type": "string"
          },
          "since": {
            "description": "YYYY-MM-DD, default 30 days ago; Pinterest allows at most 90 days back",
            "type": "string"
          },
          "until": {
            "description": "YYYY-MM-DD, default today",
            "type": "string"
          },
          "limit": {
            "description": "top_pins / top_video_pins: how many (1–50, default 10)",
            "type": "number"
          },
          "appTypes": {
            "type": "string",
            "enum": [
              "ALL",
              "MOBILE",
              "TABLET",
              "WEB"
            ]
          },
          "splitField": {
            "description": "account: NO_SPLIT | APP_TYPE | OWNED_CONTENT | SOURCE | PIN_FORMAT",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_pinterest_board",
      "title": "Create a Pinterest board",
      "description": "Create a board on the connected Pinterest account. Needed because a Pin cannot exist without a board, and a NEW Pinterest business account has none — if list_pinterest_boards comes back empty, make one here rather than telling the user you can't pin. Boards are PUBLIC unless you pass privacy 'SECRET'; a Pin on a secret board is invisible to everyone, so only choose that if the user asked for it.",
      "group": "publish",
      "section": "PINTEREST (2026-07-30). Two tools on purpose: the board is a REQUIRED, user-owned choice, and a Pin on the",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "board name, e.g. \"Product launches\" — keep it something a real Pinterest audience would browse"
          },
          "description": {
            "description": "optional board description (≤500 chars)",
            "type": "string"
          },
          "privacy": {
            "description": "default PUBLIC. SECRET hides the board and every Pin on it from everyone but the account owner.",
            "type": "string",
            "enum": [
              "PUBLIC",
              "SECRET"
            ]
          }
        },
        "required": [
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "post_to_pinterest",
      "title": "Create a Pin",
      "description": "Create a Pin on one of the user’s Pinterest boards from any finished visual they have — image, video, or a 2–5 slide CAROUSEL (pass the slides in order as imageUrls[] and Pinterest publishes one swipeable Pin). Title, description and destination link ride along. The link is what makes a Pin drive traffic, so ask for it rather than omitting it, and the description is the text Pinterest search actually reads. boardId is REQUIRED: call list_pinterest_boards first and let the user choose. This PUBLISHES to their public profile — confirm the board, title and link before calling. Video Pins take a minute or two while Pinterest ingests the file. Needs Pinterest connected (Settings ▸ Connectors ▸ Pinterest).",
      "group": "publish",
      "section": "PINTEREST (2026-07-30). Two tools on purpose: the board is a REQUIRED, user-owned choice, and a Pin on the",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "hook": {
            "description": "WHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer \"which hooks work\", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. \"direct_callout\", \"mid_problem\", \"before_after\") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.",
            "type": "string"
          },
          "subject": {
            "description": "WHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. \"winter coat\", \"free trial\", \"founder story\"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.",
            "type": "string"
          },
          "boardId": {
            "type": "string",
            "description": "numeric board id from list_pinterest_boards — the user picks it, never guess"
          },
          "imageUrl": {
            "description": "a Hermoso render image URL (or an upload_file url)",
            "type": "string"
          },
          "videoUrl": {
            "description": "a Hermoso render video URL — takes 1–2 minutes to ingest",
            "type": "string"
          },
          "imageUrls": {
            "description": "CAROUSEL — an ORDERED list of image (and, where the channel allows, video) URLs published as ONE post the viewer swipes through. THIS IS NOT “post several” — it is a single post with several slides, which is what a multi-slide creative (a listicle, a “1/6 · SWIPE” deck) actually needs; publishing only its first slide tells the viewer to swipe at something that cannot. The ORDER is the product. Limits per channel: Instagram 2–10 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "idempotencyKey": {
            "description": "SAFE RETRIES. Publishing can take minutes (a video upload, a carousel of ten slides) and a transport can time out while the post SUCCEEDS — retrying blind is how the same thing gets posted twice. Pass any stable string here and a repeat of the SAME publish returns the ORIGINAL post id instead of posting again (24h). You do not have to: an identical publish is auto-recognised for 10 minutes anyway. If a call times out or errors ambiguously, CALL AGAIN WITH THE SAME KEY — that is the safe move, and it will either report the original post or publish it for the first time. It never posts twice.",
            "type": "string"
          },
          "allowDuplicate": {
            "description": "post it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land.",
            "type": "boolean"
          },
          "title": {
            "description": "Pin title, max 100 characters",
            "type": "string"
          },
          "description": {
            "description": "Pin description, max 800 characters — this is what Pinterest search reads",
            "type": "string"
          },
          "link": {
            "description": "destination URL the Pin clicks through to",
            "type": "string"
          },
          "altText": {
            "description": "accessibility alt text, max 500 characters",
            "type": "string"
          },
          "coverImageUrl": {
            "description": "video Pins only — a render to use as the cover frame",
            "type": "string"
          },
          "boardSectionId": {
            "description": "optional section within the board",
            "type": "string"
          }
        },
        "required": [
          "boardId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "pinterest_ads_async_report",
      "title": "Pinterest deep (async) ad report",
      "description": "The DEEP Pinterest ad report — Pinterest’s ASYNCHRONOUS lane, which reaches 914 DAYS back (2.5 years) where pinterest_ads_report stops at 90, and carries roughly three times the metric columns (conversion, ROAS and cross-device families the quick report does not have). Use it for anything older than three months, and for revenue questions. Levels: ADVERTISER / CAMPAIGN / AD_GROUP / PIN_PROMOTION / KEYWORD / PRODUCT_GROUP / PRODUCT_ITEM plus their *_TARGETING twins. Pinterest generates it asynchronously, so this may come back pending:true with a token — CALL AGAIN WITH THAT TOKEN to pick it up, and never re-submit without it (a second submit generates a second report). Pinterest’s own windows are enforced here with the reason rather than as an opaque 400: 914 days back over at most 186 days; at HOUR granularity 8 days back over 3; at a PRODUCT_ITEM level 92 back over 31. A finished report link is valid five minutes and the report one hour, so an EXPIRED status means run it again, not that anything failed. Read-only, 0 credits.",
      "group": "ads",
      "section": "PINTEREST DEEP ANALYTICS (2026-08-05)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "token": {
            "description": "RESUME a pending report — pass the token back instead of re-submitting",
            "type": "string"
          },
          "since": {
            "description": "YYYY-MM-DD (default 30 days ago)",
            "type": "string"
          },
          "until": {
            "description": "YYYY-MM-DD (default today)",
            "type": "string"
          },
          "granularity": {
            "type": "string",
            "enum": [
              "TOTAL",
              "DAY",
              "HOUR",
              "WEEK",
              "MONTH"
            ]
          },
          "level": {
            "description": "ADVERTISER | CAMPAIGN | AD_GROUP | PIN_PROMOTION | KEYWORD | PRODUCT_GROUP | PRODUCT_ITEM (+ _TARGETING variants) — default CAMPAIGN. An unknown level is refused with the list.",
            "type": "string"
          },
          "columns": {
            "description": "Pinterest async metric columns — omit for the standard spend/impressions/clicks/CTR/conversions set",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "campaignIds": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "adGroupIds": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "adIds": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "targetingTypes": {
            "description": "only valid with a *_TARGETING level",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "reportFormat": {
            "type": "string",
            "enum": [
              "JSON",
              "CSV"
            ]
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "pinterest_targeting_analytics",
      "title": "Pinterest ads by audience segment",
      "description": "WHICH AUDIENCE SEGMENT actually delivered on Pinterest — ad performance broken down by keyword, targeted interest, age bucket, gender, location, region, country, placement, app type, media type and more. targetingTypes is REQUIRED because it is what the report breaks down BY. scope:\"account\" covers the whole ad account; \"campaign\" / \"adGroup\" / \"ad\" each REQUIRE their own id list, because Pinterest publishes no all-of-them form at those levels — that is Pinterest’s shape, not a limitation here. 90 days back in windows of at most 90 days, refused locally with the reason. An unknown targeting type is refused BY NAME; note Pinterest’s four per-level enums differ slightly, so a value valid at one level can still be refused at another. Read-only, 0 credits.",
      "group": "ads",
      "section": "PINTEREST DEEP ANALYTICS (2026-08-05)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "scope": {
            "description": "default account",
            "type": "string",
            "enum": [
              "account",
              "campaign",
              "adGroup",
              "ad"
            ]
          },
          "targetingTypes": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "REQUIRED — e.g. KEYWORD, AGE_BUCKET, GENDER, LOCATION, PLACEMENT, MEDIA_TYPE, TARGETED_INTEREST, PINNER_INTEREST, COUNTRY, REGION"
          },
          "campaignIds": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "adGroupIds": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "adIds": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "since": {
            "type": "string"
          },
          "until": {
            "type": "string"
          },
          "granularity": {
            "type": "string",
            "enum": [
              "TOTAL",
              "DAY",
              "HOUR",
              "WEEK",
              "MONTH"
            ]
          },
          "columns": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "required": [
          "targetingTypes"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "pinterest_audience_insights",
      "title": "Pinterest audience insights",
      "description": "WHO the Pinterest audience IS, rather than what it did — interest categories each carrying an affinity INDEX, plus demographics (ages, countries, devices, genders, metros). Three audiences: YOUR_TOTAL_AUDIENCE, YOUR_ENGAGED_AUDIENCE, and PINTEREST_TOTAL_AUDIENCE as the baseline to compare the other two against. This is an input to a creative brief, not a performance report. SAY THIS WHEN REPORTING: an affinity index is how much MORE likely this audience is to engage with a category than Pinterest’s baseline — it is a comparison, never a count — and when Pinterest flags size_is_upper_bound the audience size is an upper bound, not a measurement. There is no date range: Pinterest returns its current snapshot and names the date it is for. Read-only, 0 credits.",
      "group": "ads",
      "section": "PINTEREST DEEP ANALYTICS (2026-08-05)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "insightType": {
            "description": "default YOUR_TOTAL_AUDIENCE",
            "type": "string",
            "enum": [
              "YOUR_TOTAL_AUDIENCE",
              "YOUR_ENGAGED_AUDIENCE",
              "PINTEREST_TOTAL_AUDIENCE"
            ]
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_business_locations",
      "title": "List Google business listings",
      "description": "List the Google Business Profile listings SHARED WITH THIS BRAND — id, title, address, website and Maps link. These are the only listings anything here can post to or read: one Google login often manages several businesses (an agency manages its clients’), and the user ticks which of them belong to this brand. Call this before posting whenever more than one is shared and let the USER pick: a Post on the wrong storefront is a public mistake Hermoso will not make for them. If nothing is shared, ask the user to choose — list_connector_accounts(\"google_business\") then set_connector_accounts — and never name or guess a listing. Read-only, 0 credits. Needs Google Business Profile connected (Settings ▸ Connectors ▸ Google Business Profile).",
      "group": "publish",
      "section": "GOOGLE BUSINESS PROFILE (2026-07-30). The local-SEO channel: the listing panel on Google Search + Maps.",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "post_to_google_business",
      "title": "Post to Google Business Profile",
      "description": "Publish a Post to the brand’s Google Business Profile — the panel that appears on Google Search and Maps for the business. Text, optionally ONE PHOTO, and a call-to-action button. Google’s Posts API accepts NO VIDEO, so pass a still image. This PUBLISHES immediately and publicly on the business listing — show the user the exact text, photo and button and get an explicit yes BEFORE calling. If the account manages several listings, call list_business_locations first and pass locationId. EVENT and OFFER posts both REQUIRE a title and a start date (Google’s rule). On an OFFER, Google IGNORES the button’s link — pass redeemOnlineUrl instead. A CALL button dials the number on the listing and takes no link. Needs Google Business Profile connected (Settings ▸ Connectors ▸ Google Business Profile).",
      "group": "publish",
      "section": "GOOGLE BUSINESS PROFILE (2026-07-30). The local-SEO channel: the listing panel on Google Search + Maps.",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "hook": {
            "description": "WHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer \"which hooks work\", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. \"direct_callout\", \"mid_problem\", \"before_after\") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.",
            "type": "string"
          },
          "subject": {
            "description": "WHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. \"winter coat\", \"free trial\", \"founder story\"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.",
            "type": "string"
          },
          "summary": {
            "description": "the body text of the Post",
            "type": "string"
          },
          "locationId": {
            "description": "which listing, e.g. 'locations/123' from list_business_locations — only needed when the account manages more than one",
            "type": "string"
          },
          "imageUrl": {
            "description": "a Hermoso render image URL (or an upload_file url) to show on the Post",
            "type": "string"
          },
          "topicType": {
            "description": "default STANDARD",
            "type": "string",
            "enum": [
              "STANDARD",
              "EVENT",
              "OFFER",
              "ALERT"
            ]
          },
          "actionType": {
            "description": "the button on the Post",
            "type": "string",
            "enum": [
              "BOOK",
              "ORDER",
              "SHOP",
              "LEARN_MORE",
              "SIGN_UP",
              "CALL"
            ]
          },
          "link": {
            "description": "the URL the button opens — not for CALL, and ignored on an OFFER",
            "type": "string"
          },
          "title": {
            "description": "headline — REQUIRED for EVENT and OFFER",
            "type": "string"
          },
          "startDate": {
            "description": "YYYY-MM-DD — REQUIRED for EVENT and OFFER",
            "type": "string"
          },
          "endDate": {
            "description": "YYYY-MM-DD, defaults to startDate",
            "type": "string"
          },
          "couponCode": {
            "description": "OFFER only",
            "type": "string"
          },
          "redeemOnlineUrl": {
            "description": "OFFER only — this is the link Google actually uses on an offer",
            "type": "string"
          },
          "termsConditions": {
            "description": "OFFER only",
            "type": "string"
          },
          "languageCode": {
            "description": "BCP-47 language of the Post, default 'en'",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_google_business_posts",
      "title": "List Google Business Profile Posts",
      "description": "List the Posts currently on the brand’s Google Business Profile listing — text, topic type, state (LIVE / PROCESSING / REJECTED / SCHEDULED / RECURRING), button and timestamps. Use it to see what is already showing before writing another, or to get the id of one to remove. Read-only, 0 credits. Needs Google Business Profile connected.",
      "group": "publish",
      "section": "GOOGLE BUSINESS PROFILE (2026-07-30). The local-SEO channel: the listing panel on Google Search + Maps.",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "locationId": {
            "description": "which listing, from list_business_locations — only needed when there is more than one",
            "type": "string"
          },
          "limit": {
            "description": "how many to return, max 100 (default 20)",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_google_business_post",
      "title": "Delete a Google Business Profile Post",
      "description": "Remove a Post from the brand’s Google Business Profile listing. This takes it off Google Search and Maps immediately and CANNOT be undone — confirm with the user first. Pass the full post name from list_google_business_posts. Needs Google Business Profile connected.",
      "group": "publish",
      "section": "GOOGLE BUSINESS PROFILE (2026-07-30). The local-SEO channel: the listing panel on Google Search + Maps.",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "postId": {
            "type": "string",
            "description": "the full post name from list_google_business_posts (accounts/…/locations/…/localPosts/…)"
          }
        },
        "required": [
          "postId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_google_business_reviews",
      "title": "Read the reviews on a Google Business listing",
      "description": "The reviews customers have left on the brand’s Google Business Profile listing — star rating, reviewer, the text, when it landed, and whether the business has replied. For a local business this is the highest-leverage surface there is: an unanswered review sits on the listing next to the ad you paid for. The reply says which ones have NO answer yet, so you can work the list rather than read it. Google reports the listing’s own average rating and total review count alongside the page — use those for \"how are we doing\", never a mean you computed over one page. An empty page is an empty PAGE, not proof the listing has no reviews. Read-only, 0 credits. Needs Google Business Profile connected AND the project approved for Google’s Business Profile APIs (a pending access request, not a setting — the error says so).",
      "group": "publish",
      "section": "GOOGLE BUSINESS PROFILE (2026-07-30). The local-SEO channel: the listing panel on Google Search + Maps.",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "locationId": {
            "description": "which listing — omit when only one is shared with this brand",
            "type": "string"
          },
          "limit": {
            "description": "1–50, default 20",
            "type": "number"
          },
          "orderBy": {
            "description": "default newest first",
            "type": "string",
            "enum": [
              "updateTime desc",
              "updateTime",
              "rating",
              "rating desc"
            ]
          },
          "pageToken": {
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "reply_to_google_business_review",
      "title": "Reply to (or remove a reply from) a Google review",
      "description": "Answer a customer review publicly, as the business, on the brand’s Google Business Profile listing — or delete a reply that is already there. THIS IS AN UPSERT: a listing has exactly one reply per review, so replying to a review that already has an answer REPLACES it rather than adding a second. Google only accepts replies on a VERIFIED listing. Deleting is public and immediate, so it is confirm-gated. Write the reply in the brand’s voice and answer the specific complaint — a generic reply under a one-star review is worse than none. Needs Google Business Profile connected and the project approved.",
      "group": "publish",
      "section": "GOOGLE BUSINESS PROFILE (2026-07-30). The local-SEO channel: the listing panel on Google Search + Maps.",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "reviewId": {
            "type": "string",
            "description": "from list_google_business_reviews"
          },
          "comment": {
            "description": "the public reply text — required unless you are deleting",
            "type": "string"
          },
          "locationId": {
            "description": "which listing — omit when only one is shared",
            "type": "string"
          },
          "delete": {
            "description": "true removes the existing reply instead of writing one",
            "type": "boolean"
          },
          "confirm": {
            "description": "required for delete:true",
            "type": "boolean"
          }
        },
        "required": [
          "reviewId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_google_business_questions",
      "title": "Read the Q&A on a Google Business listing",
      "description": "The questions the public has asked on the brand’s Google Business Profile listing, with the answers so far and how many people upvoted each question. Unanswered questions sit publicly on the listing and are read as \"this business does not respond\" — the reply names the ones with no answer at all. Read-only, 0 credits. Needs Google Business Profile connected and the project approved.",
      "group": "publish",
      "section": "GOOGLE BUSINESS PROFILE (2026-07-30). The local-SEO channel: the listing panel on Google Search + Maps.",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "locationId": {
            "type": "string"
          },
          "limit": {
            "description": "1–20, default 10",
            "type": "number"
          },
          "pageToken": {
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "answer_google_business_question",
      "title": "Answer a question on a Google Business listing",
      "description": "Post the business’s answer to a public question on the brand’s Google Business Profile listing, or delete the answer already there. THIS IS AN UPSERT — one answer per account, so answering again REPLACES the previous one rather than adding a second. Deleting is public and immediate and is confirm-gated. Needs Google Business Profile connected and the project approved.",
      "group": "publish",
      "section": "GOOGLE BUSINESS PROFILE (2026-07-30). The local-SEO channel: the listing panel on Google Search + Maps.",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "questionId": {
            "type": "string",
            "description": "from list_google_business_questions"
          },
          "text": {
            "description": "the answer — required unless deleting",
            "type": "string"
          },
          "locationId": {
            "type": "string"
          },
          "delete": {
            "type": "boolean"
          },
          "confirm": {
            "description": "required for delete:true",
            "type": "boolean"
          }
        },
        "required": [
          "questionId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "google_business_insights",
      "title": "Google Business Profile performance",
      "description": "How the brand’s Google Business Profile listing actually performed — impressions on Google Search and Maps (desktop and mobile), calls, website clicks, direction requests, messages and bookings — over the last N days. For a local business this is the real-world demand signal, and it is the number an ad campaign should be judged against. NOTE: Google discontinued PER-POST insights in February 2023 and published no replacement, so these are listing-level figures and per-post performance genuinely does not exist in any API — do not promise it. Read-only, 0 credits. Needs Google Business Profile connected.",
      "group": "publish",
      "section": "GOOGLE BUSINESS PROFILE (2026-07-30). The local-SEO channel: the listing panel on Google Search + Maps.",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "locationId": {
            "description": "which listing, from list_business_locations",
            "type": "string"
          },
          "days": {
            "description": "how many days back, default 30",
            "type": "number"
          },
          "metrics": {
            "description": "optional subset of Google’s daily metrics (BUSINESS_IMPRESSIONS_DESKTOP_MAPS, BUSINESS_IMPRESSIONS_DESKTOP_SEARCH, BUSINESS_IMPRESSIONS_MOBILE_MAPS, BUSINESS_IMPRESSIONS_MOBILE_SEARCH, BUSINESS_CONVERSATIONS, BUSINESS_DIRECTION_REQUESTS, CALL_CLICKS, WEBSITE_CLICKS, BUSINESS_BOOKINGS, BUSINESS_FOOD_ORDERS, BUSINESS_FOOD_MENU_CLICKS). Omit for all of them. An unknown name is refused rather than quietly dropped, so a total is never reported under a metric you did not get.",
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "get_business_location",
      "title": "Read a Google Business Profile listing",
      "description": "Read everything Google holds on one of the brand’s Google Business Profile listings — business name, address, phone numbers, website, categories, description, regular and special hours, service area, labels, store code, open state, and whether the listing can carry a Post at all. This is the listing AS THE MERCHANT LAST SET IT, which is exactly what update_business_location edits; it can differ from what Google Maps shows today, because Google and the public can suggest changes on top. Call it before offering to change anything, and to answer “what does our Google listing actually say?”. Read-only, 0 credits. Needs Google Business Profile connected (Settings ▸ Connectors ▸ Google Business Profile).",
      "group": "publish",
      "section": "THE LISTING ITSELF, AND THE ACCOUNT UNDER IT (2026-08-04). These three ride the v1 hosts that are already",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "locationId": {
            "description": "which listing, e.g. 'locations/123' from list_business_locations — only needed when more than one is shared with this brand",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_business_location",
      "title": "Update a Google Business Profile listing",
      "description": "Change the brand’s Google Business Profile listing — hours, phone, website, description, categories, service area, labels, store code, address or the business name. THIS EDITS THE PANEL ON GOOGLE SEARCH AND MAPS, immediately and publicly: there is no draft, no preview and no undo. Pass ONLY what changes, in `fields`, keyed by Google’s own field names: websiteUri, phoneNumbers, regularHours, specialHours, moreHours, profile, categories, storefrontAddress, title, labels, storeCode, openInfo, serviceArea, serviceItems, latlng, adWordsLocationExtensions, relationshipData. CALL IT WITHOUT confirm FIRST — nothing is written, Google validates the payload for you, and you get back the CURRENT value of every field you are about to change, so you can show the user the exact before-and-after; then call again with confirm:true once they approve. Changing the business NAME (title) or ADDRESS (storefrontAddress) additionally needs confirmName set to the listing’s CURRENT name, because Google can suspend a listing over either. Output-only fields (metadata) and immutable ones (languageCode) are refused by name rather than dropped. Use dryRun:true to validate a payload with Google and write nothing. Needs Google Business Profile connected.",
      "group": "publish",
      "section": "THE LISTING ITSELF, AND THE ACCOUNT UNDER IT (2026-08-04). These three ride the v1 hosts that are already",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "fields": {
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {},
            "description": "the changes, keyed by Google’s Location field names, e.g. {\"websiteUri\":\"https://example.com\"} or {\"regularHours\":{\"periods\":[…]}}"
          },
          "locationId": {
            "description": "which listing, from list_business_locations — only needed when more than one is shared with this brand",
            "type": "string"
          },
          "confirm": {
            "description": "true ONLY after the user has seen the exact before-and-after and approved it",
            "type": "boolean"
          },
          "confirmName": {
            "description": "the listing’s CURRENT name, echoed back — required when changing title or storefrontAddress",
            "type": "string"
          },
          "dryRun": {
            "description": "validate with Google and write nothing (needs no confirm)",
            "type": "boolean"
          }
        },
        "required": [
          "fields"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "google_business_account",
      "title": "Google Business Profile account for a listing",
      "description": "Read the Google Business Profile ACCOUNT that owns one of the brand’s listings — the account name, its type (a personal Google account, a location group, a user group or an organization), the connected user’s role on it (primary owner / owner / manager / site manager), the account’s verification state and the permission level. Use it to answer “can we actually edit this listing?” and “whose account is it on?” before offering an edit that Google would refuse anyway. It reads exactly ONE account — the parent of a listing already shared with this brand — and never lists the other accounts the connected Google login can reach; that roster belongs to the account picker (list_connector_accounts). Read-only, 0 credits. Needs Google Business Profile connected.",
      "group": "publish",
      "section": "THE LISTING ITSELF, AND THE ACCOUNT UNDER IT (2026-08-04). These three ride the v1 hosts that are already",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "locationId": {
            "description": "which listing, from list_business_locations — only needed when more than one is shared with this brand",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "post_to_youtube",
      "title": "Post a video to YouTube",
      "description": "Publish a finished video to the brand’s connected YouTube channel. Pass a Hermoso render URL (or an upload_file url for a local/external file). DEFAULTS TO UNLISTED (link-only — not on the channel, not searchable, but shareable by link AND usable as a YouTube/Google ad). Pass privacy:\"public\" to put it ON the channel (a public publish — confirm with the user first) or privacy:\"private\" for eyes-only. Do NOT use \"private\" for anything meant to run as an ad — private videos CANNOT be used as ads; unlisted is the ad-ready setting. Needs a connected YouTube channel (Settings ▸ Connectors ▸ YouTube).",
      "group": "publish",
      "section": "THE LISTING ITSELF, AND THE ACCOUNT UNDER IT (2026-08-04). These three ride the v1 hosts that are already",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "hook": {
            "description": "WHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer \"which hooks work\", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. \"direct_callout\", \"mid_problem\", \"before_after\") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.",
            "type": "string"
          },
          "subject": {
            "description": "WHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. \"winter coat\", \"free trial\", \"founder story\"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.",
            "type": "string"
          },
          "videoUrl": {
            "type": "string",
            "description": "the video to post — a Hermoso render URL or an upload_file url"
          },
          "title": {
            "description": "video title (≤100 chars)",
            "type": "string"
          },
          "description": {
            "description": "video description (≤5000 chars)",
            "type": "string"
          },
          "tags": {
            "description": "up to 30 tags",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "privacy": {
            "description": "default unlisted (anyone-with-link, ad-ready); public = live + searchable on the channel (confirm first); private = eyes-only (cannot run as an ad)",
            "type": "string",
            "enum": [
              "private",
              "unlisted",
              "public"
            ]
          }
        },
        "required": [
          "videoUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "youtube_channel",
      "title": "Get the connected YouTube channel",
      "description": "Read the brand’s connected YouTube channel — title + subscriber / view / video counts (for reporting). Needs a connected YouTube channel.",
      "group": "publish",
      "section": "THE LISTING ITSELF, AND THE ACCOUNT UNDER IT (2026-08-04). These three ride the v1 hosts that are already",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_youtube_videos",
      "title": "List the brand’s own YouTube uploads",
      "description": "List the connected channel’s OWN recent uploads — video id, title, publish date and privacy — so you can resolve a video WITHOUT asking the user for a link. Call this whenever the user names a video loosely (\"my latest\", \"the shorts one\", part of a title) and match it yourself; only ask them when two titles are genuinely ambiguous. This is the tool that gets you the videoId every other YouTube tool needs — youtube_channel returns counts only, and search_youtube searches the PUBLIC index, not your uploads. Includes UNLISTED and PRIVATE videos, which are invisible to any public search. Read-only, 0 credits. Needs a connected YouTube channel.",
      "group": "publish",
      "section": "YOUTUBE: MEASURE + MANAGE (2026-07-30). We requested yt-analytics.readonly and youtube.force-ssl from day one",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "limit": {
            "description": "how many recent uploads to return (default 25, max 50)",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "youtube_video_insights",
      "title": "Performance of one of your YouTube videos",
      "description": "Per-VIDEO performance for a video on the connected channel — views, estimated minutes watched, average view duration, average view PERCENTAGE (the retention number that tells you whether the hook held), likes, comments, shares and subscribers gained. Use it for \"how did that video do\", \"which upload performed best\", or to judge an ad before spending more behind it. youtube_channel only returns channel-wide totals and cannot answer this. Defaults to the last 28 days; pass startDate/endDate (YYYY-MM-DD) for another window. Read-only, 0 credits. Needs a connected YouTube channel.",
      "group": "publish",
      "section": "YOUTUBE: MEASURE + MANAGE (2026-07-30). We requested yt-analytics.readonly and youtube.force-ssl from day one",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "videoId": {
            "type": "string",
            "description": "the YouTube video id (the v= part of the watch URL, or the videoId returned by post_to_youtube)"
          },
          "startDate": {
            "description": "YYYY-MM-DD, default 28 days ago",
            "type": "string"
          },
          "endDate": {
            "description": "YYYY-MM-DD, default today",
            "type": "string"
          }
        },
        "required": [
          "videoId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "youtube_channel_report",
      "title": "YouTube analytics broken down by dimension",
      "description": "The YouTube Analytics reports that say WHERE views came from, WHO watched and WHERE they stopped watching — the questions youtube_channel (totals) and youtube_video_insights (one video, flat) cannot answer. Pick a report: day / month (time series) · country / province (US states) / city / dma (geography) · trafficSource (search vs browse vs suggested vs shorts feed vs external — the single most useful one for judging a thumbnail and title) · trafficSourceDetail (the actual search terms, inside ONE source — pass parent, e.g. \"YT_SEARCH\") · playbackLocation / playbackLocationDetail (which sites embedded it) · device / operatingSystem · demographics (age + gender) · sharingService · subscribedStatus · audienceRetention (the drop-off CURVE, 100 points across ONE video — the read that tells you whether the hook held and exactly when people left) · topVideos (the channel’s best in the window). Scope it to one or more videoIds, or omit for the whole channel. An unknown report name is refused WITH the list rather than quietly swapped. TWO THINGS TO SAY OUT LOUD WHEN REPORTING: demographics returns viewerPercentage and NOTHING else — YouTube publishes no absolute demographic counts, so never convert it into a number of viewers — and a capped report (city 250, topVideos 200, the *Detail reports 25) is the TOP N, not the whole set. Zero rows means missing data for that window, never zero views. Read-only, 0 credits.",
      "group": "publish",
      "section": "YOUTUBE: MEASURE + MANAGE (2026-07-30). We requested yt-analytics.readonly and youtube.force-ssl from day one",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "report": {
            "description": "which report (default day)",
            "type": "string",
            "enum": [
              "day",
              "month",
              "country",
              "province",
              "city",
              "dma",
              "trafficSource",
              "trafficSourceDetail",
              "playbackLocation",
              "playbackLocationDetail",
              "device",
              "operatingSystem",
              "demographics",
              "sharingService",
              "subscribedStatus",
              "audienceRetention",
              "topVideos"
            ]
          },
          "videoIds": {
            "description": "narrow to these videos — audienceRetention requires exactly ONE, because the curve is per video",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "parent": {
            "description": "required by the *Detail reports: the ONE parent to drill into, e.g. \"YT_SEARCH\" / \"SUBSCRIBER\" / \"RELATED_VIDEO\" for trafficSourceDetail, \"EMBEDDED\" for playbackLocationDetail",
            "type": "string"
          },
          "startDate": {
            "description": "YYYY-MM-DD, default 28 days ago",
            "type": "string"
          },
          "endDate": {
            "description": "YYYY-MM-DD, default today",
            "type": "string"
          },
          "limit": {
            "description": "rows, within YouTube’s own cap for that report",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "youtube_bulk_report",
      "title": "YouTube bulk report (thumbnail CTR, cards, end screens)",
      "description": "THE ONLY PLACE YOUTUBE PUBLISHES THUMBNAIL IMPRESSIONS AND THUMBNAIL CTR. This is a different API from youtube_channel_report — YouTube’s bulk Reporting API — and for a product that generates thumbnails it is the number that says whether the thumbnail actually worked. Reports: thumbnails (impressions + CTR per video per day) · thumbnails_by_source (the same, split by traffic source, traffic source DETAIL, device and OS) · cards (per-card impressions, clicks and click rate by card_id) · end_screens (per end-screen element) · traffic_source (with the UNCAPPED traffic_source_detail — youtube_channel_report caps that at 25 rows) · basic. IT IS SCHEDULED, NOT ON-DEMAND, AND THIS IS THE ONE THING YOU MUST EXPLAIN TO THE USER: the first call SCHEDULES a job and returns NO DATA. YouTube then writes one CSV per 24-hour Pacific day — the first within 48 hours — plus a backfill of the 30 days before scheduling, and files expire after 60 days. It can NEVER answer about a period before the job existed, so \"we have no thumbnail history yet\" is a real and correct answer on day one. An unknown report name is refused with the list. Zero rows means missing data for that window, never zero impressions. Read-only, 0 credits.",
      "group": "publish",
      "section": "YOUTUBE REPORTING API — BULK CSV JOBS (2026-08-05)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "report": {
            "description": "default thumbnails",
            "type": "string",
            "enum": [
              "thumbnails",
              "thumbnails_by_source",
              "cards",
              "end_screens",
              "traffic_source",
              "basic"
            ]
          },
          "days": {
            "description": "how many recent daily files to read (1–14, default 7)",
            "type": "number"
          },
          "since": {
            "description": "YYYY-MM-DD — only files whose data starts on or after this",
            "type": "string"
          },
          "until": {
            "description": "YYYY-MM-DD — only files whose data starts before this",
            "type": "string"
          },
          "schedule": {
            "description": "false = do not create the job if it is missing; just report that none exists",
            "type": "boolean"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_youtube_report_jobs",
      "title": "List YouTube bulk reporting jobs",
      "description": "The YouTube BULK reporting jobs running on this channel — which report each one generates, its report type id, and when it was scheduled. Call this to find out whether thumbnail-CTR history is already accumulating, and since when, BEFORE promising a user a number: the bulk API can only answer about days after a job existed. Read-only, 0 credits.",
      "group": "publish",
      "section": "YOUTUBE REPORTING API — BULK CSV JOBS (2026-08-05)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_youtube_report_job",
      "title": "Delete a YouTube bulk reporting job",
      "description": "Stop a YouTube bulk reporting job. IRREVERSIBLE IN A WAY THAT IS EASY TO MISS: the job IS the history — deleting it discards every daily CSV it has accumulated, and a replacement job starts over with only a 30-day backfill, so anything older than that is gone for good. Call WITHOUT confirm first: nothing is deleted and you get the real job read back from YouTube (its report type and when it was scheduled) to show the user. Then call again with confirm:true. Needs a connected YouTube channel.",
      "group": "publish",
      "section": "YOUTUBE REPORTING API — BULK CSV JOBS (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "jobId": {
            "type": "string",
            "description": "from list_youtube_report_jobs"
          },
          "confirm": {
            "description": "true only after the user has seen the job and said yes",
            "type": "boolean"
          }
        },
        "required": [
          "jobId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_youtube_video",
      "title": "Update a YouTube video’s title, description, tags or privacy",
      "description": "Edit an existing video on the connected channel: title, description, tags, and/or privacy (unlisted | public | private). THIS IS HOW YOU FLIP AN UNLISTED UPLOAD PUBLIC — post_to_youtube defaults to UNLISTED, and without this there was no way to publish it afterwards. Making a video PUBLIC puts it on the channel where anyone can find it, so show the user exactly what will change and get an explicit yes before calling with privacy:\"public\". Fields you omit are left untouched. Needs a connected YouTube channel.",
      "group": "publish",
      "section": "YOUTUBE REPORTING API — BULK CSV JOBS (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "videoId": {
            "type": "string",
            "description": "the YouTube video id"
          },
          "title": {
            "description": "≤100 chars",
            "type": "string"
          },
          "description": {
            "description": "≤5000 chars",
            "type": "string"
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "privacy": {
            "description": "public = live on the channel; confirm with the user first",
            "type": "string",
            "enum": [
              "unlisted",
              "public",
              "private"
            ]
          }
        },
        "required": [
          "videoId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_youtube_video",
      "title": "Delete a video from the connected YouTube channel",
      "description": "PERMANENTLY delete a video from the connected YouTube channel. IRREVERSIBLE — YouTube has no trash and no undelete, and the video's views, comments and every link or embed pointing at it go with it. Call it WITHOUT confirm first: nothing is deleted, and it reports the video's REAL title, privacy, view count and comment count read back from YouTube. Show the user that, get an unambiguous yes, then call again with confirm:true — plus, once the video is public or has any views or comments, confirmTitle set to its exact title. confirmTitle exists because confirming that you meant to delete SOMETHING does not prove you aimed at the right video, and a wrong id must not be confirmable blind. If the user only wants it out of public view, use update_youtube_video(privacy:\"private\") instead — that is reversible and this is not. Get the videoId from list_youtube_videos. 0 credits. Needs a connected YouTube channel.",
      "group": "publish",
      "section": "YOUTUBE REPORTING API — BULK CSV JOBS (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "videoId": {
            "type": "string",
            "description": "the YouTube video id (from list_youtube_videos)"
          },
          "confirm": {
            "description": "REQUIRED true — deletion is permanent and cannot be undone",
            "type": "boolean"
          },
          "confirmTitle": {
            "description": "the video's EXACT title as the unconfirmed call reported it — required once the video is public or has any views or comments",
            "type": "string"
          }
        },
        "required": [
          "videoId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_youtube_thumbnail",
      "title": "Set the custom thumbnail on a YouTube video",
      "description": "Set the CUSTOM THUMBNAIL on a video already on the connected channel, using a Hermoso image — a make_thumbnail render, a generated image, or a frame. The thumbnail is the single biggest lever on YouTube click-through and YouTube otherwise auto-picks a frame, so a published video without one is leaving reach on the table. It changes ONLY the thumbnail — video, title and privacy are untouched — but it is public and immediate, so show the user which image is going on which video and get a yes first. Custom thumbnails require a VERIFIED YouTube channel (a phone number at youtube.com/verify); without it YouTube refuses and the error says so. Images over YouTube’s 2MB cap are compressed automatically. The image must be Hermoso-HOSTED, which is not the same as Hermoso-GENERATED: the user’s own artwork works, put it through upload_file first and pass the URL that returns. An arbitrary external host is refused. 0 credits. Needs a connected YouTube channel.",
      "group": "publish",
      "section": "YOUTUBE REPORTING API — BULK CSV JOBS (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "videoId": {
            "type": "string",
            "description": "the YouTube video id (what post_to_youtube returned)"
          },
          "imageUrl": {
            "type": "string",
            "description": "a Hermoso-hosted image URL — a make_thumbnail / list_library render, OR any image of the user’s own passed through upload_file first. An arbitrary external host is refused."
          }
        },
        "required": [
          "videoId",
          "imageUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_youtube_comments",
      "title": "Read comments on one of your YouTube videos",
      "description": "Read the comments under a video on the connected channel — the questions, objections and exact wording real viewers use. Same raw material for ad copy that list_meta_comments gives you on Meta. Returns author, text, like count, timestamp and reply count, newest first. Read-only, 0 credits. Needs a connected YouTube channel.",
      "group": "publish",
      "section": "YOUTUBE REPORTING API — BULK CSV JOBS (2026-08-05)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "videoId": {
            "type": "string",
            "description": "the YouTube video id"
          },
          "limit": {
            "description": "max comments, default 25, cap 100",
            "type": "number"
          }
        },
        "required": [
          "videoId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "reply_to_youtube_comment",
      "title": "Reply to a YouTube comment",
      "description": "Post a public reply to a comment on the connected channel, as the channel. This is PUBLIC and immediate — show the user the exact reply text and get an explicit yes before calling. Get commentId from list_youtube_comments. Needs a connected YouTube channel.",
      "group": "publish",
      "section": "YOUTUBE REPORTING API — BULK CSV JOBS (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "commentId": {
            "type": "string",
            "description": "id of the comment to reply to (from list_youtube_comments)"
          },
          "text": {
            "type": "string",
            "description": "the reply, shown publicly under the video"
          }
        },
        "required": [
          "commentId",
          "text"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "tiktok_creator_info",
      "title": "Read the connected TikTok creator’s posting options",
      "description": "Read the connected TikTok creator’s REAL posting options BEFORE posting: which privacy levels THEY are allowed to use, whether comments / duet / stitch are available on their account, their maximum video length, and their nickname. TikTok REQUIRES that the user is shown these actual options and picks a privacy level — never assume or default one. Call this first, show the options, get the user’s pick, then call post_to_tiktok with destination:\"post\". The SAME privacy options govern PHOTO posts (slideshows), not just video — TikTok takes the same four levels on both. Needs TikTok connected (Settings ▸ Connectors ▸ TikTok).",
      "group": "publish",
      "section": "YOUTUBE REPORTING API — BULK CSV JOBS (2026-08-05)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "post_to_tiktok",
      "title": "Post a video or photo post to TikTok",
      "description": "Publish to the user’s connected TikTok account — a finished VIDEO, or a PHOTO POST (TikTok’s photo/slideshow format). A photo post carries 1 to 35 images and ONE image is simply a one-slide photo post, so there is nothing special to do for a single picture: pass imageUrls, in the order the slides should appear, and optionally coverIndex. Pass videoUrl for a video. Never pass both — TikTok has no mixed post. TWO destinations either way: destination:\"post\" puts it LIVE on their profile now — that requires `privacy`, and you must call tiktok_creator_info first, show the creator’s real privacy options and get an explicit yes before calling. destination:\"draft\" (the default, and the safer one) sends it to TikTok for the user to finish and post themselves from the app. Pass Hermoso render URLs (or upload_file urls for local/external files). Needs TikTok connected (Settings ▸ Connectors ▸ TikTok).",
      "group": "publish",
      "section": "YOUTUBE REPORTING API — BULK CSV JOBS (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "hook": {
            "description": "WHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer \"which hooks work\", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. \"direct_callout\", \"mid_problem\", \"before_after\") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.",
            "type": "string"
          },
          "subject": {
            "description": "WHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. \"winter coat\", \"free trial\", \"founder story\"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.",
            "type": "string"
          },
          "videoUrl": {
            "description": "the video to post — a Hermoso render URL or an upload_file url. Omit for a photo post.",
            "type": "string"
          },
          "imageUrls": {
            "description": "a PHOTO POST: 1–35 image URLs in slide order. One url = a single-image photo post. Do not combine with videoUrl.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "coverIndex": {
            "description": "photo posts: which slide is the cover, 0-based. Default 0 (the first slide).",
            "type": "number"
          },
          "destination": {
            "description": "\"post\" = live on the profile now (needs privacy + an explicit user yes); \"draft\" = to TikTok for the user to review and post themselves. Default \"draft\".",
            "type": "string",
            "enum": [
              "post",
              "draft"
            ]
          },
          "title": {
            "description": "the caption — hashtags go here (video ≤2200 chars, photo post ≤4000)",
            "type": "string"
          },
          "photoTitle": {
            "description": "photo posts only: a short title above the caption (≤90 chars). Defaults to the caption’s first line.",
            "type": "string"
          },
          "privacy": {
            "description": "REQUIRED for destination:\"post\", for photos and video alike. Must be one the creator actually allows — read them from tiktok_creator_info, never guess.",
            "type": "string",
            "enum": [
              "PUBLIC_TO_EVERYONE",
              "MUTUAL_FOLLOW_FRIENDS",
              "FOLLOWER_OF_CREATOR",
              "SELF_ONLY"
            ]
          },
          "disableComment": {
            "type": "boolean"
          },
          "disableDuet": {
            "description": "video only — TikTok has no duet on a photo post",
            "type": "boolean"
          },
          "disableStitch": {
            "description": "video only — TikTok has no stitch on a photo post",
            "type": "boolean"
          },
          "autoAddMusic": {
            "description": "photo posts only: let TikTok add a recommended track (default true — a silent slideshow reads as broken)",
            "type": "boolean"
          },
          "coverTimestampMs": {
            "description": "video only: which frame to use as the cover, in ms",
            "type": "number"
          },
          "brandedContent": {
            "description": "discloses a paid partnership — cannot be combined with SELF_ONLY privacy",
            "type": "boolean"
          },
          "yourBrand": {
            "description": "discloses that this promotes the creator’s own brand",
            "type": "boolean"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "tiktok_account",
      "title": "Read the connected TikTok account",
      "description": "Read the connected TikTok account: display name, username, bio, verified status, and their follower / following / total-likes / video counts. Use it for “how many followers do we have on TikTok”, “how is our TikTok doing”, or to confirm whose account is linked before posting. Read-only. Needs TikTok connected (Settings ▸ Connectors ▸ TikTok).",
      "group": "publish",
      "section": "YOUTUBE REPORTING API — BULK CSV JOBS (2026-08-05)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_tiktok_videos",
      "title": "List the connected account’s TikTok posts",
      "description": "List the connected account’s own recent PUBLIC TikTok posts with per-video stats — views, likes, comments, shares, duration, cover image and link. Use it for “how did our last TikToks do”, “which of our videos performed best”, or to pick a reference before making a new ad. Only ever the connected user’s OWN videos. Read-only. Needs TikTok connected.",
      "group": "publish",
      "section": "YOUTUBE REPORTING API — BULK CSV JOBS (2026-08-05)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "limit": {
            "description": "1-20, default 10",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "upload_meta_asset",
      "title": "Upload an asset to a Meta ad account",
      "description": "Upload creative(s) — a finished Hermoso ad OR arbitrary user files (e.g. a folder of media from the user’s desktop) — into a connected ad account’s ASSET LIBRARY so the user or a later ad-build step can use them in their OWN campaigns. Pass `url` for one file, or `urls` (up to 20) to BULK-upload in a single call. Each accepts a public https URL, a data: URI, or a Hermoso /generated path; for LOCAL files call upload_file first and pass the url(s) it returns. Image → image hash; video → video id. Pass adAccountId from list_meta_pages.",
      "group": "publish",
      "section": "YOUTUBE REPORTING API — BULK CSV JOBS (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string",
            "description": "ad account id (digits or act_… — from list_meta_pages)"
          },
          "url": {
            "description": "a single public https URL / data: URI / /generated path",
            "type": "string"
          },
          "urls": {
            "description": "up to 20 media URLs/paths for a one-call BULK upload",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "kind": {
            "description": "inferred from the URL if omitted",
            "type": "string",
            "enum": [
              "image",
              "video"
            ]
          },
          "name": {
            "description": "a label for the asset",
            "type": "string"
          }
        },
        "required": [
          "adAccountId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_meta_campaign",
      "title": "Create a Meta ad campaign (paused)",
      "description": "Create a campaign on a connected Meta ad account. Always created PAUSED — it spends NOTHING until you activate it with set_meta_campaign_status(confirm:true). Optionally set a dailyBudgetUsd. Pass adAccountId (from list_meta_pages) + an objective. Needs ads-management permission on the connected account.",
      "group": "publish",
      "section": "YOUTUBE REPORTING API — BULK CSV JOBS (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "campaign name"
          },
          "adAccountId": {
            "type": "string",
            "description": "ad account id (digits or act_… — from list_meta_pages)"
          },
          "objective": {
            "description": "default OUTCOME_TRAFFIC",
            "type": "string",
            "enum": [
              "OUTCOME_TRAFFIC",
              "OUTCOME_AWARENESS",
              "OUTCOME_ENGAGEMENT",
              "OUTCOME_LEADS",
              "OUTCOME_SALES",
              "OUTCOME_APP_PROMOTION"
            ]
          },
          "dailyBudgetUsd": {
            "description": "optional campaign daily budget in USD (1–10000); real spend once ACTIVE",
            "type": "number"
          }
        },
        "required": [
          "name",
          "adAccountId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_meta_campaign_status",
      "title": "Activate or pause a Meta campaign",
      "description": "Turn a campaign ON (ACTIVE) or OFF (PAUSED). ACTIVATING STARTS REAL AD SPEND — you MUST first show the user the campaign name + its daily budget, get an explicit yes, then call with status:\"ACTIVE\" and confirm:true. Pausing is always safe. Needs ads-management permission.",
      "group": "publish",
      "section": "YOUTUBE REPORTING API — BULK CSV JOBS (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "campaignId": {
            "type": "string",
            "description": "the campaign id (from create_meta_campaign)"
          },
          "status": {
            "type": "string",
            "enum": [
              "ACTIVE",
              "PAUSED"
            ],
            "description": "ACTIVE = start spending; PAUSED = stop"
          },
          "confirm": {
            "description": "REQUIRED true to activate (real spend) — set only after the user explicitly approved the budget",
            "type": "boolean"
          }
        },
        "required": [
          "campaignId",
          "status"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_meta_ad",
      "title": "Build a full Meta ad (campaign → ad set → ad, paused)",
      "description": "Build a complete, ready-to-run Meta ad: campaign → ad set (FULL targeting + budget + schedule + bidding) → creative → ad(s), ALL created PAUSED — it spends NOTHING until you activate the campaign with set_meta_campaign_status(confirm:true). This is the \"create a campaign and put the ads on it\" path. IMAGE, VIDEO (uploaded, transcoded and thumbnailed for you) and CAROUSEL (format:\"carousel\", 2–10 cards each with its own headline/description/link) all work. Targeting is the `targeting` object: geo down to cities with a radius, age, gender, interests, behaviours, custom audiences and lookalikes, languages, placements, devices and OS. For a conversion objective pass pixelId + conversionEvent and the ad set optimizes for that conversion. Schedule with startTime/endTime + dayparting; bid with bidStrategy + bidAmountUsd/minRoas; use lifetimeBudgetUsd (with endTime) for a fixed flight. Attach to an existing campaign with campaignId or an existing ad set with adSetId. Everything is READ BACK from Meta before you are told it exists — print the returned summary verbatim (it now carries Meta-rendered PREVIEW LINKS for the first ad, valid 24 hours — hand them to the user so they can see the ad; preview_meta_ad renders any ad in any placement). Needs ads-management on the connected account.",
      "group": "ads",
      "section": "Meta ad-set targeting, shared by create_meta_ad and create_meta_adset. Ids come from find_meta_audiences —",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string",
            "description": "ad account id (act_… or digits — from list_meta_pages)"
          },
          "format": {
            "description": "auto = one ad per asset (image or video); carousel = ONE multi-card ad",
            "type": "string",
            "enum": [
              "auto",
              "carousel"
            ]
          },
          "imageUrl": {
            "description": "public https image URL for the ad creative",
            "type": "string"
          },
          "imageUrls": {
            "description": "several image URLs → one ad each, or the carousel cards in order",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "videoUrl": {
            "description": "a video URL → a real Meta VIDEO ad (uploaded + transcoded + thumbnailed for you)",
            "type": "string"
          },
          "thumbnailUrl": {
            "description": "custom video thumbnail (otherwise Meta picks a frame)",
            "type": "string"
          },
          "message": {
            "description": "primary ad text",
            "type": "string"
          },
          "headline": {
            "description": "headline",
            "type": "string"
          },
          "description": {
            "description": "the smaller description line under the headline",
            "type": "string"
          },
          "cards": {
            "description": "carousel cards in order — each may set its own headline/description/link",
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "headline": {
                  "type": "string"
                },
                "description": {
                  "type": "string"
                },
                "link": {
                  "type": "string"
                }
              }
            }
          },
          "carouselEndCard": {
            "description": "append the Page end card to a carousel",
            "type": "boolean"
          },
          "link": {
            "description": "destination URL (defaults to the brand domain)",
            "type": "string"
          },
          "cta": {
            "description": "call-to-action, e.g. SHOP_NOW / LEARN_MORE / SIGN_UP (default LEARN_MORE)",
            "type": "string"
          },
          "objective": {
            "description": "default OUTCOME_TRAFFIC",
            "type": "string",
            "enum": [
              "OUTCOME_TRAFFIC",
              "OUTCOME_AWARENESS",
              "OUTCOME_ENGAGEMENT",
              "OUTCOME_LEADS",
              "OUTCOME_SALES"
            ]
          },
          "dailyBudgetUsd": {
            "description": "ad-set daily budget USD (1–10000, default 10) — spends only once ACTIVE",
            "type": "number"
          },
          "lifetimeBudgetUsd": {
            "description": "a fixed total instead of a daily budget — REQUIRES endTime",
            "type": "number"
          },
          "country": {
            "description": "2-letter shorthand when you are not passing full targeting (default US)",
            "type": "string"
          },
          "targeting": {
            "description": "full Meta ad-set targeting — age, gender, geo, interests, behaviours, audiences, languages, placements, devices. Use EXACTLY these key names: an unrecognised one (e.g. geoLocations) is REFUSED by name — it is never dropped, because a dropped geo key used to fall back to targeting the United States.",
            "type": "object",
            "properties": {
              "geo": {
                "description": "where the ad runs",
                "type": "object",
                "properties": {
                  "countries": {
                    "description": "2-letter codes, e.g. [\"US\",\"CA\"]",
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "regions": {
                    "description": "region KEYS from find_meta_audiences(type:\"adgeolocation\")",
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "key": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "key"
                      ]
                    }
                  },
                  "cities": {
                    "description": "city KEYS; radius works here (10–50 mi / 17–80 km)",
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "key": {
                          "type": "string"
                        },
                        "radius": {
                          "type": "number"
                        },
                        "distanceUnit": {
                          "type": "string",
                          "enum": [
                            "mile",
                            "kilometer"
                          ]
                        }
                      },
                      "required": [
                        "key"
                      ]
                    }
                  },
                  "zips": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "key": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "key"
                      ]
                    }
                  },
                  "geoMarkets": {
                    "description": "DMA keys, e.g. {key:\"DMA:807\"}",
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "key": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "key"
                      ]
                    }
                  },
                  "customLocations": {
                    "description": "drop a pin + radius",
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "latitude": {
                          "type": "number"
                        },
                        "longitude": {
                          "type": "number"
                        },
                        "radius": {
                          "type": "number"
                        },
                        "distanceUnit": {
                          "type": "string",
                          "enum": [
                            "mile",
                            "kilometer"
                          ]
                        }
                      },
                      "required": [
                        "latitude",
                        "longitude"
                      ]
                    }
                  },
                  "locationTypes": {
                    "description": "people who LIVE there vs were recently there",
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "home",
                        "recent",
                        "travel_in"
                      ]
                    }
                  }
                },
                "additionalProperties": {}
              },
              "excludedGeo": {
                "description": "places to exclude",
                "type": "object",
                "properties": {
                  "countries": {
                    "description": "2-letter codes, e.g. [\"US\",\"CA\"]",
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "regions": {
                    "description": "region KEYS from find_meta_audiences(type:\"adgeolocation\")",
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "key": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "key"
                      ]
                    }
                  },
                  "cities": {
                    "description": "city KEYS; radius works here (10–50 mi / 17–80 km)",
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "key": {
                          "type": "string"
                        },
                        "radius": {
                          "type": "number"
                        },
                        "distanceUnit": {
                          "type": "string",
                          "enum": [
                            "mile",
                            "kilometer"
                          ]
                        }
                      },
                      "required": [
                        "key"
                      ]
                    }
                  },
                  "zips": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "key": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "key"
                      ]
                    }
                  },
                  "geoMarkets": {
                    "description": "DMA keys, e.g. {key:\"DMA:807\"}",
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "key": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "key"
                      ]
                    }
                  },
                  "customLocations": {
                    "description": "drop a pin + radius",
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "latitude": {
                          "type": "number"
                        },
                        "longitude": {
                          "type": "number"
                        },
                        "radius": {
                          "type": "number"
                        },
                        "distanceUnit": {
                          "type": "string",
                          "enum": [
                            "mile",
                            "kilometer"
                          ]
                        }
                      },
                      "required": [
                        "latitude",
                        "longitude"
                      ]
                    }
                  },
                  "locationTypes": {
                    "description": "people who LIVE there vs were recently there",
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "home",
                        "recent",
                        "travel_in"
                      ]
                    }
                  }
                },
                "additionalProperties": {}
              },
              "ageMin": {
                "description": "13–65",
                "type": "number"
              },
              "ageMax": {
                "description": "13–65 (65 means 65+)",
                "type": "number"
              },
              "genders": {
                "type": "string",
                "enum": [
                  "all",
                  "men",
                  "women"
                ]
              },
              "interests": {
                "description": "interest ids from find_meta_audiences(type:\"adinterest\")",
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "name": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "id"
                  ]
                }
              },
              "behaviors": {
                "description": "behaviour ids from find_meta_audiences(type:\"adTargetingCategory\", class:\"behaviors\")",
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "name": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "id"
                  ]
                }
              },
              "excludedInterests": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "name": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "id"
                  ]
                }
              },
              "excludedBehaviors": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "name": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "id"
                  ]
                }
              },
              "flexibleSpec": {
                "description": "AND across entries, OR within one",
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "interests": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          }
                        },
                        "required": [
                          "id"
                        ]
                      }
                    },
                    "behaviors": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          }
                        },
                        "required": [
                          "id"
                        ]
                      }
                    }
                  }
                }
              },
              "customAudiences": {
                "description": "saved audiences AND lookalikes — a lookalike IS a custom audience id",
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "name": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "id"
                  ]
                }
              },
              "excludedCustomAudiences": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "name": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "id"
                  ]
                }
              },
              "locales": {
                "description": "Meta language ids from find_meta_audiences(type:\"adlocale\")",
                "type": "array",
                "items": {
                  "type": "number"
                }
              },
              "publisherPlatforms": {
                "type": "array",
                "items": {
                  "type": "string",
                  "enum": [
                    "facebook",
                    "instagram",
                    "audience_network",
                    "messenger",
                    "threads"
                  ]
                }
              },
              "facebookPositions": {
                "description": "feed, story, facebook_reels, marketplace, video_feeds, search, instream_video, right_hand_column, …",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "instagramPositions": {
                "description": "stream, story, reels, explore, profile_feed, …",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "messengerPositions": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "audienceNetworkPositions": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "devicePlatforms": {
                "type": "array",
                "items": {
                  "type": "string",
                  "enum": [
                    "mobile",
                    "desktop"
                  ]
                }
              },
              "userOs": {
                "type": "array",
                "items": {
                  "type": "string",
                  "enum": [
                    "iOS",
                    "Android"
                  ]
                }
              },
              "advantageAudience": {
                "description": "let Meta expand beyond your audience (Advantage+ audience)",
                "type": "boolean"
              }
            },
            "additionalProperties": {}
          },
          "pixelId": {
            "description": "Meta Pixel id — with this the ad set optimizes for a real CONVERSION instead of falling back to link clicks",
            "type": "string"
          },
          "conversionEvent": {
            "description": "PURCHASE | LEAD | COMPLETE_REGISTRATION | ADD_TO_CART | INITIATED_CHECKOUT | …",
            "type": "string"
          },
          "customConversionId": {
            "type": "string"
          },
          "applicationId": {
            "description": "app-promotion ads",
            "type": "string"
          },
          "objectStoreUrl": {
            "type": "string"
          },
          "optimizationGoal": {
            "description": "override, e.g. OFFSITE_CONVERSIONS / LANDING_PAGE_VIEWS / THRUPLAY / VALUE",
            "type": "string"
          },
          "billingEvent": {
            "description": "default IMPRESSIONS",
            "type": "string"
          },
          "bidStrategy": {
            "type": "string",
            "enum": [
              "LOWEST_COST_WITHOUT_CAP",
              "LOWEST_COST_WITH_BID_CAP",
              "COST_CAP",
              "LOWEST_COST_WITH_MIN_ROAS"
            ]
          },
          "bidAmountUsd": {
            "description": "REQUIRED for a bid cap / cost cap",
            "type": "number"
          },
          "minRoas": {
            "description": "REQUIRED for LOWEST_COST_WITH_MIN_ROAS, e.g. 1.1",
            "type": "number"
          },
          "startTime": {
            "description": "ISO-8601 with offset, e.g. 2026-08-01T09:00:00-0700",
            "type": "string"
          },
          "endTime": {
            "description": "REQUIRED with lifetimeBudgetUsd",
            "type": "string"
          },
          "adsetSchedule": {
            "description": "dayparting — minutes from midnight (0–1440), days 0=Sunday…6=Saturday",
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "startMinute": {
                  "type": "number"
                },
                "endMinute": {
                  "type": "number"
                },
                "days": {
                  "type": "array",
                  "items": {
                    "type": "number"
                  }
                }
              },
              "required": [
                "startMinute",
                "endMinute",
                "days"
              ]
            }
          },
          "attributionSpec": {
            "description": "e.g. [{event_type:\"CLICK_THROUGH\",window_days:7}]",
            "type": "array",
            "items": {}
          },
          "specialAdCategories": {
            "description": "legally required when the ad falls in one of these categories — it restricts targeting",
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "HOUSING",
                "EMPLOYMENT",
                "CREDIT",
                "ISSUES_ELECTIONS_POLITICS",
                "ONLINE_GAMBLING_AND_GAMING",
                "FINANCIAL_PRODUCTS_SERVICES"
              ]
            }
          },
          "instagramUserId": {
            "description": "run it on Instagram under the brand’s own handle",
            "type": "string"
          },
          "name": {
            "description": "base name for the campaign/ad set/ads",
            "type": "string"
          },
          "campaignId": {
            "description": "attach to an existing campaign instead of creating one",
            "type": "string"
          },
          "adSetId": {
            "description": "attach the ad(s) to an EXISTING ad set (skips ad-set creation)",
            "type": "string"
          },
          "pageId": {
            "description": "Page id from list_meta_pages; omit = first Page",
            "type": "string"
          }
        },
        "required": [
          "adAccountId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_meta_adset",
      "title": "Create a Meta ad set (audience + budget + schedule)",
      "description": "Create an AD SET on an EXISTING Meta campaign — the level that holds the audience, budget, schedule and bidding. Use it to hang SEVERAL ad sets off ONE campaign, which is how you actually test audiences on Meta (one ad set per audience, same campaign, same creative). Takes the same full `targeting`, pixelId/conversionEvent, bidStrategy, schedule and budget fields as create_meta_ad. Created PAUSED and read back from Meta. It has NO ads until you call create_meta_ad(adSetId:…).",
      "group": "ads",
      "section": "Meta ad-set targeting, shared by create_meta_ad and create_meta_adset. Ids come from find_meta_audiences —",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string",
            "description": "ad account id (act_… or digits)"
          },
          "campaignId": {
            "type": "string",
            "description": "the campaign this ad set belongs to"
          },
          "name": {
            "description": "ad set name",
            "type": "string"
          },
          "dailyBudgetUsd": {
            "description": "ad-set daily budget USD (1–10000, default 10) — spends only once ACTIVE",
            "type": "number"
          },
          "lifetimeBudgetUsd": {
            "description": "a fixed total instead of a daily budget — REQUIRES endTime",
            "type": "number"
          },
          "country": {
            "description": "2-letter shorthand when you are not passing full targeting (default US)",
            "type": "string"
          },
          "targeting": {
            "description": "full Meta ad-set targeting — age, gender, geo, interests, behaviours, audiences, languages, placements, devices. Use EXACTLY these key names: an unrecognised one (e.g. geoLocations) is REFUSED by name — it is never dropped, because a dropped geo key used to fall back to targeting the United States.",
            "type": "object",
            "properties": {
              "geo": {
                "description": "where the ad runs",
                "type": "object",
                "properties": {
                  "countries": {
                    "description": "2-letter codes, e.g. [\"US\",\"CA\"]",
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "regions": {
                    "description": "region KEYS from find_meta_audiences(type:\"adgeolocation\")",
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "key": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "key"
                      ]
                    }
                  },
                  "cities": {
                    "description": "city KEYS; radius works here (10–50 mi / 17–80 km)",
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "key": {
                          "type": "string"
                        },
                        "radius": {
                          "type": "number"
                        },
                        "distanceUnit": {
                          "type": "string",
                          "enum": [
                            "mile",
                            "kilometer"
                          ]
                        }
                      },
                      "required": [
                        "key"
                      ]
                    }
                  },
                  "zips": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "key": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "key"
                      ]
                    }
                  },
                  "geoMarkets": {
                    "description": "DMA keys, e.g. {key:\"DMA:807\"}",
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "key": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "key"
                      ]
                    }
                  },
                  "customLocations": {
                    "description": "drop a pin + radius",
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "latitude": {
                          "type": "number"
                        },
                        "longitude": {
                          "type": "number"
                        },
                        "radius": {
                          "type": "number"
                        },
                        "distanceUnit": {
                          "type": "string",
                          "enum": [
                            "mile",
                            "kilometer"
                          ]
                        }
                      },
                      "required": [
                        "latitude",
                        "longitude"
                      ]
                    }
                  },
                  "locationTypes": {
                    "description": "people who LIVE there vs were recently there",
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "home",
                        "recent",
                        "travel_in"
                      ]
                    }
                  }
                },
                "additionalProperties": {}
              },
              "excludedGeo": {
                "description": "places to exclude",
                "type": "object",
                "properties": {
                  "countries": {
                    "description": "2-letter codes, e.g. [\"US\",\"CA\"]",
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "regions": {
                    "description": "region KEYS from find_meta_audiences(type:\"adgeolocation\")",
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "key": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "key"
                      ]
                    }
                  },
                  "cities": {
                    "description": "city KEYS; radius works here (10–50 mi / 17–80 km)",
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "key": {
                          "type": "string"
                        },
                        "radius": {
                          "type": "number"
                        },
                        "distanceUnit": {
                          "type": "string",
                          "enum": [
                            "mile",
                            "kilometer"
                          ]
                        }
                      },
                      "required": [
                        "key"
                      ]
                    }
                  },
                  "zips": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "key": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "key"
                      ]
                    }
                  },
                  "geoMarkets": {
                    "description": "DMA keys, e.g. {key:\"DMA:807\"}",
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "key": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "key"
                      ]
                    }
                  },
                  "customLocations": {
                    "description": "drop a pin + radius",
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "latitude": {
                          "type": "number"
                        },
                        "longitude": {
                          "type": "number"
                        },
                        "radius": {
                          "type": "number"
                        },
                        "distanceUnit": {
                          "type": "string",
                          "enum": [
                            "mile",
                            "kilometer"
                          ]
                        }
                      },
                      "required": [
                        "latitude",
                        "longitude"
                      ]
                    }
                  },
                  "locationTypes": {
                    "description": "people who LIVE there vs were recently there",
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "home",
                        "recent",
                        "travel_in"
                      ]
                    }
                  }
                },
                "additionalProperties": {}
              },
              "ageMin": {
                "description": "13–65",
                "type": "number"
              },
              "ageMax": {
                "description": "13–65 (65 means 65+)",
                "type": "number"
              },
              "genders": {
                "type": "string",
                "enum": [
                  "all",
                  "men",
                  "women"
                ]
              },
              "interests": {
                "description": "interest ids from find_meta_audiences(type:\"adinterest\")",
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "name": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "id"
                  ]
                }
              },
              "behaviors": {
                "description": "behaviour ids from find_meta_audiences(type:\"adTargetingCategory\", class:\"behaviors\")",
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "name": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "id"
                  ]
                }
              },
              "excludedInterests": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "name": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "id"
                  ]
                }
              },
              "excludedBehaviors": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "name": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "id"
                  ]
                }
              },
              "flexibleSpec": {
                "description": "AND across entries, OR within one",
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "interests": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          }
                        },
                        "required": [
                          "id"
                        ]
                      }
                    },
                    "behaviors": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          }
                        },
                        "required": [
                          "id"
                        ]
                      }
                    }
                  }
                }
              },
              "customAudiences": {
                "description": "saved audiences AND lookalikes — a lookalike IS a custom audience id",
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "name": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "id"
                  ]
                }
              },
              "excludedCustomAudiences": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "name": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "id"
                  ]
                }
              },
              "locales": {
                "description": "Meta language ids from find_meta_audiences(type:\"adlocale\")",
                "type": "array",
                "items": {
                  "type": "number"
                }
              },
              "publisherPlatforms": {
                "type": "array",
                "items": {
                  "type": "string",
                  "enum": [
                    "facebook",
                    "instagram",
                    "audience_network",
                    "messenger",
                    "threads"
                  ]
                }
              },
              "facebookPositions": {
                "description": "feed, story, facebook_reels, marketplace, video_feeds, search, instream_video, right_hand_column, …",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "instagramPositions": {
                "description": "stream, story, reels, explore, profile_feed, …",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "messengerPositions": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "audienceNetworkPositions": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "devicePlatforms": {
                "type": "array",
                "items": {
                  "type": "string",
                  "enum": [
                    "mobile",
                    "desktop"
                  ]
                }
              },
              "userOs": {
                "type": "array",
                "items": {
                  "type": "string",
                  "enum": [
                    "iOS",
                    "Android"
                  ]
                }
              },
              "advantageAudience": {
                "description": "let Meta expand beyond your audience (Advantage+ audience)",
                "type": "boolean"
              }
            },
            "additionalProperties": {}
          },
          "pixelId": {
            "description": "Meta Pixel id — with this the ad set optimizes for a real CONVERSION instead of falling back to link clicks",
            "type": "string"
          },
          "conversionEvent": {
            "description": "PURCHASE | LEAD | COMPLETE_REGISTRATION | ADD_TO_CART | INITIATED_CHECKOUT | …",
            "type": "string"
          },
          "customConversionId": {
            "type": "string"
          },
          "applicationId": {
            "description": "app-promotion ads",
            "type": "string"
          },
          "objectStoreUrl": {
            "type": "string"
          },
          "optimizationGoal": {
            "description": "override, e.g. OFFSITE_CONVERSIONS / LANDING_PAGE_VIEWS / THRUPLAY / VALUE",
            "type": "string"
          },
          "billingEvent": {
            "description": "default IMPRESSIONS",
            "type": "string"
          },
          "bidStrategy": {
            "type": "string",
            "enum": [
              "LOWEST_COST_WITHOUT_CAP",
              "LOWEST_COST_WITH_BID_CAP",
              "COST_CAP",
              "LOWEST_COST_WITH_MIN_ROAS"
            ]
          },
          "bidAmountUsd": {
            "description": "REQUIRED for a bid cap / cost cap",
            "type": "number"
          },
          "minRoas": {
            "description": "REQUIRED for LOWEST_COST_WITH_MIN_ROAS, e.g. 1.1",
            "type": "number"
          },
          "startTime": {
            "description": "ISO-8601 with offset, e.g. 2026-08-01T09:00:00-0700",
            "type": "string"
          },
          "endTime": {
            "description": "REQUIRED with lifetimeBudgetUsd",
            "type": "string"
          },
          "adsetSchedule": {
            "description": "dayparting — minutes from midnight (0–1440), days 0=Sunday…6=Saturday",
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "startMinute": {
                  "type": "number"
                },
                "endMinute": {
                  "type": "number"
                },
                "days": {
                  "type": "array",
                  "items": {
                    "type": "number"
                  }
                }
              },
              "required": [
                "startMinute",
                "endMinute",
                "days"
              ]
            }
          },
          "attributionSpec": {
            "description": "e.g. [{event_type:\"CLICK_THROUGH\",window_days:7}]",
            "type": "array",
            "items": {}
          },
          "pageId": {
            "description": "Page id; omit = first Page",
            "type": "string"
          }
        },
        "required": [
          "adAccountId",
          "campaignId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "find_meta_audiences",
      "title": "Look up Meta targeting ids",
      "description": "Look up the Meta targeting ids you need before building an ad set — interests, behaviours, cities/regions/zips/DMAs, languages, employers, job titles and schools. type:\"adinterest\" (q:\"yoga\") returns interest ids + audience size; type:\"adTargetingCategory\" with class:\"behaviors\" returns behaviour ids; type:\"adgeolocation\" (q:\"Toronto\", optionally locationTypes:\"city\") returns the geo KEYS that go in targeting.geo.cities/regions/zips; type:\"adlocale\" (q:\"french\") returns language ids for targeting.locales. Read-only and free. Use it whenever the user names an audience in words — never guess an id.",
      "group": "ads",
      "section": "Meta ad-set targeting, shared by create_meta_ad and create_meta_adset. Ids come from find_meta_audiences —",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "adinterest",
              "adTargetingCategory",
              "adgeolocation",
              "adlocale",
              "adcountry",
              "adzipcode",
              "adeducationschool",
              "adeducationmajor",
              "adworkemployer",
              "adworkposition"
            ],
            "description": "what kind of targeting object to search"
          },
          "q": {
            "description": "what to search for",
            "type": "string"
          },
          "class": {
            "description": "for adTargetingCategory, e.g. \"behaviors\" or \"interests\"",
            "type": "string"
          },
          "locationTypes": {
            "description": "comma-separated: country,region,city,zip,geo_market",
            "type": "string"
          },
          "countryCode": {
            "description": "2-letter hint to disambiguate a city name",
            "type": "string"
          },
          "adAccountId": {
            "description": "search with that ad account’s token",
            "type": "string"
          },
          "limit": {
            "type": "number"
          }
        },
        "required": [
          "type"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_meta_ads",
      "title": "List Meta campaigns / ad sets / ads",
      "description": "Read the EXISTING campaigns, ad sets, or ads on a connected Meta ad account — id, name, status, budget, objective. Pass adAccountId (from list_meta_pages) and level (campaign|adset|ad). Scope to a parent with campaignId (→ its ad sets/ads) or adsetId (→ its ads), and filter by status (ACTIVE/PAUSED/…). Read-only — use it to inspect an account before editing/deleting, or to answer \"what’s running?\".",
      "group": "ads",
      "section": "Meta: READ / MEASURE / EDIT / DELETE existing objects (drive a whole ad account, not just create)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string",
            "description": "ad account id (act_… or digits — from list_meta_pages)"
          },
          "level": {
            "description": "what to list (default campaign)",
            "type": "string",
            "enum": [
              "campaign",
              "adset",
              "ad"
            ]
          },
          "campaignId": {
            "description": "list the ad sets / ads under this campaign",
            "type": "string"
          },
          "adsetId": {
            "description": "list the ads under this ad set",
            "type": "string"
          },
          "status": {
            "description": "filter by effective status, e.g. ACTIVE / PAUSED",
            "type": "string"
          },
          "limit": {
            "description": "max rows (1–200, default 50)",
            "type": "number"
          }
        },
        "required": [
          "adAccountId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "meta_insights",
      "title": "Meta ad performance metrics",
      "description": "Pull performance INSIGHTS (spend, impressions, reach, clicks, CTR, CPC, CPM, conversions) for a connected ad account, or a specific campaign / ad set / ad. Pass adAccountId (for auth); optionally objectId to scope to one object and level to break the numbers down. BREAKDOWNS are what make the numbers actionable — a flat total says an ad cost $X, never WHO it worked on: pass breakdowns:\"age,gender\", \"publisher_platform,platform_position\" (which placement), \"country\" / \"region\" / \"dma\" (where), \"impression_device\" / \"device_platform\" (what they held). Comma-separated; \"placement\", \"device\" and \"geo\" are accepted as aliases; an unknown value is REJECTED, never silently ignored. THREE breakdowns need an ad-account OPT-IN from 2026-08-06 — impression_device, hourly_stats_aggregated_by_audience_time_zone and frequency_value: Meta returns NO ROWS (not an error) for an account that has not opted in, so they are always ATTEMPTED, and if nothing comes back the report is re-run WITHOUT them and `droppedBreakdowns` + a note name the missing dimension and say an account admin can enable it in Ads Manager. A dropped dimension is ABSENT, never zero — never present the remaining total as if it were still split by it. Date window: datePreset OR since+until (YYYY-MM-DD). datePreset is Meta's OWN enum — today, yesterday, last_3d, last_7d, last_14d, last_28d, last_30d, last_90d, this_week_mon_today, this_week_sun_today, last_week_mon_sun, last_week_sun_sat, this_month, last_month, this_quarter, last_quarter, this_year, last_year, maximum, data_maximum. THERE IS NO \"lifetime\": Meta disabled it in Graph API v10.0 and replaced it with \"maximum\" (the last 37 months); anything unrecognised is refused by name here rather than 400ing at Meta. Read-only.",
      "group": "ads",
      "section": "Meta: READ / MEASURE / EDIT / DELETE existing objects (drive a whole ad account, not just create)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string",
            "description": "ad account id (act_… or digits)"
          },
          "objectId": {
            "description": "a campaign / ad set / ad id to scope to (default: the whole account)",
            "type": "string"
          },
          "level": {
            "description": "break the numbers down by this level",
            "type": "string",
            "enum": [
              "account",
              "campaign",
              "adset",
              "ad"
            ]
          },
          "breakdowns": {
            "description": "comma-separated, e.g. \"age,gender\" | \"publisher_platform,platform_position\" | \"country\" | \"impression_device\"",
            "type": "string"
          },
          "actionBreakdowns": {
            "description": "comma-separated, e.g. \"action_type,action_device\" — splits the conversion/action counts",
            "type": "string"
          },
          "datePreset": {
            "description": "Meta's date_preset enum, default last_30d: today | yesterday | last_3d | last_7d | last_14d | last_28d | last_30d | last_90d | this_week_mon_today | this_week_sun_today | last_week_mon_sun | last_week_sun_sat | this_month | last_month | this_quarter | last_quarter | this_year | last_year | maximum | data_maximum. NOT \"lifetime\" — Meta retired it in v10.0; use \"maximum\" (37 months) or since+until.",
            "type": "string"
          },
          "since": {
            "description": "start date YYYY-MM-DD (use with until)",
            "type": "string"
          },
          "until": {
            "description": "end date YYYY-MM-DD",
            "type": "string"
          }
        },
        "required": [
          "adAccountId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "preview_meta_ad",
      "title": "Preview a Meta ad exactly as it will appear",
      "description": "Render a REAL preview of a Meta ad, per placement — Meta returns a link that shows exactly what a person scrolling Facebook or Instagram would see. Pass adAccountId + adId (from list_meta_ads), or creativeId. Optional placements (comma-separated): facebook_feed, facebook_feed_desktop, facebook_story, facebook_reels, facebook_profile_feed, facebook_marketplace, facebook_right_column, facebook_video_feed, instagram_feed, instagram_story, instagram_reels, instagram_explore, instagram_profile_feed, messenger_inbox, messenger_story, audience_network — default facebook_feed + instagram_feed + instagram_story + instagram_reels. Free, read-only, spends nothing. THE LINKS EXPIRE AFTER 24 HOURS — always say so when handing them to a user. Use it straight after create_meta_ad, and whenever someone wants to approve an ad before it runs.",
      "group": "ads",
      "section": "Meta: SEE the ad, SIZE the audience, BUILD the audience (2026-07-31)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string",
            "description": "ad account id (act_… or digits)"
          },
          "adId": {
            "description": "the ad to preview (from list_meta_ads)",
            "type": "string"
          },
          "creativeId": {
            "description": "preview a creative directly instead of an ad",
            "type": "string"
          },
          "placements": {
            "description": "comma-separated placements (see the list above)",
            "type": "string"
          }
        },
        "required": [
          "adAccountId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "estimate_meta_reach",
      "title": "Estimate how many people a Meta audience reaches",
      "description": "Ask Meta how many people a targeting spec can actually reach — BEFORE any budget is committed. Two ways: pass adSetId to size an ad set you already built (Meta uses its own saved targeting), or pass the same `targeting` object you would give create_meta_ad (plus optional objective / optimizationGoal / country / pixelId) to size an audience you are considering. Returns the monthly-active range, a daily-active estimate, and an explicit warning when the audience is too narrow to deliver. Free, read-only, creates nothing and spends nothing. Use it before recommending a budget and every time the user narrows a geo or piles on interests.",
      "group": "ads",
      "section": "Meta: SEE the ad, SIZE the audience, BUILD the audience (2026-07-31)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string",
            "description": "ad account id (act_… or digits)"
          },
          "adSetId": {
            "description": "size an EXISTING ad set using its own saved targeting",
            "type": "string"
          },
          "targeting": {
            "description": "a targeting object, same shape as create_meta_ad.targeting",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "objective": {
            "description": "OUTCOME_TRAFFIC | OUTCOME_SALES | … — picks the matching optimization goal",
            "type": "string"
          },
          "optimizationGoal": {
            "description": "override the goal, e.g. REACH / LINK_CLICKS / OFFSITE_CONVERSIONS",
            "type": "string"
          },
          "country": {
            "description": "2-letter fallback country when targeting names no geo",
            "type": "string"
          },
          "pixelId": {
            "description": "estimate a conversion goal against this pixel",
            "type": "string"
          },
          "conversionEvent": {
            "description": "e.g. PURCHASE — used with pixelId",
            "type": "string"
          }
        },
        "required": [
          "adAccountId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_meta_audiences",
      "title": "List Meta custom audiences + lookalikes",
      "description": "List the custom audiences and lookalikes on a connected Meta ad account — id, name, type, approximate size, and whether Meta says it is ready to target. Call it before create_meta_audience (so you never build a duplicate) and before targeting one: the ids go straight into create_meta_ad’s targeting.customAudiences / excludedCustomAudiences. Read-only, free.",
      "group": "ads",
      "section": "Meta: SEE the ad, SIZE the audience, BUILD the audience (2026-07-31)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string",
            "description": "ad account id (act_… or digits)"
          },
          "limit": {
            "description": "max rows (1–200, default 50)",
            "type": "number"
          }
        },
        "required": [
          "adAccountId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_meta_audience",
      "title": "Create a Meta custom audience or lookalike",
      "description": "Build a retargeting audience on a connected Meta ad account. Three kinds: kind:\"website\" (people whose visited URL contains urlContains, seen by pixelId — pass the brand’s own domain for \"all visitors\"; retentionDays up to 180), kind:\"engagement\" (people who did `event` on the brand’s Facebook Page, or its Instagram business profile with source:\"instagram\"; retentionDays up to 730), or kind:\"lookalike\" (sourceAudienceId + country + ratio 0.01–0.20, lookalikeType \"similarity\" or \"reach\"). CREATING AN AUDIENCE SPENDS NOTHING — it is a definition; money only moves when an ad set targets it and that campaign is activated through set_meta_campaign_status(confirm:true). Meta needs roughly 30 minutes and ~1,000 people before a new audience can be targeted, so a fresh one reporting no size is normal. Customer-list uploads are deliberately NOT supported here (hashed personal data + Meta’s Custom Audience Terms) — send the user to Ads Manager for those.",
      "group": "ads",
      "section": "Meta: SEE the ad, SIZE the audience, BUILD the audience (2026-07-31)",
      "annotations": {
        "readOnlyHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string",
            "description": "ad account id (act_… or digits)"
          },
          "kind": {
            "type": "string",
            "enum": [
              "website",
              "engagement",
              "lookalike"
            ],
            "description": "which kind of audience to build"
          },
          "name": {
            "type": "string",
            "description": "audience name"
          },
          "description": {
            "type": "string"
          },
          "retentionDays": {
            "description": "how long someone stays in it — website max 180, engagement max 730 (default 30)",
            "type": "number"
          },
          "pixelId": {
            "description": "website: the Meta Pixel that sees the traffic",
            "type": "string"
          },
          "urlContains": {
            "description": "website: the URL fragment that defines the audience (your domain = all visitors)",
            "type": "string"
          },
          "pageId": {
            "description": "engagement: which connected Page (required only if the brand has several)",
            "type": "string"
          },
          "source": {
            "description": "engagement: Facebook Page (default) or the linked Instagram business profile",
            "type": "string",
            "enum": [
              "page",
              "instagram"
            ]
          },
          "event": {
            "description": "engagement: page_engaged | page_visited | page_liked | page_messaged | page_cta_clicked | page_or_post_save | page_post_interaction — or ig_business_profile_all | ig_business_profile_engaged | ig_user_messaged_business | ig_business_profile_visit",
            "type": "string"
          },
          "sourceAudienceId": {
            "description": "lookalike: the existing audience to model (from list_meta_audiences)",
            "type": "string"
          },
          "country": {
            "description": "lookalike: 2-letter country to build it in",
            "type": "string"
          },
          "ratio": {
            "description": "lookalike: 0.01–0.20 = the top 1%–20% most similar people in that country (default 0.01)",
            "type": "number"
          },
          "startingRatio": {
            "description": "lookalike: optional lower bound, must be less than ratio",
            "type": "number"
          },
          "lookalikeType": {
            "description": "lookalike: similarity (tighter) or reach (broader) — default similarity",
            "type": "string",
            "enum": [
              "similarity",
              "reach"
            ]
          },
          "prefill": {
            "description": "website/engagement: seed it with activity from BEFORE the audience existed (default true)",
            "type": "boolean"
          }
        },
        "required": [
          "adAccountId",
          "kind",
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_meta_audience",
      "title": "Delete a Meta custom audience",
      "description": "PERMANENTLY delete a Meta custom audience or lookalike. Meta’s own warning: \"When you delete a custom audience, it will be permanently removed from your account and your ads using it will stop running.\" An audience is the one ad object whose value is its CONTENTS — a big retargeting list cannot be rebuilt, it has to re-accumulate — so CALL IT WITHOUT confirm FIRST: nothing is deleted, and you get its real name, how many people are in it and which lookalikes were built from it, read live from Meta. Show the user exactly that. A populated audience, or one with lookalikes, then needs confirmName set to its exact name (and confirmChildren set to the lookalike count when there are any). META REFUSES to delete an audience that has lookalikes derived from it (error 2656) — delete those first; the unconfirmed call names them. Pass adAccountId + audienceId (from list_meta_audiences). The result is READ BACK from Meta: it says deleted only when the id no longer resolves.",
      "group": "ads",
      "section": "Meta: SEE the ad, SIZE the audience, BUILD the audience (2026-07-31)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string",
            "description": "ad account id (act_… or digits)"
          },
          "audienceId": {
            "type": "string",
            "description": "the custom audience id (from list_meta_audiences)"
          },
          "confirm": {
            "description": "REQUIRED true — the deletion is permanent",
            "type": "boolean"
          },
          "confirmName": {
            "description": "the audience’s EXACT name, required when it holds people or has lookalikes",
            "type": "string"
          },
          "confirmChildren": {
            "description": "the exact number of derived lookalikes reported by the unconfirmed call, required when it has any",
            "type": "number"
          }
        },
        "required": [
          "adAccountId",
          "audienceId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_google_ads_campaigns",
      "title": "List Google Ads accounts / campaigns",
      "description": "Read the connected Google Ads account(s). Call with NO customerId to list the accessible accounts (customerId + name + currency) — do this first to pick a target. Call WITH customerId to list that account’s campaigns (id, name, status, daily budget, channel) plus performance metrics (impressions, clicks, CTR, avg CPC, cost, conversions). Date window: datePreset (LAST_7_DAYS | LAST_30_DAYS | TODAY | THIS_MONTH | LAST_90_DAYS …) or since+until (YYYY-MM-DD). Read-only, free. Needs Google Ads connected (Settings ▸ Connectors ▸ Google Ads).",
      "group": "ads",
      "section": "Google Ads: read + manage (flagship, Meta-parity). Every spend change is confirm-gated.",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "customerId": {
            "description": "10-digit account id (dashes ok) — omit to list accessible accounts",
            "type": "string"
          },
          "status": {
            "description": "filter campaigns by status",
            "type": "string",
            "enum": [
              "ENABLED",
              "PAUSED",
              "REMOVED"
            ]
          },
          "datePreset": {
            "description": "metrics window preset (default LAST_30_DAYS)",
            "type": "string"
          },
          "since": {
            "description": "start date YYYY-MM-DD (with until)",
            "type": "string"
          },
          "until": {
            "description": "end date YYYY-MM-DD",
            "type": "string"
          },
          "metrics": {
            "description": "include performance metrics (default true)",
            "type": "boolean"
          },
          "loginCustomerId": {
            "description": "manager (MCC) id — only if reaching a client account through a manager",
            "type": "string"
          },
          "limit": {
            "description": "max campaigns (1–500, default 100)",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "google_ads_report",
      "title": "Google Ads GAQL report",
      "description": "Run a GAQL (Google Ads Query Language) report for detailed performance breakdowns — ad groups, ads, keywords, search terms, demographics, geo. Pass customerId + a GAQL query (SELECT … FROM <resource> WHERE segments.date DURING LAST_30_DAYS). Allowed FROM resources: campaign, ad_group, ad_group_ad, keyword_view, campaign_budget, age_range_view, gender_view, geographic_view, search_term_view. cost_micros is micros — divide by 1,000,000 for the account currency. Read-only, free.",
      "group": "ads",
      "section": "Google Ads: read + manage (flagship, Meta-parity). Every spend change is confirm-gated.",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "customerId": {
            "description": "10-digit account id (dashes ok) — omit to use the brand’s selected default account",
            "type": "string"
          },
          "query": {
            "type": "string",
            "description": "GAQL, e.g. \"SELECT ad_group.name, metrics.clicks, metrics.cost_micros FROM ad_group WHERE segments.date DURING LAST_7_DAYS\""
          },
          "loginCustomerId": {
            "description": "manager id if operating through an MCC",
            "type": "string"
          }
        },
        "required": [
          "query"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "google_ads_change_history",
      "title": "What changed on a Google Ads account, and when",
      "description": "WHAT CHANGED ON THE ACCOUNT, AND WHEN — the answer to \"performance fell off a cliff on Tuesday, what happened?\", and the Google twin of reddit_ads_history. source:\"change_event\" (default) is FIELD-LEVEL over the last 30 days: the change time, who made it, from which client (web UI, API, scripts, bulk upload, automated rule), whether it was a CREATE / UPDATE / REMOVE, and exactly which fields moved — with detail:true it also carries the old and new resource snapshots. source:\"change_status\" reaches 90 days and is the ONLY one that catches GOOGLE ADS EDITOR and criterion-level edits: Google documents change_event as NEVER returning Editor changes, so an Editor-managed account looks completely untouched there. CHECK BOTH BEFORE TELLING ANYONE NOTHING CHANGED. An unknown source is refused by name; the 30/90-day windows and Google’s own 10,000-row cap are enforced here with the reason instead of surfacing as an unreadable Google error, and a change takes up to three minutes to appear. Neither resource carries any metric or segment, so this says what changed, never what it cost. Read-only, 0 credits.",
      "group": "ads",
      "section": "GOOGLE ADS CHANGE HISTORY (2026-08-05)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "customerId": {
            "description": "10-digit account id — omit to use the brand’s selected default account",
            "type": "string"
          },
          "source": {
            "description": "default change_event (30 days, field-level). change_status is 90 days and is the only one that sees Google Ads Editor.",
            "type": "string",
            "enum": [
              "change_event",
              "change_status"
            ]
          },
          "since": {
            "description": "YYYY-MM-DD, default 14 days ago",
            "type": "string"
          },
          "until": {
            "description": "YYYY-MM-DD, default today",
            "type": "string"
          },
          "limit": {
            "description": "rows, max 10000 — Google’s own ceiling, and the clamp is reported",
            "type": "number"
          },
          "detail": {
            "description": "change_event only — include the old/new resource snapshots",
            "type": "boolean"
          },
          "loginCustomerId": {
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_google_ads_campaign",
      "title": "Build a Google Ads campaign (paused)",
      "description": "Build a campaign on a connected Google Ads account. ALWAYS created PAUSED — it spends NOTHING until you enable it with set_google_ads_status(confirm:true). Google's object graph is campaign → ad group → ad, so a campaign ON ITS OWN CANNOT SERVE AN IMPRESSION: pass adGroup{name, ad{headlines,descriptions,finalUrls}, keywords[]} and this builds budget + campaign + location/language targeting + ad group + ad + keywords in ONE ATOMIC operation (if any part is rejected, nothing at all is created — no half-built campaign to clean up). Also here: bidding strategy, locations by NAME (\"United States\", \"Toronto\" — resolved for you), languages, and start/end dates. Google requires 3–15 headlines (≤30 chars) and 2–4 descriptions (≤90 chars) on a search ad. Everything is READ BACK from Google before you are told it exists; print the returned note verbatim, and if it says the campaign cannot serve yet, say that rather than calling it a finished ad.",
      "group": "ads",
      "section": "GOOGLE ADS CHANGE HISTORY (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "customerId": {
            "description": "10-digit account id (from list_google_ads_campaigns) — omit to use the brand’s selected default account",
            "type": "string"
          },
          "name": {
            "type": "string",
            "description": "campaign name"
          },
          "dailyBudgetUsd": {
            "description": "daily budget USD (1–100000) — creates a budget inline; required unless budgetResourceName is given",
            "type": "number"
          },
          "budgetResourceName": {
            "description": "reuse an existing budget instead of creating one",
            "type": "string"
          },
          "channelType": {
            "description": "default SEARCH",
            "type": "string",
            "enum": [
              "SEARCH",
              "DISPLAY"
            ]
          },
          "searchPartners": {
            "description": "SEARCH only — also serve on Google search partners (default false)",
            "type": "boolean"
          },
          "bidding": {
            "description": "how the campaign bids — default MANUAL_CPC",
            "type": "object",
            "properties": {
              "strategy": {
                "type": "string",
                "enum": [
                  "MANUAL_CPC",
                  "MAXIMIZE_CLICKS",
                  "MAXIMIZE_CONVERSIONS",
                  "MAXIMIZE_CONVERSION_VALUE",
                  "TARGET_CPA",
                  "TARGET_ROAS"
                ]
              },
              "targetCpaUsd": {
                "description": "REQUIRED for TARGET_CPA — cost per conversion you will pay",
                "type": "number"
              },
              "targetRoas": {
                "description": "REQUIRED for TARGET_ROAS — e.g. 4 = $4 revenue per $1 spent",
                "type": "number"
              },
              "maxCpcUsd": {
                "description": "MAXIMIZE_CLICKS only — optional max CPC ceiling",
                "type": "number"
              },
              "enhancedCpc": {
                "description": "MANUAL_CPC only",
                "type": "boolean"
              }
            },
            "required": [
              "strategy"
            ]
          },
          "locations": {
            "description": "location NAMES to target, e.g. [\"United States\"] or [\"Toronto\",\"Vancouver\"]. WITHOUT this the campaign runs WORLDWIDE — the most expensive default in Google Ads",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "excludedLocations": {
            "description": "location names to EXCLUDE",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "languages": {
            "description": "ISO language codes, e.g. [\"en\",\"fr\"]",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "startDate": {
            "description": "YYYY-MM-DD",
            "type": "string"
          },
          "endDate": {
            "description": "YYYY-MM-DD",
            "type": "string"
          },
          "adGroup": {
            "description": "build the serving tree in the same atomic call — WITHOUT this you get a campaign shell that can never show an ad",
            "type": "object",
            "properties": {
              "name": {
                "type": "string"
              },
              "cpcBidUsd": {
                "type": "number"
              },
              "ad": {
                "type": "object",
                "properties": {
                  "finalUrls": {
                    "description": "the landing page(s) — at least one is required",
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "headlines": {
                    "description": "SEARCH: 3–15 headlines, each ≤30 characters. DISPLAY: 1–5. Pass a plain string, or {text, pin} to PIN one to a fixed slot (a brand name or legal line).",
                    "type": "array",
                    "items": {
                      "anyOf": [
                        {
                          "type": "string"
                        },
                        {
                          "type": "object",
                          "properties": {
                            "text": {
                              "type": "string"
                            },
                            "pin": {
                              "type": "string",
                              "enum": [
                                "HEADLINE_1",
                                "HEADLINE_2",
                                "HEADLINE_3",
                                "DESCRIPTION_1",
                                "DESCRIPTION_2"
                              ]
                            }
                          },
                          "required": [
                            "text"
                          ]
                        }
                      ]
                    }
                  },
                  "descriptions": {
                    "description": "SEARCH: 2–4 descriptions, each ≤90 characters. DISPLAY: 1–5. Pass a plain string, or {text, pin} to pin it.",
                    "type": "array",
                    "items": {
                      "anyOf": [
                        {
                          "type": "string"
                        },
                        {
                          "type": "object",
                          "properties": {
                            "text": {
                              "type": "string"
                            },
                            "pin": {
                              "type": "string",
                              "enum": [
                                "HEADLINE_1",
                                "HEADLINE_2",
                                "HEADLINE_3",
                                "DESCRIPTION_1",
                                "DESCRIPTION_2"
                              ]
                            }
                          },
                          "required": [
                            "text"
                          ]
                        }
                      ]
                    }
                  },
                  "path1": {
                    "description": "SEARCH only — display-URL path segment, ≤15 chars",
                    "type": "string"
                  },
                  "path2": {
                    "description": "SEARCH only — second display-URL path segment, ≤15 chars",
                    "type": "string"
                  },
                  "longHeadline": {
                    "description": "DISPLAY only — ≤90 characters",
                    "type": "string"
                  },
                  "businessName": {
                    "description": "DISPLAY only — ≤25 characters",
                    "type": "string"
                  },
                  "marketingImages": {
                    "description": "DISPLAY only — landscape 1.91:1 asset resource names from upload_google_ads_asset",
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "squareMarketingImages": {
                    "description": "DISPLAY only — square 1:1 asset resource names",
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "logoImages": {
                    "description": "DISPLAY only — logo asset resource names",
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              },
              "keywords": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "text": {
                      "type": "string",
                      "description": "≤80 characters, ≤10 words"
                    },
                    "matchType": {
                      "description": "default PHRASE",
                      "type": "string",
                      "enum": [
                        "EXACT",
                        "PHRASE",
                        "BROAD"
                      ]
                    },
                    "negative": {
                      "description": "true = BLOCK this term instead of targeting it",
                      "type": "boolean"
                    },
                    "cpcBidUsd": {
                      "description": "per-keyword max CPC",
                      "type": "number"
                    },
                    "paused": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "text"
                  ]
                }
              }
            }
          },
          "containsEuPoliticalAds": {
            "description": "EU Political Advertising Regulation declaration. Google REQUIRES one on every campaign. Default false (a normal commercial ad) — set true ONLY for genuine EU political advertising",
            "type": "boolean"
          },
          "dryRun": {
            "description": "validate the WHOLE tree against Google without creating anything. Nothing is written and no budget is consumed",
            "type": "boolean"
          },
          "loginCustomerId": {
            "description": "manager id if operating through an MCC",
            "type": "string"
          }
        },
        "required": [
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_google_ads_ad_group",
      "title": "Add an ad group to a Google Ads campaign",
      "description": "Add an ad group to an EXISTING Google Ads campaign — the level between a campaign and its ads. Google requires it: a campaign with no ad group cannot serve. Optionally build its ad and keywords in the same ATOMIC call. The ad-group type is taken from the campaign's channel automatically. Created PAUSED and read back from Google before you are told it exists. If the parent campaign is already LIVE (ENABLED), creating this ENABLED starts REAL AD SPEND immediately — show the user what would begin serving, get an explicit yes, then pass confirm:true. Leaving it PAUSED never needs confirmation.",
      "group": "ads",
      "section": "GOOGLE ADS CHANGE HISTORY (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "customerId": {
            "description": "omit to use the brand’s selected default account",
            "type": "string"
          },
          "campaignId": {
            "type": "string",
            "description": "the campaign this ad group belongs to"
          },
          "name": {
            "type": "string",
            "description": "ad group name"
          },
          "cpcBidUsd": {
            "description": "max CPC for this ad group — omit to inherit the campaign bidding",
            "type": "number"
          },
          "status": {
            "description": "default PAUSED",
            "type": "string",
            "enum": [
              "ENABLED",
              "PAUSED"
            ]
          },
          "ad": {
            "description": "build the ad in the same atomic call",
            "type": "object",
            "properties": {
              "finalUrls": {
                "description": "the landing page(s) — at least one is required",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "headlines": {
                "description": "SEARCH: 3–15 headlines, each ≤30 characters. DISPLAY: 1–5. Pass a plain string, or {text, pin} to PIN one to a fixed slot (a brand name or legal line).",
                "type": "array",
                "items": {
                  "anyOf": [
                    {
                      "type": "string"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "text": {
                          "type": "string"
                        },
                        "pin": {
                          "type": "string",
                          "enum": [
                            "HEADLINE_1",
                            "HEADLINE_2",
                            "HEADLINE_3",
                            "DESCRIPTION_1",
                            "DESCRIPTION_2"
                          ]
                        }
                      },
                      "required": [
                        "text"
                      ]
                    }
                  ]
                }
              },
              "descriptions": {
                "description": "SEARCH: 2–4 descriptions, each ≤90 characters. DISPLAY: 1–5. Pass a plain string, or {text, pin} to pin it.",
                "type": "array",
                "items": {
                  "anyOf": [
                    {
                      "type": "string"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "text": {
                          "type": "string"
                        },
                        "pin": {
                          "type": "string",
                          "enum": [
                            "HEADLINE_1",
                            "HEADLINE_2",
                            "HEADLINE_3",
                            "DESCRIPTION_1",
                            "DESCRIPTION_2"
                          ]
                        }
                      },
                      "required": [
                        "text"
                      ]
                    }
                  ]
                }
              },
              "path1": {
                "description": "SEARCH only — display-URL path segment, ≤15 chars",
                "type": "string"
              },
              "path2": {
                "description": "SEARCH only — second display-URL path segment, ≤15 chars",
                "type": "string"
              },
              "longHeadline": {
                "description": "DISPLAY only — ≤90 characters",
                "type": "string"
              },
              "businessName": {
                "description": "DISPLAY only — ≤25 characters",
                "type": "string"
              },
              "marketingImages": {
                "description": "DISPLAY only — landscape 1.91:1 asset resource names from upload_google_ads_asset",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "squareMarketingImages": {
                "description": "DISPLAY only — square 1:1 asset resource names",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "logoImages": {
                "description": "DISPLAY only — logo asset resource names",
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            }
          },
          "keywords": {
            "description": "a SEARCH ad group with no keywords never shows",
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "text": {
                  "type": "string",
                  "description": "≤80 characters, ≤10 words"
                },
                "matchType": {
                  "description": "default PHRASE",
                  "type": "string",
                  "enum": [
                    "EXACT",
                    "PHRASE",
                    "BROAD"
                  ]
                },
                "negative": {
                  "description": "true = BLOCK this term instead of targeting it",
                  "type": "boolean"
                },
                "cpcBidUsd": {
                  "description": "per-keyword max CPC",
                  "type": "number"
                },
                "paused": {
                  "type": "boolean"
                }
              },
              "required": [
                "text"
              ]
            }
          },
          "confirm": {
            "description": "set true ONLY after the user explicitly approved starting spend — required when the parent campaign is already LIVE (ENABLED) and this object would serve immediately",
            "type": "boolean"
          },
          "dryRun": {
            "type": "boolean"
          },
          "loginCustomerId": {
            "type": "string"
          }
        },
        "required": [
          "campaignId",
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_google_ads_ad",
      "title": "Create a Google Ads ad",
      "description": "Create the actual AD inside a Google Ads ad group — this is the object that carries the creative; a campaign or ad group alone shows nothing. On a SEARCH campaign it builds a RESPONSIVE SEARCH AD: 3–15 headlines (≤30 chars), 2–4 descriptions (≤90 chars), at least one finalUrl, optional path1/path2. On a DISPLAY campaign it builds a RESPONSIVE DISPLAY AD: headlines, longHeadline, descriptions, businessName plus BOTH a landscape (1.91:1) and a square (1:1) image asset from upload_google_ads_asset. The right format is chosen from the campaign's channel. Created PAUSED and read back from Google. If the parent campaign is already LIVE (ENABLED), creating this ENABLED starts REAL AD SPEND immediately — show the user what would begin serving, get an explicit yes, then pass confirm:true. Leaving it PAUSED never needs confirmation.",
      "group": "ads",
      "section": "GOOGLE ADS CHANGE HISTORY (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "customerId": {
            "description": "omit to use the brand’s selected default account",
            "type": "string"
          },
          "adGroupId": {
            "type": "string",
            "description": "the ad group this ad lives in"
          },
          "finalUrls": {
            "description": "the landing page(s) — at least one is required",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "headlines": {
            "description": "SEARCH: 3–15 headlines, each ≤30 characters. DISPLAY: 1–5. Pass a plain string, or {text, pin} to PIN one to a fixed slot (a brand name or legal line).",
            "type": "array",
            "items": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "object",
                  "properties": {
                    "text": {
                      "type": "string"
                    },
                    "pin": {
                      "type": "string",
                      "enum": [
                        "HEADLINE_1",
                        "HEADLINE_2",
                        "HEADLINE_3",
                        "DESCRIPTION_1",
                        "DESCRIPTION_2"
                      ]
                    }
                  },
                  "required": [
                    "text"
                  ]
                }
              ]
            }
          },
          "descriptions": {
            "description": "SEARCH: 2–4 descriptions, each ≤90 characters. DISPLAY: 1–5. Pass a plain string, or {text, pin} to pin it.",
            "type": "array",
            "items": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "object",
                  "properties": {
                    "text": {
                      "type": "string"
                    },
                    "pin": {
                      "type": "string",
                      "enum": [
                        "HEADLINE_1",
                        "HEADLINE_2",
                        "HEADLINE_3",
                        "DESCRIPTION_1",
                        "DESCRIPTION_2"
                      ]
                    }
                  },
                  "required": [
                    "text"
                  ]
                }
              ]
            }
          },
          "path1": {
            "description": "SEARCH only — display-URL path segment, ≤15 chars",
            "type": "string"
          },
          "path2": {
            "description": "SEARCH only — second display-URL path segment, ≤15 chars",
            "type": "string"
          },
          "longHeadline": {
            "description": "DISPLAY only — ≤90 characters",
            "type": "string"
          },
          "businessName": {
            "description": "DISPLAY only — ≤25 characters",
            "type": "string"
          },
          "marketingImages": {
            "description": "DISPLAY only — landscape 1.91:1 asset resource names from upload_google_ads_asset",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "squareMarketingImages": {
            "description": "DISPLAY only — square 1:1 asset resource names",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "logoImages": {
            "description": "DISPLAY only — logo asset resource names",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "status": {
            "description": "default PAUSED",
            "type": "string",
            "enum": [
              "ENABLED",
              "PAUSED"
            ]
          },
          "confirm": {
            "description": "set true ONLY after the user explicitly approved starting spend — required when the parent campaign is already LIVE (ENABLED) and this object would serve immediately",
            "type": "boolean"
          },
          "dryRun": {
            "type": "boolean"
          },
          "loginCustomerId": {
            "type": "string"
          }
        },
        "required": [
          "adGroupId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "add_google_ads_keywords",
      "title": "Add Google Ads keywords",
      "description": "Add keywords — and NEGATIVE keywords — to a Google Ads ad group. A Search ad group with no keywords never shows. Each keyword takes text (≤80 chars, ≤10 words) and matchType EXACT | PHRASE | BROAD (default PHRASE). Set negative:true to BLOCK a term instead of targeting it, which is the cheapest way to stop wasted spend. Read back from Google before you are told they exist. If the parent campaign and ad group are already LIVE, a positive keyword starts bidding real money at once — get an explicit yes and pass confirm:true, or add it with paused:true. Negative keywords only restrict spend and never need confirmation.",
      "group": "ads",
      "section": "GOOGLE ADS CHANGE HISTORY (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "customerId": {
            "description": "omit to use the brand’s selected default account",
            "type": "string"
          },
          "adGroupId": {
            "type": "string",
            "description": "the ad group to add them to"
          },
          "keywords": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "text": {
                  "type": "string",
                  "description": "≤80 characters, ≤10 words"
                },
                "matchType": {
                  "description": "default PHRASE",
                  "type": "string",
                  "enum": [
                    "EXACT",
                    "PHRASE",
                    "BROAD"
                  ]
                },
                "negative": {
                  "description": "true = BLOCK this term instead of targeting it",
                  "type": "boolean"
                },
                "cpcBidUsd": {
                  "description": "per-keyword max CPC",
                  "type": "number"
                },
                "paused": {
                  "type": "boolean"
                }
              },
              "required": [
                "text"
              ]
            },
            "description": "the keywords to add"
          },
          "confirm": {
            "description": "set true ONLY after the user explicitly approved starting spend — required when the parent campaign is already LIVE (ENABLED) and this object would serve immediately",
            "type": "boolean"
          },
          "dryRun": {
            "type": "boolean"
          },
          "loginCustomerId": {
            "type": "string"
          }
        },
        "required": [
          "adGroupId",
          "keywords"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_google_ads_targeting",
      "title": "Set Google Ads location & language targeting",
      "description": "Set WHERE and in what LANGUAGE an existing Google Ads campaign runs. Pass locations by NAME (\"United States\", \"California\", \"Toronto\") — they are resolved to Google's geo target ids for you; excludedLocations blocks places; languages takes ISO codes (\"en\",\"fr\"). A campaign with NO location targeting runs WORLDWIDE, which is the most expensive default in Google Ads. Changing a LIVE campaign's targeting moves real spend immediately, so that needs confirm:true.",
      "group": "ads",
      "section": "GOOGLE ADS CHANGE HISTORY (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "customerId": {
            "description": "omit to use the brand’s selected default account",
            "type": "string"
          },
          "campaignId": {
            "type": "string",
            "description": "the campaign to target"
          },
          "locations": {
            "description": "location NAMES to target",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "excludedLocations": {
            "description": "location NAMES to exclude",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "languages": {
            "description": "ISO language codes, e.g. [\"en\",\"es\"]",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "countryCode": {
            "description": "2-letter hint to disambiguate a city name, e.g. CA for \"London\"",
            "type": "string"
          },
          "confirm": {
            "description": "REQUIRED true to change a LIVE (ENABLED) campaign",
            "type": "boolean"
          },
          "dryRun": {
            "type": "boolean"
          },
          "loginCustomerId": {
            "type": "string"
          }
        },
        "required": [
          "campaignId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_google_ads_bidding",
      "title": "Set a Google Ads bidding strategy",
      "description": "Change how an existing Google Ads campaign bids: MANUAL_CPC (optionally enhanced), MAXIMIZE_CLICKS (needs maxCpcUsd on an existing campaign — Google requires the CPC ceiling on that change), MAXIMIZE_CONVERSIONS, MAXIMIZE_CONVERSION_VALUE, TARGET_CPA (needs targetCpaUsd) or TARGET_ROAS (needs targetRoas, e.g. 4 = $4 revenue per $1 spent). TARGET_CPA and TARGET_ROAS are applied as Google's own v25 equivalents — maximize-conversions with a target CPA, and maximize-conversion-value with a target ROAS — so the read-back reports them as MAXIMIZE_CONVERSIONS / MAXIMIZE_CONVERSION_VALUE; report what the read-back says. The conversion-based strategies only deliver once conversion tracking is configured on the account. Changing a LIVE campaign's bidding changes what it pays immediately, so that needs confirm:true.",
      "group": "ads",
      "section": "GOOGLE ADS CHANGE HISTORY (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "customerId": {
            "description": "omit to use the brand’s selected default account",
            "type": "string"
          },
          "campaignId": {
            "type": "string",
            "description": "the campaign to change"
          },
          "strategy": {
            "type": "string",
            "enum": [
              "MANUAL_CPC",
              "MAXIMIZE_CLICKS",
              "MAXIMIZE_CONVERSIONS",
              "MAXIMIZE_CONVERSION_VALUE",
              "TARGET_CPA",
              "TARGET_ROAS"
            ],
            "description": "the bidding strategy"
          },
          "targetCpaUsd": {
            "description": "REQUIRED for TARGET_CPA",
            "type": "number"
          },
          "targetRoas": {
            "description": "REQUIRED for TARGET_ROAS — e.g. 4 = $4 revenue per $1 spent",
            "type": "number"
          },
          "maxCpcUsd": {
            "description": "MAXIMIZE_CLICKS — the max CPC ceiling; REQUIRED when switching an existing campaign to it",
            "type": "number"
          },
          "enhancedCpc": {
            "description": "MANUAL_CPC only",
            "type": "boolean"
          },
          "confirm": {
            "description": "REQUIRED true to change a LIVE (ENABLED) campaign",
            "type": "boolean"
          },
          "dryRun": {
            "type": "boolean"
          },
          "loginCustomerId": {
            "type": "string"
          }
        },
        "required": [
          "campaignId",
          "strategy"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "find_google_ads_locations",
      "title": "Look up Google Ads locations",
      "description": "Look up Google Ads location targets by name — turns \"Toronto\" / \"California\" / \"United Kingdom\" into the geo target ids Google needs, with each one's type (COUNTRY, STATE, CITY, POSTAL_CODE…) and reach. Use it when a location name is ambiguous, or to show the user exactly which place you are about to target. Read-only and free.",
      "group": "ads",
      "section": "GOOGLE ADS CHANGE HISTORY (2026-08-05)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "query": {
            "type": "string",
            "description": "one location name, or several comma-separated (up to 25)"
          },
          "countryCode": {
            "description": "2-letter hint, e.g. CA to disambiguate \"London\"",
            "type": "string"
          },
          "loginCustomerId": {
            "type": "string"
          }
        },
        "required": [
          "query"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_google_ads_budget",
      "title": "Set a Google Ads campaign budget",
      "description": "Create a new daily budget, or change an existing budget’s daily amount (pass budgetResourceName). Raising the budget on a LIVE (ENABLED) campaign increases real spend immediately — you MUST show the user the new daily amount, get an explicit yes, then call with confirm:true. Creating a budget or lowering one on a paused campaign is safe.",
      "group": "ads",
      "section": "GOOGLE ADS CHANGE HISTORY (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "customerId": {
            "description": "10-digit account id (dashes ok) — omit to use the brand’s selected default account",
            "type": "string"
          },
          "dailyBudgetUsd": {
            "type": "number",
            "description": "daily budget in USD (1–100000)"
          },
          "budgetResourceName": {
            "description": "existing budget to UPDATE — omit to CREATE a new budget",
            "type": "string"
          },
          "name": {
            "description": "name for a newly created budget",
            "type": "string"
          },
          "confirm": {
            "description": "REQUIRED true to raise the budget of a LIVE campaign",
            "type": "boolean"
          },
          "loginCustomerId": {
            "description": "manager id if operating through an MCC",
            "type": "string"
          }
        },
        "required": [
          "dailyBudgetUsd"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_google_ads_status",
      "title": "Enable, pause or remove a Google Ads campaign / ad group / ad",
      "description": "Turn a campaign, AD GROUP or AD ON (ENABLED), OFF (PAUSED) or REMOVED. Pass level:\"campaign\" + campaignId, level:\"adGroup\" + adGroupId, or level:\"ad\" + BOTH adGroupId and adId (Google keys an ad by adGroupId~adId). ENABLING STARTS REAL AD SPEND — you MUST first show the user the campaign name + its daily budget, get an explicit yes, then call with status:\"ENABLED\" and confirm:true. Pausing is always safe. REMOVED is PERMANENT in Google Ads and is handled by the same gate as delete_google_ads_object — call it once WITHOUT confirm to see what goes with it, and expect to echo back the object’s name and child count when it has children, is live, or has spent. The resulting status is READ BACK from Google before you are told it took.",
      "group": "ads",
      "section": "GOOGLE ADS CHANGE HISTORY (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "customerId": {
            "description": "10-digit account id (dashes ok) — omit to use the brand’s selected default account",
            "type": "string"
          },
          "level": {
            "description": "what to change — default campaign",
            "type": "string",
            "enum": [
              "campaign",
              "adGroup",
              "ad"
            ]
          },
          "campaignId": {
            "description": "campaign id (level:\"campaign\")",
            "type": "string"
          },
          "adGroupId": {
            "description": "ad group id (level:\"adGroup\", or with adId for level:\"ad\")",
            "type": "string"
          },
          "adId": {
            "description": "ad id (level:\"ad\" — pass adGroupId too)",
            "type": "string"
          },
          "campaignResourceName": {
            "description": "full resource name, e.g. customers/{cid}/campaigns/{id}",
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "ENABLED",
              "PAUSED",
              "REMOVED"
            ],
            "description": "ENABLED = start spending; PAUSED = stop; REMOVED = permanent"
          },
          "confirm": {
            "description": "REQUIRED true to ENABLE (real spend) or to REMOVE (permanent)",
            "type": "boolean"
          },
          "loginCustomerId": {
            "description": "manager id if operating through an MCC",
            "type": "string"
          }
        },
        "required": [
          "status"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_google_ads_object",
      "title": "Remove a Google Ads campaign / ad group / ad / keyword / asset link / conversion action",
      "description": "PERMANENTLY remove a Google Ads object. Google has no HTTP delete — removal is a `remove` operation that puts the object in the terminal REMOVED state, which cannot be undone or re-enabled, so treat it as a delete. Levels: \"campaign\" + campaignId · \"adGroup\" + adGroupId · \"ad\" + adGroupId AND adId · \"keyword\" + adGroupId AND keywordId · \"conversionAction\" + conversionActionId · \"campaignAsset\"/\"adGroupAsset\" + the LINK’s full resourceName (get it from google_ads_report over campaign_asset / ad_group_asset — an asset id alone does not identify a link). THERE IS DELIBERATELY NO \"asset\" LEVEL: Google publishes no operation that deletes an Asset, only its links, so removing a link unlinks the asset and leaves it in the library. CALL IT WITHOUT confirm FIRST — nothing is removed and you get the object’s real name, status, LIFETIME SPEND and child counts read live from Google; show the user exactly that. A target with children, live delivery or real spend additionally needs confirmName (its exact name) and confirmChildren (the child count from that read-back). Removing a CAMPAIGN also removes its campaign-owned budget, and the note says whether it did. Removing the last ENABLED conversion action makes every smart-bidding campaign on the account undeliverable — the refusal says so. To stop delivery without removing, use set_google_ads_status(status:\"PAUSED\").",
      "group": "ads",
      "section": "GOOGLE ADS CHANGE HISTORY (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "customerId": {
            "description": "10-digit account id (dashes ok) — omit to use the brand’s selected default account",
            "type": "string"
          },
          "level": {
            "description": "what to remove — default campaign",
            "type": "string",
            "enum": [
              "campaign",
              "adGroup",
              "ad",
              "keyword",
              "campaignAsset",
              "adGroupAsset",
              "conversionAction"
            ]
          },
          "campaignId": {
            "description": "campaign id (level:\"campaign\")",
            "type": "string"
          },
          "adGroupId": {
            "description": "ad group id (level:\"adGroup\"; REQUIRED as the parent for \"ad\" and \"keyword\")",
            "type": "string"
          },
          "adId": {
            "description": "ad id (level:\"ad\" — pass adGroupId too)",
            "type": "string"
          },
          "keywordId": {
            "description": "keyword criterion id (level:\"keyword\" — pass adGroupId too)",
            "type": "string"
          },
          "conversionActionId": {
            "description": "conversion action id (level:\"conversionAction\")",
            "type": "string"
          },
          "resourceName": {
            "description": "full resource name — REQUIRED for campaignAsset / adGroupAsset, accepted for any level",
            "type": "string"
          },
          "confirm": {
            "description": "REQUIRED true — REMOVED is permanent",
            "type": "boolean"
          },
          "confirmName": {
            "description": "the object’s EXACT name, required when it has children / is live / has spent",
            "type": "string"
          },
          "confirmChildren": {
            "description": "the exact number of children reported by the unconfirmed call, required when it has any",
            "type": "number"
          },
          "loginCustomerId": {
            "description": "manager id if operating through an MCC",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "upload_google_ads_asset",
      "title": "Upload a creative to Google Ads",
      "description": "Add a creative to a Google Ads account’s ASSET LIBRARY so it can be used in ads. It does NOT have to be a Hermoso render — the user’s own creative is the normal case; the URL just has to be Hermoso-HOSTED because we fetch the bytes, so run any file of theirs through upload_file and pass the URL it returns. For an IMAGE, pass imageUrl (≤5MB); an arbitrary external/CDN URL is refused. For VIDEO, Google Ads uses YouTube-hosted videos — post the video to YouTube as UNLISTED first (post_to_youtube with privacy:\"unlisted\" — link-only, not public or searchable, and unlike \"private\" it CAN run as an ad), then pass its youtubeVideoId here. Returns the asset resource name. Pass customerId (from list_google_ads_campaigns).",
      "group": "ads",
      "section": "GOOGLE ADS CHANGE HISTORY (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "customerId": {
            "description": "10-digit account id (dashes ok) — omit to use the brand’s selected default account",
            "type": "string"
          },
          "imageUrl": {
            "description": "a Hermoso-hosted image URL for an IMAGE asset (≤5MB) — a Hermoso render, or the user’s OWN creative put through upload_file first. An arbitrary external/CDN URL is refused.",
            "type": "string"
          },
          "youtubeVideoId": {
            "description": "a YouTube video id for a VIDEO asset (post_to_youtube first)",
            "type": "string"
          },
          "name": {
            "description": "asset name",
            "type": "string"
          },
          "loginCustomerId": {
            "description": "manager id if operating through an MCC",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_google_ads_conversion_action",
      "title": "Create a Google Ads conversion action",
      "description": "Create a CONVERSION ACTION — the thing that tells Google what counts as a result on this account. This is a PREREQUISITE, not a nicety: MAXIMIZE_CONVERSIONS, MAXIMIZE_CONVERSION_VALUE, TARGET_CPA, TARGET_ROAS and every Performance Max campaign are undeliverable without one, because Google has nothing to optimise toward. type WEBPAGE (a purchase / lead / signup on the site — the normal choice), UPLOAD_CLICKS or UPLOAD_CALLS; every other Google conversion type (Firebase, Google Analytics 4, Floodlight, store visits) is READ-ONLY and is created in those products, not here. Set category to what actually happened (PURCHASE, SUBMIT_LEAD_FORM, SIGNUP, BOOK_APPOINTMENT…) and defaultValueUsd when a conversion has a known worth — TARGET_ROAS has nothing to maximise without a value. Created ENABLED and counted in \"conversions\" by default, because a conversion action that is neither records nothing. It CANNOT SERVE AN AD and cannot spend a cent, so it needs no confirmation. A WEBPAGE action records NOTHING until its Google tag is installed on the site — say that when you report it.",
      "group": "ads",
      "section": "Google Ads breadth (Dave 2026-07-31): the four holes the connector audit found.",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "customerId": {
            "description": "10-digit account id (dashes ok) — omit to use the brand’s selected default account",
            "type": "string"
          },
          "name": {
            "type": "string",
            "description": "what the user calls this result, e.g. \"Purchase\", \"Demo request\""
          },
          "type": {
            "description": "default WEBPAGE — a conversion that happens on the website",
            "type": "string",
            "enum": [
              "WEBPAGE",
              "UPLOAD_CLICKS",
              "UPLOAD_CALLS"
            ]
          },
          "category": {
            "description": "what kind of result this is — default DEFAULT",
            "type": "string",
            "enum": [
              "DEFAULT",
              "PAGE_VIEW",
              "PURCHASE",
              "SIGNUP",
              "DOWNLOAD",
              "ADD_TO_CART",
              "BEGIN_CHECKOUT",
              "SUBSCRIBE_PAID",
              "PHONE_CALL_LEAD",
              "IMPORTED_LEAD",
              "SUBMIT_LEAD_FORM",
              "BOOK_APPOINTMENT",
              "REQUEST_QUOTE",
              "GET_DIRECTIONS",
              "OUTBOUND_CLICK",
              "CONTACT",
              "ENGAGEMENT",
              "STORE_VISIT",
              "STORE_SALE",
              "QUALIFIED_LEAD",
              "CONVERTED_LEAD",
              "YOUTUBE_FOLLOW_ON_VIEWS"
            ]
          },
          "status": {
            "description": "default ENABLED — anything else records nothing",
            "type": "string",
            "enum": [
              "ENABLED",
              "PAUSED",
              "REMOVED",
              "HIDDEN"
            ]
          },
          "countingType": {
            "description": "ONE_PER_CLICK for leads, MANY_PER_CLICK for sales — defaults by category",
            "type": "string",
            "enum": [
              "ONE_PER_CLICK",
              "MANY_PER_CLICK"
            ]
          },
          "defaultValueUsd": {
            "description": "what one conversion is worth — required in practice for TARGET_ROAS",
            "type": "number"
          },
          "defaultCurrencyCode": {
            "description": "3-letter ISO code, e.g. USD",
            "type": "string"
          },
          "alwaysUseDefaultValue": {
            "description": "ignore any value sent with the conversion and always use the default",
            "type": "boolean"
          },
          "clickThroughLookbackDays": {
            "description": "1–90 days",
            "type": "number"
          },
          "viewThroughLookbackDays": {
            "description": "1–30 days",
            "type": "number"
          },
          "includeInConversionsMetric": {
            "description": "default true — false makes smart bidding IGNORE it",
            "type": "boolean"
          },
          "primaryForGoal": {
            "description": "default true — whether this action is biddable for its category",
            "type": "boolean"
          },
          "dryRun": {
            "description": "validate against Google and create NOTHING",
            "type": "boolean"
          },
          "loginCustomerId": {
            "description": "manager id if operating through an MCC",
            "type": "string"
          }
        },
        "required": [
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_google_ads_conversion_actions",
      "title": "List Google Ads conversion actions",
      "description": "List the conversion actions on a Google Ads account and say plainly whether smart bidding can work there. Call this BEFORE proposing MAXIMIZE_CONVERSIONS / MAXIMIZE_CONVERSION_VALUE / TARGET_CPA / TARGET_ROAS or any Performance Max campaign: an account with no ENABLED conversion action that counts toward \"conversions\" cannot optimise on any of them, and the campaign would spend its budget without ever learning. Shows each action’s status, type, category, counting type, and whether it counts toward \"conversions\". Read-only, free.",
      "group": "ads",
      "section": "Google Ads breadth (Dave 2026-07-31): the four holes the connector audit found.",
      "annotations": {
        "readOnlyHint": true,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "customerId": {
            "description": "10-digit account id (dashes ok) — omit to use the brand’s selected default account",
            "type": "string"
          },
          "includeRemoved": {
            "description": "also list REMOVED conversion actions",
            "type": "boolean"
          },
          "loginCustomerId": {
            "description": "manager id if operating through an MCC",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "add_google_ads_assets",
      "title": "Add sitelinks / callouts / structured snippets to a Google Ads campaign",
      "description": "Add SITELINKS, CALLOUTS or STRUCTURED SNIPPETS to a Google Ads campaign or ad group — and ATTACH them, which is the part that makes them do anything (an asset sitting in the account library shows nothing at all). Sitelinks are the highest-CTR free win on Search: extra links under the ad, each with its own landing page. Pass assetType plus assets[]: SITELINK needs {linkText (≤25 chars), finalUrl, and optionally description1 / description2}; CALLOUT needs {calloutText (≤25)}; STRUCTURED_SNIPPET needs {header, values[] — at least 3}. Or link assets that already exist with assetResourceNames[]. Assets and links go up in ONE atomic operation, so a rejected link never strands an orphan asset, and the links are READ BACK from Google before you are told they exist. Attaching a live asset to a LIVE (ENABLED) campaign changes what that ad shows on the very next auction — show the user what would appear, get an explicit yes, then pass confirm:true. On a paused campaign it never needs confirmation.",
      "group": "ads",
      "section": "Google Ads breadth (Dave 2026-07-31): the four holes the connector audit found.",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "customerId": {
            "description": "10-digit account id (dashes ok) — omit to use the brand’s selected default account",
            "type": "string"
          },
          "assetType": {
            "type": "string",
            "enum": [
              "SITELINK",
              "CALLOUT",
              "STRUCTURED_SNIPPET"
            ],
            "description": "what kind of asset to create and attach"
          },
          "level": {
            "description": "where to attach it — default campaign",
            "type": "string",
            "enum": [
              "campaign",
              "adGroup"
            ]
          },
          "campaignId": {
            "description": "campaign id (level:\"campaign\")",
            "type": "string"
          },
          "adGroupId": {
            "description": "ad group id (level:\"adGroup\")",
            "type": "string"
          },
          "assets": {
            "description": "the assets to CREATE and attach",
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "linkText": {
                  "description": "SITELINK — the clickable label, ≤25 characters",
                  "type": "string"
                },
                "finalUrl": {
                  "description": "SITELINK — the page it opens",
                  "type": "string"
                },
                "finalMobileUrl": {
                  "description": "SITELINK — a different page on mobile",
                  "type": "string"
                },
                "description1": {
                  "description": "SITELINK — first description line",
                  "type": "string"
                },
                "description2": {
                  "description": "SITELINK — second description line",
                  "type": "string"
                },
                "calloutText": {
                  "description": "CALLOUT — ≤25 characters, e.g. \"Free 2-day shipping\"",
                  "type": "string"
                },
                "header": {
                  "description": "STRUCTURED_SNIPPET — e.g. \"Services\", \"Brands\", \"Types\"",
                  "type": "string"
                },
                "values": {
                  "description": "STRUCTURED_SNIPPET — at least 3 values",
                  "type": "array",
                  "items": {
                    "type": "string"
                  }
                },
                "name": {
                  "description": "optional asset name in the library",
                  "type": "string"
                }
              }
            }
          },
          "assetResourceNames": {
            "description": "attach assets that ALREADY exist instead of creating new ones",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "status": {
            "description": "the LINK status — default ENABLED",
            "type": "string",
            "enum": [
              "ENABLED",
              "PAUSED"
            ]
          },
          "confirm": {
            "description": "set true ONLY after the user approved changing what a LIVE campaign shows",
            "type": "boolean"
          },
          "dryRun": {
            "description": "validate against Google and create NOTHING",
            "type": "boolean"
          },
          "loginCustomerId": {
            "description": "manager id if operating through an MCC",
            "type": "string"
          }
        },
        "required": [
          "assetType"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_google_ads_performance_max_campaign",
      "title": "Create a Google Ads Performance Max campaign",
      "description": "Build a PERFORMANCE MAX campaign — Google’s cross-surface campaign type (Search, YouTube, Display, Discover, Gmail, Maps) and the one Google pushes hardest at small advertisers. ALWAYS created PAUSED; it spends NOTHING until you enable it with set_google_ads_status(confirm:true). PMax has NO manual bidding and NO keywords: it bids only on conversions, so the account MUST already have a conversion action — check with list_google_ads_conversion_actions, because this REFUSES rather than build a campaign that cannot optimise. Creative lives in an ASSET GROUP, and Google’s minimums are enforced before anything is sent: 3–15 headlines (≤30 chars), 1–5 longHeadlines (≤90), 2–5 descriptions (≤90), one businessName (≤25), at least one LOGO (1:1), one MARKETING_IMAGE (1.91:1) and one SQUARE_MARKETING_IMAGE (1:1) — upload the images with upload_google_ads_asset first and pass their asset resource names. A YouTube video is optional (Google generates one from the asset group if you omit it). Brand guidelines: since Google Ads API v21 they are ON by default for new PMax campaigns, which means the businessName and LOGO assets are linked to the CAMPAIGN (CampaignAsset), not to the asset group — Hermoso does that for you. Leave brandGuidelinesEnabled alone unless the user wants the older asset-group layout, and pass false for that. Budget, campaign, location/language targeting, the asset group and every asset link go up in ONE ATOMIC operation — if any part is rejected, nothing at all is created — and the whole tree is READ BACK from Google before you are told it exists. Print the returned note verbatim; if it says the campaign cannot serve, say that instead of calling it finished. RETAIL / Shopping Performance Max (a Merchant Center product feed with listing groups) is NOT supported here and is refused by name.",
      "group": "ads",
      "section": "Google Ads breadth (Dave 2026-07-31): the four holes the connector audit found.",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "customerId": {
            "description": "10-digit account id (dashes ok) — omit to use the brand’s selected default account",
            "type": "string"
          },
          "name": {
            "type": "string",
            "description": "campaign name"
          },
          "dailyBudgetUsd": {
            "description": "daily budget in USD (1–100000) — or pass budgetResourceName",
            "type": "number"
          },
          "budgetResourceName": {
            "description": "an existing budget to reuse",
            "type": "string"
          },
          "bidding": {
            "type": "object",
            "properties": {
              "strategy": {
                "description": "default MAXIMIZE_CONVERSIONS — PMax has no manual bidding",
                "type": "string",
                "enum": [
                  "MAXIMIZE_CONVERSIONS",
                  "MAXIMIZE_CONVERSION_VALUE",
                  "TARGET_CPA",
                  "TARGET_ROAS"
                ]
              },
              "targetCpaUsd": {
                "description": "required for TARGET_CPA",
                "type": "number"
              },
              "targetRoas": {
                "description": "required for TARGET_ROAS, e.g. 4 = $4 revenue per $1 spent",
                "type": "number"
              }
            }
          },
          "locations": {
            "description": "place NAMES (\"United States\", \"Toronto\") — resolved for you",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "excludedLocations": {
            "description": "places to block",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "languages": {
            "description": "ISO codes, e.g. [\"en\"]",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "countryCode": {
            "description": "2-letter hint to disambiguate a city name",
            "type": "string"
          },
          "startDate": {
            "description": "YYYY-MM-DD",
            "type": "string"
          },
          "endDate": {
            "description": "YYYY-MM-DD",
            "type": "string"
          },
          "containsEuPoliticalAds": {
            "description": "true ONLY for genuine EU political advertising",
            "type": "boolean"
          },
          "brandGuidelinesEnabled": {
            "description": "default TRUE, matching Google’s own default since v21: businessName + logos are linked to the CAMPAIGN. Pass false only for the pre-v21 layout, where they sit on the asset group instead",
            "type": "boolean"
          },
          "assetGroup": {
            "type": "object",
            "properties": {
              "name": {
                "type": "string",
                "description": "asset group name"
              },
              "finalUrls": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "the landing page(s) — at least one"
              },
              "headlines": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "3–15, each ≤30 characters"
              },
              "longHeadlines": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "1–5, each ≤90 characters"
              },
              "descriptions": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "2–5, each ≤90 characters"
              },
              "businessName": {
                "type": "string",
                "description": "≤25 characters"
              },
              "logoAssets": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "at least one 1:1 LOGO asset resource name from upload_google_ads_asset"
              },
              "landscapeLogos": {
                "description": "optional 4:1 LANDSCAPE_LOGO asset resource names — LOGO + LANDSCAPE_LOGO may total at most 5",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "marketingImages": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "at least one 1.91:1 asset resource name"
              },
              "squareMarketingImages": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "at least one 1:1 asset resource name"
              },
              "youtubeVideos": {
                "description": "optional YOUTUBE_VIDEO asset resource names",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "path1": {
                "description": "display-URL path, ≤15 characters",
                "type": "string"
              },
              "path2": {
                "description": "second display-URL path, ≤15 characters",
                "type": "string"
              }
            },
            "required": [
              "name",
              "finalUrls",
              "headlines",
              "longHeadlines",
              "descriptions",
              "businessName",
              "logoAssets",
              "marketingImages",
              "squareMarketingImages"
            ],
            "description": "the creative — Google requires every field above before a PMax campaign can serve"
          },
          "dryRun": {
            "description": "validate the whole tree against Google and create NOTHING",
            "type": "boolean"
          },
          "loginCustomerId": {
            "description": "manager id if operating through an MCC",
            "type": "string"
          }
        },
        "required": [
          "name",
          "assetGroup"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "google_ads_keyword_ideas",
      "title": "Google Keyword Planner — keyword ideas with real search volume",
      "description": "Google’s own KEYWORD PLANNER: real keyword ideas with average monthly search volume, competition level and top-of-page bid estimates, so keyword choices are measured instead of guessed. Seed it with keywords[] (terms you already have), url (one landing page to mine) or site (a whole domain — the fastest way to size a competitor). Narrow by locations (place NAMES, resolved for you) and language. Results come back sorted by monthly volume. Use this BEFORE add_google_ads_keywords or create_google_ads_campaign so the ad group targets terms people actually search, and quote the volumes when you propose them. Read-only, free, spends nothing and creates nothing.",
      "group": "ads",
      "section": "Google Ads breadth (Dave 2026-07-31): the four holes the connector audit found.",
      "annotations": {
        "readOnlyHint": true,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "customerId": {
            "description": "10-digit account id (dashes ok) — omit to use the brand’s selected default account",
            "type": "string"
          },
          "keywords": {
            "description": "up to 20 seed terms",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "url": {
            "description": "one page to mine for ideas",
            "type": "string"
          },
          "site": {
            "description": "a whole domain to mine, e.g. example.com",
            "type": "string"
          },
          "locations": {
            "description": "place NAMES, e.g. [\"United States\"]",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "language": {
            "description": "ISO code, e.g. \"en\"",
            "type": "string"
          },
          "countryCode": {
            "description": "2-letter hint to disambiguate a city name",
            "type": "string"
          },
          "network": {
            "description": "default GOOGLE_SEARCH",
            "type": "string",
            "enum": [
              "GOOGLE_SEARCH",
              "GOOGLE_SEARCH_AND_PARTNERS"
            ]
          },
          "limit": {
            "description": "how many ideas to return (1–200, default 50)",
            "type": "number"
          },
          "loginCustomerId": {
            "description": "manager id if operating through an MCC",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_microsoft_ads_campaigns",
      "title": "List Microsoft Advertising accounts / campaigns",
      "description": "Read the brand’s connected Microsoft Advertising (Bing Ads) account(s). Call with NO accountId to list the accounts shared with this brand — do this first to pick a target. Call WITH accountId to list that account’s campaigns (id, name, status, daily budget, campaign type, and whether the budget is SHARED). Microsoft statuses are Active / Paused — never Google’s ENABLED — and Microsoft also sets BudgetPaused, BudgetAndManualPaused and Suspended on its own, so report the status you read rather than assuming a paused campaign was paused by a person. Read-only, free. Needs Microsoft Advertising connected (Settings ▸ Connectors ▸ Microsoft Advertising).",
      "group": "ads",
      "section": "Microsoft Advertising (Bing Ads): read + manage. Same spend law as Google — everything is created",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "description": "Microsoft ad account id — omit to list the accounts shared with this brand",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "microsoft_ads_report",
      "title": "Microsoft Advertising performance report",
      "description": "Performance for a Microsoft Advertising account — impressions, clicks, CTR, average CPC, spend, conversions. `reportType` picks WHICH report, and that is the whole Microsoft reporting surface, not just campaigns: AdGroupPerformance, AdPerformance, KeywordPerformance, SearchQueryPerformance (the actual search terms people typed), GeographicPerformance, UserLocationPerformance, AgeGenderAudience and ProfessionalDemographicsAudience (LinkedIn-sourced job function and industry, inside Bing), ConversionPerformance, DestinationUrlPerformance, ShareOfVoice, AssetPerformance, ProductDimensionPerformance, SearchCampaignChangeHistory (\"what changed on Tuesday\") and ~30 more — an unknown name is refused WITH the full list rather than forwarded. `aggregation` controls the row grain (Summary / Daily / Hourly / Weekly / Monthly / Yearly / HourOfDay / DayOfWeek). Two reports keep far less history than the usual 36 months — AssetPerformance 30 days, ShareOfVoice 6 — and the reply says so, because an empty short-retention report is a retention limit, not an absence of delivery. Window via timePeriod (Today | Yesterday | LastSevenDays | Last14Days | Last30Days | ThisWeek | LastWeek | LastFourWeeks | ThisMonth | LastMonth | LastThreeMonths | LastSixMonths | ThisYear | LastYear | ThisWeekStartingMonday | LastWeekStartingMonday | LastFourWeeksStartingMonday) or since+until (YYYY-MM-DD) — default Last30Days. An unrecognised timePeriod is REFUSED, never silently swapped for another window. Microsoft generates reports ASYNCHRONOUSLY: this can return pending:true with a reportRequestId, and you must call again rather than reporting any numbers. A report that succeeds with ZERO rows genuinely means there was no delivery in that window — say exactly that; never present zeros as measured performance. Read-only, free.",
      "group": "ads",
      "section": "Microsoft Advertising (Bing Ads): read + manage. Same spend law as Google — everything is created",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "description": "Microsoft ad account id — omit to use the brand’s single shared account",
            "type": "string"
          },
          "timePeriod": {
            "description": "predefined Microsoft window, default Last30Days — must be one of the values in the description; anything else is rejected",
            "type": "string"
          },
          "since": {
            "description": "YYYY-MM-DD custom range start (with until)",
            "type": "string"
          },
          "until": {
            "description": "YYYY-MM-DD custom range end",
            "type": "string"
          },
          "columns": {
            "description": "report columns — defaults to campaign performance. Each report type accepts only its OWN column set; Microsoft also refuses impression-share columns alongside BidMatchType / BudgetName / DeviceOS / Goal / TopVsOther in the same request.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "reportType": {
            "description": "which report — default CampaignPerformanceReportRequest. An unknown name is refused with the full list.",
            "type": "string"
          },
          "aggregation": {
            "description": "default Summary. Hourly accepts only Today/Yesterday or a custom range.",
            "type": "string",
            "enum": [
              "Summary",
              "Hourly",
              "Daily",
              "Weekly",
              "Monthly",
              "Yearly",
              "HourOfDay",
              "DayOfWeek",
              "WeeklyStartingMonday"
            ]
          },
          "reportRequestId": {
            "description": "pick up a report that came back pending — pass it back and this RESUMES that exact report instead of submitting a new one",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "microsoft_ads_keyword_ideas",
      "title": "Microsoft Advertising keyword planner",
      "description": "Microsoft Advertising’s KEYWORD PLANNER — real monthly search volume, competition, suggested bid and ad impression share, expanded from seed keywords, a landing-page URL to mine, or a category. Run it BEFORE choosing keywords for a Microsoft campaign, exactly as you would google_ads_keyword_ideas for Google. Unlike Google’s Keyword Planner there is NO planning-tier gate here — a developer token is sufficient. locationIds is REQUIRED and deliberately not defaulted: a search volume with no market attached is a number nobody can act on, and inventing a country would silently answer about the wrong market — use microsoft_ads_geo_search to resolve a country or city name to an id, free. SAY THIS WHEN REPORTING: Competition is Microsoft’s Low/Medium/High bucket, NOT a percentage; MonthlySearchCounts is a per-month series rather than one number; SuggestedBid is in the account currency. An empty result means Microsoft found no ideas for those seeds, never that nobody searches for them. Read-only, 0 credits.",
      "group": "ads",
      "section": "MICROSOFT AD INSIGHT — KEYWORD PLANNING (2026-08-05)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "type": "string"
          },
          "keywords": {
            "description": "seed terms to expand from",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "url": {
            "description": "a landing page for Microsoft to mine ideas from",
            "type": "string"
          },
          "categoryId": {
            "type": "number"
          },
          "locationIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "REQUIRED — Microsoft location ids (microsoft_ads_geo_search resolves names to ids, free)"
          },
          "language": {
            "description": "default English",
            "type": "string"
          },
          "network": {
            "type": "string",
            "enum": [
              "OwnedAndOperatedAndSyndicatedSearch",
              "OwnedAndOperatedOnly",
              "SyndicatedSearchOnly"
            ]
          },
          "competition": {
            "description": "filter to Low | Medium | High",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "minSearchVolume": {
            "type": "number"
          },
          "maxSearchVolume": {
            "type": "number"
          },
          "attributes": {
            "description": "which idea attributes to return — omit for all",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "expandIdeas": {
            "description": "false = do not expand; then keywords[] is mandatory",
            "type": "boolean"
          }
        },
        "required": [
          "locationIds"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "microsoft_ads_traffic_estimates",
      "title": "Microsoft Advertising traffic estimates",
      "description": "What a set of keywords would DELIVER on Microsoft Advertising at a given bid — estimated impressions, clicks, CTR, average CPC, average position and total cost. maxCpc is REQUIRED because a traffic estimate IS a function of the bid; estimating without one would be inventing the input. locationIds is REQUIRED for the same reason a search volume needs a market. SAY THIS WHEN REPORTING: Microsoft returns a MINIMUM and a MAXIMUM per keyword — quote the range, never average the two into a single figure — and every number here is a FORECAST, so never present it as measured performance. Read-only, 0 credits.",
      "group": "ads",
      "section": "MICROSOFT AD INSIGHT — KEYWORD PLANNING (2026-08-05)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "type": "string"
          },
          "keywords": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "the keywords to estimate"
          },
          "maxCpc": {
            "type": "number",
            "description": "REQUIRED — the max CPC bid to estimate at, in the account currency"
          },
          "matchType": {
            "description": "default Exact",
            "type": "string",
            "enum": [
              "Exact",
              "Phrase",
              "Broad"
            ]
          },
          "locationIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "REQUIRED — Microsoft location ids"
          },
          "language": {
            "type": "string"
          },
          "network": {
            "type": "string"
          },
          "dailyBudget": {
            "type": "number"
          }
        },
        "required": [
          "keywords",
          "maxCpc",
          "locationIds"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "microsoft_ads_budget_opportunities",
      "title": "Where Microsoft says budget is capping delivery",
      "description": "Where a Microsoft Advertising campaign is BUDGET-CONSTRAINED — Microsoft’s own recommended budget against the current one, the estimated WEEKLY click and impression gain from raising it, and a budget/return curve. Omit campaignId for the whole account. SAY THIS WHEN REPORTING: these are Microsoft’s FORECASTS, never measurements — a projected increase has not happened — and acting on one spends real money, so it takes set_microsoft_ads_budget and an explicit yes from the user. Microsoft EXCLUDES user-paused campaigns from this analysis, so a paused campaign is absent by design rather than well-funded. Read-only, 0 credits.",
      "group": "ads",
      "section": "MICROSOFT AD INSIGHT — KEYWORD PLANNING (2026-08-05)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "type": "string"
          },
          "campaignId": {
            "description": "omit for the whole account",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "microsoft_ads_geo_search",
      "title": "Find Microsoft Advertising location ids",
      "description": "Resolve country / region / city names to the Microsoft Advertising location ids that create_microsoft_ads_campaign needs. Read-only, free, 0 credits. Use it when a location ask is ambiguous (\"Springfield\") — this returns EVERY candidate with its id so the USER can pick, and you never guess between two places. Accepts names, ISO country codes (\"CA\"), or numeric location ids. Pass `query` as ONE ask (a plain string) or SEVERAL (an array of strings) — a comma is part of a place's name (\"Seattle, Washington, United States\"), never a separator. Postal codes and neighbourhoods are not name-searchable — pass their numeric location id straight through; the campaign read-back reports the name Microsoft resolves for it.",
      "group": "ads",
      "section": "MICROSOFT AD INSIGHT — KEYWORD PLANNING (2026-08-05)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "description": "Microsoft ad account id — omit to use the brand’s single shared account",
            "type": "string"
          },
          "query": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            ],
            "description": "ONE location ask as a plain string, or several as an array of strings — names, ISO country codes, or numeric Microsoft location ids. A comma belongs to a name (\"Seattle, Washington, United States\") and is NOT a separator: pass several places as several array items, never one comma-joined string."
          }
        },
        "required": [
          "query"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_microsoft_ads_campaign",
      "title": "Build a Microsoft Advertising campaign (paused)",
      "description": "Build a campaign on a connected Microsoft Advertising (Bing Ads) account. ALWAYS created Paused — it spends NOTHING until you activate it with set_microsoft_ads_status(confirm:true). Microsoft’s object graph is campaign → ad group → responsive search ad → keywords, so a campaign ON ITS OWN CANNOT SERVE AN IMPRESSION: pass adGroup{name, ad{headlines,descriptions,finalUrls}, keywords[]} and this builds the whole tree. Microsoft has NO atomic multi-object write (unlike Google), so the levels are created in sequence and the campaign is DELETED again if anything below it is rejected — you never inherit a half-built campaign. Microsoft requires 3–15 headlines (≤30 chars) and 2–4 descriptions (≤90 chars); expanded text ads can no longer be created at all. dailyBudget is in the ACCOUNT’S currency, not necessarily USD. LOCATION TARGETING: pass locations[] (country / region / city names, ISO country codes, or numeric Microsoft location ids). A Microsoft campaign has NO geo targeting unless it is set, and Microsoft does not require any — so if you pass none, the campaign IS CREATED and serves WORLDWIDE (Microsoft’s own default), and the returned note says so loudly. That is safe at this stage because the campaign is Paused and spends nothing; it is NOT safe to activate without telling the user, so relay the warning. Nothing is created when a location you DID name cannot be resolved (call microsoft_ads_geo_search to disambiguate, then pass the id). Pass worldwide:true to record that everywhere was deliberate and suppress the nudge. The locations are written and READ BACK inside the same rollback as the rest of the tree, so a campaign is either targeted as asked or does not exist. Everything is READ BACK from Microsoft before you are told it exists; print the returned note verbatim, and if it says the campaign cannot serve yet, say that rather than calling it a finished ad.",
      "group": "ads",
      "section": "MICROSOFT AD INSIGHT — KEYWORD PLANNING (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "description": "Microsoft ad account id — omit to use the brand’s single shared account",
            "type": "string"
          },
          "name": {
            "type": "string",
            "description": "campaign name, ≤128 characters"
          },
          "dailyBudget": {
            "type": "number",
            "description": "daily budget in the account’s currency"
          },
          "budgetType": {
            "description": "default DailyBudgetStandard; Accelerated is Audience-campaign only",
            "type": "string",
            "enum": [
              "DailyBudgetStandard",
              "DailyBudgetAccelerated",
              "LifetimeBudgetStandard"
            ]
          },
          "campaignType": {
            "description": "default Search",
            "type": "string"
          },
          "timeZone": {
            "description": "Microsoft time-zone enum — Microsoft requires one; default PacificTimeUSCanadaTijuana",
            "type": "string"
          },
          "locations": {
            "description": "where the ads may serve — omit for worldwide (Microsoft’s default, warned about in the read-back), e.g. [\"United States\"] or [\"Seattle, Washington, United States\",\"CA\"]. Resolved to Microsoft location ids BEFORE anything is created; an ambiguous or unknown one refuses the whole create and names it",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "excludeLocations": {
            "description": "locations to EXCLUDE from the targeted set",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "locationIntent": {
            "description": "default PeopleInOrSearchingForOrViewingPages — someone OUTSIDE the target still sees the ad if they search for the place; PeopleIn restricts to people physically there",
            "type": "string",
            "enum": [
              "PeopleInOrSearchingForOrViewingPages",
              "PeopleIn"
            ]
          },
          "worldwide": {
            "description": "set true when the user DELIBERATELY wants to serve everywhere. Omitting locations already creates a worldwide campaign; this only records that it was intended, so the read-back stops nudging you to add locations",
            "type": "boolean"
          },
          "languages": {
            "description": "campaign languages, e.g. [\"English\"]",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "adGroup": {
            "description": "build the serving tree in the same call — WITHOUT this you get a campaign shell that can never show an ad",
            "type": "object",
            "properties": {
              "name": {
                "type": "string"
              },
              "cpcBid": {
                "type": "number"
              },
              "language": {
                "type": "string"
              },
              "status": {
                "type": "string",
                "enum": [
                  "Active",
                  "Paused"
                ]
              },
              "ad": {
                "type": "object",
                "properties": {
                  "headlines": {
                    "description": "3–15 headlines, each ≤30 characters",
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "descriptions": {
                    "description": "2–4 descriptions, each ≤90 characters",
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "finalUrls": {
                    "description": "the landing page(s) — at least one is required",
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "path1": {
                    "description": "display-URL path segment, ≤15 chars, no \"/\"",
                    "type": "string"
                  },
                  "path2": {
                    "description": "second display-URL path segment (only with path1)",
                    "type": "string"
                  }
                }
              },
              "keywords": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "text": {
                      "type": "string",
                      "description": "≤100 characters"
                    },
                    "matchType": {
                      "description": "default Phrase — Microsoft has no broad-match-modifier",
                      "type": "string",
                      "enum": [
                        "Exact",
                        "Phrase",
                        "Broad"
                      ]
                    },
                    "bid": {
                      "description": "per-keyword max CPC in the account currency",
                      "type": "number"
                    },
                    "status": {
                      "description": "default Paused",
                      "type": "string",
                      "enum": [
                        "Active",
                        "Paused"
                      ]
                    }
                  },
                  "required": [
                    "text"
                  ]
                }
              }
            }
          }
        },
        "required": [
          "name",
          "dailyBudget"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_microsoft_ads_ad_group",
      "title": "Add a Microsoft Advertising ad group",
      "description": "Add an ad group to an existing Microsoft Advertising campaign — optionally with its responsive search ad and keywords in the same call. Created Paused by default. If the parent campaign is already LIVE (Active), creating this ad group Active starts REAL AD SPEND on the next auction, exactly like activating it — show the user what would begin serving, get an explicit yes, then pass confirm:true. Leaving it Paused never needs confirmation. Read back from Microsoft before you are told it exists.",
      "group": "ads",
      "section": "MICROSOFT AD INSIGHT — KEYWORD PLANNING (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "description": "Microsoft ad account id — omit to use the brand’s single shared account",
            "type": "string"
          },
          "campaignId": {
            "type": "string",
            "description": "the campaign this ad group belongs to"
          },
          "name": {
            "type": "string",
            "description": "ad group name, ≤256 characters"
          },
          "status": {
            "description": "default Paused",
            "type": "string",
            "enum": [
              "Active",
              "Paused"
            ]
          },
          "cpcBid": {
            "description": "default max CPC in the account currency",
            "type": "number"
          },
          "language": {
            "description": "required if the campaign has no language set",
            "type": "string"
          },
          "ad": {
            "description": "create the responsive search ad in the same call",
            "type": "object",
            "properties": {
              "headlines": {
                "description": "3–15 headlines, each ≤30 characters",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "descriptions": {
                "description": "2–4 descriptions, each ≤90 characters",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "finalUrls": {
                "description": "the landing page(s) — at least one is required",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "path1": {
                "description": "display-URL path segment, ≤15 chars, no \"/\"",
                "type": "string"
              },
              "path2": {
                "description": "second display-URL path segment (only with path1)",
                "type": "string"
              }
            }
          },
          "keywords": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "text": {
                  "type": "string",
                  "description": "≤100 characters"
                },
                "matchType": {
                  "description": "default Phrase — Microsoft has no broad-match-modifier",
                  "type": "string",
                  "enum": [
                    "Exact",
                    "Phrase",
                    "Broad"
                  ]
                },
                "bid": {
                  "description": "per-keyword max CPC in the account currency",
                  "type": "number"
                },
                "status": {
                  "description": "default Paused",
                  "type": "string",
                  "enum": [
                    "Active",
                    "Paused"
                  ]
                }
              },
              "required": [
                "text"
              ]
            }
          },
          "confirm": {
            "description": "REQUIRED true to create this Active under a LIVE campaign",
            "type": "boolean"
          }
        },
        "required": [
          "campaignId",
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_microsoft_ads_ad",
      "title": "Create a Microsoft responsive search ad",
      "description": "Create the actual AD inside a Microsoft Advertising ad group — this is the object that carries the creative; a campaign or ad group alone shows nothing. It builds a RESPONSIVE SEARCH AD: 3–15 headlines (≤30 chars), 2–4 descriptions (≤90 chars), at least one finalUrl, optional path1/path2. Expanded text ads CANNOT be created any more — Microsoft rejects them outright. Created Paused; if the parent ad group and campaign are both Active, creating this Active starts REAL AD SPEND immediately — get an explicit yes and pass confirm:true. Read back from Microsoft, including its editorial status, before you are told it exists.",
      "group": "ads",
      "section": "MICROSOFT AD INSIGHT — KEYWORD PLANNING (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "description": "Microsoft ad account id — omit to use the brand’s single shared account",
            "type": "string"
          },
          "adGroupId": {
            "type": "string",
            "description": "the ad group this ad lives in"
          },
          "headlines": {
            "description": "3–15 headlines, each ≤30 characters",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "descriptions": {
            "description": "2–4 descriptions, each ≤90 characters",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "finalUrls": {
            "description": "the landing page(s) — at least one is required",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "path1": {
            "description": "display-URL path segment, ≤15 chars, no \"/\"",
            "type": "string"
          },
          "path2": {
            "description": "second display-URL path segment (only with path1)",
            "type": "string"
          },
          "status": {
            "description": "default Paused",
            "type": "string",
            "enum": [
              "Active",
              "Paused"
            ]
          },
          "confirm": {
            "description": "REQUIRED true to create this Active in a LIVE ad group",
            "type": "boolean"
          }
        },
        "required": [
          "adGroupId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "add_microsoft_ads_keywords",
      "title": "Add Microsoft Advertising keywords",
      "description": "Add keywords to a Microsoft Advertising ad group. Match types are Exact, Phrase and Broad — Microsoft has no broad-match-modifier. Keywords are added Paused unless you set status:\"Active\"; an Active keyword on a live ad group makes the campaign bid on a new term immediately, so that needs confirm:true. Note that per-keyword bids are honoured but ad-group / keyword BID STRATEGIES are silently ignored by Microsoft — they inherit the campaign’s. Only the keywords Microsoft confirms on the read-back are reported as added.",
      "group": "ads",
      "section": "MICROSOFT AD INSIGHT — KEYWORD PLANNING (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "description": "Microsoft ad account id — omit to use the brand’s single shared account",
            "type": "string"
          },
          "adGroupId": {
            "type": "string",
            "description": "the ad group to add them to"
          },
          "keywords": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "text": {
                  "type": "string",
                  "description": "≤100 characters"
                },
                "matchType": {
                  "description": "default Phrase — Microsoft has no broad-match-modifier",
                  "type": "string",
                  "enum": [
                    "Exact",
                    "Phrase",
                    "Broad"
                  ]
                },
                "bid": {
                  "description": "per-keyword max CPC in the account currency",
                  "type": "number"
                },
                "status": {
                  "description": "default Paused",
                  "type": "string",
                  "enum": [
                    "Active",
                    "Paused"
                  ]
                }
              },
              "required": [
                "text"
              ]
            },
            "description": "the keywords"
          },
          "confirm": {
            "description": "REQUIRED true to add an Active keyword to a LIVE ad group",
            "type": "boolean"
          }
        },
        "required": [
          "adGroupId",
          "keywords"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_microsoft_ads_budget",
      "title": "Set a Microsoft Advertising daily budget",
      "description": "Change a Microsoft Advertising campaign’s DAILY BUDGET (in the account’s currency). Raising it on a LIVE (Active) campaign increases real spend immediately — you MUST show the user the new daily amount, get an explicit yes, then call with confirm:true. If the campaign is on a SHARED budget its amount is read-only here and this refuses with an explanation rather than pretending to change it. Read back after the change.",
      "group": "ads",
      "section": "MICROSOFT AD INSIGHT — KEYWORD PLANNING (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "description": "Microsoft ad account id — omit to use the brand’s single shared account",
            "type": "string"
          },
          "campaignId": {
            "type": "string",
            "description": "the campaign whose budget changes"
          },
          "dailyBudget": {
            "type": "number",
            "description": "new daily budget in the account’s currency"
          },
          "confirm": {
            "description": "REQUIRED true to change the budget of a LIVE campaign",
            "type": "boolean"
          }
        },
        "required": [
          "campaignId",
          "dailyBudget"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_microsoft_ads_status",
      "title": "Activate or pause a Microsoft Advertising campaign / ad group / ad",
      "description": "Turn a Microsoft Advertising campaign, AD GROUP or AD on (Active) or off (Paused). Pass level:\"campaign\" + campaignId, level:\"adGroup\" + adGroupId, or level:\"ad\" + BOTH adGroupId and adId. ACTIVATING STARTS REAL AD SPEND — you MUST first show the user the campaign name + its daily budget, get an explicit yes, then call with status:\"Active\" and confirm:true. Pausing is always safe. Microsoft has only these two statuses — its Deleted state is internal-only and cannot be SET — so to remove something use delete_microsoft_ads_object, which is a real delete operation, not a status. The resulting status is READ BACK from Microsoft before you are told it took — and Microsoft may report BudgetPaused / BudgetAndManualPaused / Suspended instead, which the note names explicitly.",
      "group": "ads",
      "section": "MICROSOFT AD INSIGHT — KEYWORD PLANNING (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "description": "Microsoft ad account id — omit to use the brand’s single shared account",
            "type": "string"
          },
          "level": {
            "description": "what to change — default campaign",
            "type": "string",
            "enum": [
              "campaign",
              "adGroup",
              "ad"
            ]
          },
          "campaignId": {
            "description": "campaign id (level:\"campaign\")",
            "type": "string"
          },
          "adGroupId": {
            "description": "ad group id (level:\"adGroup\", or with adId for level:\"ad\")",
            "type": "string"
          },
          "adId": {
            "description": "ad id (level:\"ad\" — pass adGroupId too)",
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "Active",
              "Paused"
            ],
            "description": "Active = start spending; Paused = stop"
          },
          "confirm": {
            "description": "REQUIRED true to set Active (real spend)",
            "type": "boolean"
          }
        },
        "required": [
          "status"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_microsoft_ads_object",
      "title": "Delete a Microsoft Advertising campaign / ad group / ad / keyword",
      "description": "PERMANENTLY delete a Microsoft Advertising campaign, ad group, ad or keyword. This is a real delete — Microsoft removes the object and it stops being returned by every read, with no undelete and no documented recovery window. Pass level:\"campaign\" + campaignId, level:\"adGroup\" + adGroupId, level:\"ad\" + adGroupId AND adId, or level:\"keyword\" + adGroupId AND keywordId. CALL IT WITHOUT confirm FIRST: nothing is deleted, and you get back the object’s real name, its status and how many ad groups / ads / keywords go with it, read live from Microsoft — show the user exactly that. If the object has children, is Active, or has spent, confirming alone is NOT enough: you must also pass confirmName set to its exact name and confirmChildren set to the child count from that read-back, which is what proves you are deleting the object you think you are. A campaign that is paused, empty and never ran deletes on plain confirm:true. To stop delivery WITHOUT deleting, use set_microsoft_ads_status(status:\"Paused\") instead. The result is READ BACK from Microsoft: it says \"deleted\" only when the object no longer resolves, \"not confirmed\" if it does, and \"could not tell\" if the check itself failed — repeat that verbatim rather than claiming success.",
      "group": "ads",
      "section": "MICROSOFT AD INSIGHT — KEYWORD PLANNING (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "accountId": {
            "description": "Microsoft ad account id — omit to use the brand’s single shared account",
            "type": "string"
          },
          "level": {
            "description": "what to delete — default campaign",
            "type": "string",
            "enum": [
              "campaign",
              "adGroup",
              "ad",
              "keyword"
            ]
          },
          "campaignId": {
            "description": "campaign id (level:\"campaign\"; also the parent for level:\"adGroup\" if you know it)",
            "type": "string"
          },
          "adGroupId": {
            "description": "ad group id (level:\"adGroup\"; REQUIRED as the parent for level:\"ad\" and level:\"keyword\")",
            "type": "string"
          },
          "adId": {
            "description": "ad id (level:\"ad\" — pass adGroupId too)",
            "type": "string"
          },
          "keywordId": {
            "description": "keyword id (level:\"keyword\" — pass adGroupId too)",
            "type": "string"
          },
          "confirm": {
            "description": "REQUIRED true — the delete is permanent",
            "type": "boolean"
          },
          "confirmName": {
            "description": "the object’s EXACT name, required when it has children / is Active / has spent",
            "type": "string"
          },
          "confirmChildren": {
            "description": "the exact number of children reported by the unconfirmed call, required when it has any",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_openai_ads_campaigns",
      "title": "List ChatGPT Ads account / campaigns / ad groups / ads",
      "description": "Read the brand’s connected ChatGPT Ads account — the ads that appear below ChatGPT answers. Call with NO ids to get the ad account itself (name, currency, status, review state) plus its campaigns; with campaignId to list that campaign’s ad groups; with adGroupId to list that ad group’s ads, including each ad’s REVIEW status, which is what decides whether it can ever show. Statuses here are active / paused / archived. Read-only, free, zero spend risk. Needs ChatGPT Ads connected (Settings ▸ Connectors ▸ ChatGPT Ads): the user pastes an Advertiser API key from ChatGPT Ads Manager ▸ Settings — there is no OAuth and no manager account, and one key is scoped to one ad account.",
      "group": "ads",
      "section": "ChatGPT Ads (OpenAI Advertiser API): read + manage. Ads that appear UNDER a ChatGPT answer. Same",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "campaignId": {
            "description": "list this campaign’s ad groups",
            "type": "string"
          },
          "adGroupId": {
            "description": "list this ad group’s ads",
            "type": "string"
          },
          "limit": {
            "description": "page size, default 100",
            "type": "number"
          },
          "after": {
            "description": "pagination cursor from a previous page",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "openai_ads_report",
      "title": "ChatGPT Ads performance report",
      "description": "Performance for ChatGPT Ads — impressions, clicks, spend, CTR, CPC, CPM. The scope follows the id you pass: none = the whole ad account, or campaignId / adGroupId / adId. granularity is hourly, daily, monthly or none (default daily); the default window is the last 30 days; segment by country or device for a breakdown, and level rolls the rows up by campaign / ad group / ad. A report with NO rows genuinely means there was NO delivery in that window — say exactly that; never present zeros as measured performance. Read-only and free, so run it FIRST after connecting: it proves the key works with zero spend risk.",
      "group": "ads",
      "section": "ChatGPT Ads (OpenAI Advertiser API): read + manage. Ads that appear UNDER a ChatGPT answer. Same",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "campaignId": {
            "type": "string"
          },
          "adGroupId": {
            "type": "string"
          },
          "adId": {
            "type": "string"
          },
          "since": {
            "description": "YYYY-MM-DD",
            "type": "string"
          },
          "until": {
            "description": "YYYY-MM-DD",
            "type": "string"
          },
          "granularity": {
            "description": "default daily",
            "type": "string",
            "enum": [
              "hourly",
              "daily",
              "monthly",
              "none"
            ]
          },
          "level": {
            "description": "roll rows up to this level",
            "type": "string",
            "enum": [
              "ad_account",
              "campaign",
              "ad_group",
              "ad"
            ]
          },
          "segment": {
            "description": "extra group-by dimension (at most one)",
            "type": "string",
            "enum": [
              "product",
              "country",
              "device"
            ]
          },
          "limit": {
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "openai_ads_geo_search",
      "title": "Find ChatGPT Ads location ids",
      "description": "Look up ChatGPT Ads location ids by name — countries, regions and DMAs — so a campaign can be geo-targeted. GEO IS THE ONLY AUDIENCE TARGETING THIS PLATFORM HAS: there are no interests, no lookalikes, no age or gender. Everything else is semantic, through an ad group’s context hints. Pass the returned ids as locationIds when creating or updating a campaign; a campaign with no location targeting runs everywhere available. Read-only, free.",
      "group": "ads",
      "section": "ChatGPT Ads (OpenAI Advertiser API): read + manage. Ads that appear UNDER a ChatGPT answer. Same",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "query": {
            "type": "string",
            "description": "a place name, e.g. \"Toronto\" or \"United Kingdom\""
          },
          "limit": {
            "type": "number"
          }
        },
        "required": [
          "query"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_openai_ads_campaign",
      "title": "Build a ChatGPT Ads campaign (paused)",
      "description": "Build a campaign on the connected ChatGPT Ads account — the ads that appear below ChatGPT answers. ALWAYS created PAUSED at every level, with no override: it spends NOTHING until you activate it with set_openai_ads_status(confirm:true). The object graph is campaign → ad group → ad, and a campaign ON ITS OWN CANNOT SERVE AN IMPRESSION, so pass adGroup{name, maxBid, contextHints, ad{creative}} and this builds the whole tree. THE CREATIVE IS A TEXT + IMAGE CARD AND NOTHING ELSE — title 3–50 characters, body 100 maximum, one landing page, one still image. THERE IS NO VIDEO ON THIS CHANNEL: never offer a video ad here, and if the brand only has video, pull a frame from it first. TARGETING IS SEMANTIC: context hints are natural-language descriptions of the conversations where this ad belongs (up to 2,000 per ad group). They guide matching, they are NOT exact-match keywords, and they do not guarantee delivery. OpenAI’s own guidance is BREADTH — many genuinely distinct hints and many distinct title/body angles beat one message repeated — which is exactly what plan_variations and mine_angles produce. OpenAI has no atomic multi-object write available here, so the whole tree is VALIDATED before the first write; if a level below the campaign is still rejected, the campaign is left PAUSED (spending nothing) and the note says exactly what exists — nothing is archived behind your back, because archiving is irreversible. Everything is READ BACK from OpenAI before you are told it exists: print the returned note verbatim, and if it says the campaign cannot serve yet, say that rather than calling it a finished ad.",
      "group": "ads",
      "section": "ChatGPT Ads (OpenAI Advertiser API): read + manage. Ads that appear UNDER a ChatGPT answer. Same",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "campaign name, at least 3 characters"
          },
          "description": {
            "type": "string"
          },
          "dailyBudget": {
            "description": "daily cap in the AD ACCOUNT’S currency — minimum 1.00",
            "type": "number"
          },
          "lifetimeBudget": {
            "description": "lifetime cap in the account currency — minimum 1.00. Pass this and/or dailyBudget; a budget is required.",
            "type": "number"
          },
          "biddingType": {
            "description": "default clicks (CPC). OpenAI suggests starting at a 3–5 max bid per click.",
            "type": "string",
            "enum": [
              "impressions",
              "clicks"
            ]
          },
          "countries": {
            "description": "2-letter country codes",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "locationIds": {
            "description": "ids from openai_ads_geo_search — up to 2,500",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "startTime": {
            "description": "unix seconds",
            "type": "number"
          },
          "endTime": {
            "description": "unix seconds",
            "type": "number"
          },
          "adGroup": {
            "description": "build the ad group (and its ad) in the same call — a campaign alone cannot serve",
            "type": "object",
            "properties": {
              "name": {
                "type": "string"
              },
              "description": {
                "type": "string"
              },
              "maxBid": {
                "type": "number",
                "description": "max bid in the account currency"
              },
              "billingEvent": {
                "type": "string",
                "enum": [
                  "click",
                  "impression"
                ]
              },
              "contextHints": {
                "description": "up to 2,000 natural-language conversation/topic descriptions — make them genuinely distinct from each other",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "ad": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "creative": {
                    "type": "object",
                    "properties": {
                      "title": {
                        "type": "string",
                        "description": "the headline — 3 to 50 characters, enforced"
                      },
                      "body": {
                        "type": "string",
                        "description": "the description under the headline — 100 characters maximum, enforced"
                      },
                      "targetUrl": {
                        "type": "string",
                        "description": "the landing page (must not block OAI-AdsBot / OAI-SearchBot in robots.txt)"
                      },
                      "imageUrl": {
                        "description": "public https URL of a STILL image — a video URL is refused, this channel has no video format",
                        "type": "string"
                      },
                      "price": {
                        "description": "optional price string shown on the card",
                        "type": "string"
                      }
                    },
                    "required": [
                      "title",
                      "body",
                      "targetUrl"
                    ]
                  }
                },
                "required": [
                  "creative"
                ]
              }
            },
            "required": [
              "name",
              "maxBid"
            ]
          }
        },
        "required": [
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_openai_ads_ad_group",
      "title": "Add a ChatGPT Ads ad group",
      "description": "Add an ad group to an existing ChatGPT Ads campaign. Created PAUSED by default. Its context hints ARE the targeting on this platform: up to 2,000 natural-language descriptions of the conversations, topics or questions where this offering is relevant — not exact-match keywords, and no guarantee of delivery. Write many distinct ones rather than variations of the same phrase. If the parent campaign is already LIVE (active), creating this ad group active starts REAL AD SPEND on the next auction, exactly like activating it — show the user what would begin serving, get an explicit yes, then pass confirm:true. Leaving it paused never needs confirmation. The whole tree is READ BACK from OpenAI before you are told it exists.",
      "group": "ads",
      "section": "ChatGPT Ads (OpenAI Advertiser API): read + manage. Ads that appear UNDER a ChatGPT answer. Same",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "campaignId": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "description": {
            "type": "string"
          },
          "maxBid": {
            "type": "number",
            "description": "max bid in the account currency"
          },
          "billingEvent": {
            "description": "default click",
            "type": "string",
            "enum": [
              "click",
              "impression"
            ]
          },
          "contextHints": {
            "description": "up to 2,000, deduplicated server-side",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "status": {
            "description": "default paused",
            "type": "string",
            "enum": [
              "active",
              "paused"
            ]
          },
          "confirm": {
            "description": "REQUIRED true to create this ACTIVE under a live campaign (real spend)",
            "type": "boolean"
          }
        },
        "required": [
          "campaignId",
          "name",
          "maxBid"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_openai_ads_ad",
      "title": "Create a ChatGPT Ads ad",
      "description": "Create the actual AD inside a ChatGPT Ads ad group — the object that carries the creative; a campaign or ad group alone shows nothing at all. The creative is a TEXT + IMAGE CARD: a title of 3–50 characters, body copy of 100 characters maximum, one landing page URL and one still image. THERE IS NO VIDEO FORMAT ON THIS PLATFORM — a video URL is refused outright, so never offer one. Created paused; creating it active inside a live ad group starts REAL AD SPEND, so that needs confirm:true. OpenAI REVIEWS every ad (usually a few minutes) and the ad is read back with its review status: until that says approved the ad CANNOT show, so report the review status rather than calling it live. The landing page is also checked against robots.txt for OAI-AdsBot / OAI-SearchBot blocks — a page that blocks those agents cannot run ChatGPT ads at all, and the note says so.",
      "group": "ads",
      "section": "ChatGPT Ads (OpenAI Advertiser API): read + manage. Ads that appear UNDER a ChatGPT answer. Same",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adGroupId": {
            "type": "string"
          },
          "name": {
            "description": "internal name — defaults to the title",
            "type": "string"
          },
          "creative": {
            "type": "object",
            "properties": {
              "title": {
                "type": "string",
                "description": "the headline — 3 to 50 characters, enforced"
              },
              "body": {
                "type": "string",
                "description": "the description under the headline — 100 characters maximum, enforced"
              },
              "targetUrl": {
                "type": "string",
                "description": "the landing page (must not block OAI-AdsBot / OAI-SearchBot in robots.txt)"
              },
              "imageUrl": {
                "description": "public https URL of a STILL image — a video URL is refused, this channel has no video format",
                "type": "string"
              },
              "price": {
                "description": "optional price string shown on the card",
                "type": "string"
              }
            },
            "required": [
              "title",
              "body",
              "targetUrl"
            ]
          },
          "status": {
            "description": "default paused",
            "type": "string",
            "enum": [
              "active",
              "paused"
            ]
          },
          "confirm": {
            "description": "REQUIRED true to create this ACTIVE in a live ad group (real spend)",
            "type": "boolean"
          }
        },
        "required": [
          "adGroupId",
          "creative"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_openai_ads_object",
      "title": "Edit a ChatGPT Ads campaign / ad group / ad",
      "description": "EDIT an existing ChatGPT Ads object in place — rename it, change a campaign’s budget or geo targeting, rewrite an ad group’s context hints or bid, or replace an ad’s title, body, landing page or image. Pass level:\"campaign\" + campaignId, level:\"adGroup\" + adGroupId, or level:\"ad\" + adId. Only the fields you pass are changed, but note that context hints, bidding and the creative are REPLACED WHOLESALE rather than merged, so send the complete list. Changing the budget, the bid or the creative of a LIVE (active) object changes what real money buys immediately — show the user the old and new values, get an explicit yes, then pass confirm:true. The object is READ BACK after the change.",
      "group": "ads",
      "section": "ChatGPT Ads (OpenAI Advertiser API): read + manage. Ads that appear UNDER a ChatGPT answer. Same",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "level": {
            "description": "inferred from which id you pass",
            "type": "string",
            "enum": [
              "campaign",
              "adGroup",
              "ad"
            ]
          },
          "campaignId": {
            "type": "string"
          },
          "adGroupId": {
            "type": "string"
          },
          "adId": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "description": {
            "type": "string"
          },
          "dailyBudget": {
            "type": "number"
          },
          "lifetimeBudget": {
            "type": "number"
          },
          "countries": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "locationIds": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "endTime": {
            "type": "number"
          },
          "contextHints": {
            "description": "REPLACES the existing list",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "maxBid": {
            "type": "number"
          },
          "billingEvent": {
            "description": "required alongside maxBid — bidding is replaced wholesale",
            "type": "string",
            "enum": [
              "click",
              "impression"
            ]
          },
          "creative": {
            "description": "REPLACES the ad’s creative (text + image card only)",
            "type": "object",
            "properties": {
              "title": {
                "type": "string",
                "description": "the headline — 3 to 50 characters, enforced"
              },
              "body": {
                "type": "string",
                "description": "the description under the headline — 100 characters maximum, enforced"
              },
              "targetUrl": {
                "type": "string",
                "description": "the landing page (must not block OAI-AdsBot / OAI-SearchBot in robots.txt)"
              },
              "imageUrl": {
                "description": "public https URL of a STILL image — a video URL is refused, this channel has no video format",
                "type": "string"
              },
              "price": {
                "description": "optional price string shown on the card",
                "type": "string"
              }
            },
            "required": [
              "title",
              "body",
              "targetUrl"
            ]
          },
          "confirm": {
            "description": "REQUIRED true to change budget / bid / creative on a LIVE object",
            "type": "boolean"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_openai_ads_budget",
      "title": "Set a ChatGPT Ads campaign budget",
      "description": "Change a ChatGPT Ads campaign’s budget — a daily cap, a lifetime cap, or both, in the ad account’s currency (OpenAI’s floor is 1.00). Raising it on a LIVE (active) campaign increases real spend immediately, so you MUST show the user the old and new amounts, get an explicit yes, then call with confirm:true. Lowering it or changing a paused campaign is safe. The campaign is READ BACK after the change and the note is built from that.",
      "group": "ads",
      "section": "ChatGPT Ads (OpenAI Advertiser API): read + manage. Ads that appear UNDER a ChatGPT answer. Same",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "campaignId": {
            "type": "string"
          },
          "dailyBudget": {
            "type": "number"
          },
          "lifetimeBudget": {
            "type": "number"
          },
          "confirm": {
            "description": "REQUIRED true when the campaign is live",
            "type": "boolean"
          }
        },
        "required": [
          "campaignId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_openai_ads_status",
      "title": "Activate, pause or archive a ChatGPT Ads campaign / ad group / ad",
      "description": "Turn a ChatGPT Ads campaign, AD GROUP or AD on (active), off (paused), or ARCHIVE it. Pass level:\"campaign\" + campaignId, level:\"adGroup\" + adGroupId, or level:\"ad\" + adId. ACTIVATING STARTS REAL AD SPEND — you MUST first show the user the object and its budget, get an explicit yes, then call with status:\"active\" and confirm:true. Pausing is always safe and stops all spend. ARCHIVING IS IRREVERSIBLE: it is this platform’s only teardown (there is no delete and no un-archive, and OpenAI’s own guidance is \"only archive objects you have no further use for\"), so it ALSO requires confirm:true — prefer pausing unless the user is certain. Remember an ad only serves when the ad, its ad group AND its campaign are all active and the ad has passed OpenAI’s review. The resulting status is READ BACK from OpenAI before you are told it took.",
      "group": "ads",
      "section": "ChatGPT Ads (OpenAI Advertiser API): read + manage. Ads that appear UNDER a ChatGPT answer. Same",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "level": {
            "description": "inferred from which id you pass — default campaign",
            "type": "string",
            "enum": [
              "campaign",
              "adGroup",
              "ad"
            ]
          },
          "campaignId": {
            "type": "string"
          },
          "adGroupId": {
            "type": "string"
          },
          "adId": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "paused",
              "archived"
            ],
            "description": "active = start spending; paused = stop; archived = permanent"
          },
          "confirm": {
            "description": "REQUIRED true to activate (real spend) or to archive (irreversible)",
            "type": "boolean"
          }
        },
        "required": [
          "status"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_openai_ads_object",
      "title": "Archive (ChatGPT Ads’ delete) a campaign / ad group / ad",
      "description": "Retire a ChatGPT Ads campaign, ad group or ad. THE OPENAI ADVERTISER API HAS NO DELETE — archiving is its only teardown, and OpenAI’s own guidance is \"only archive objects you have no further use for, as archiving isn’t reversible\": there is no un-archive, not even through support. So say ARCHIVED, never \"deleted\". Pass level:\"campaign\" + campaignId, level:\"adGroup\" + adGroupId, or level:\"ad\" + adId. CALL IT WITHOUT confirm FIRST — nothing is archived and you get the object’s real name, status and child count read live from OpenAI; show the user exactly that. A target with children or live delivery additionally needs confirmName (its exact name) and confirmChildren (the count from that read-back). PAUSING stops all spend and keeps the object editable — offer that first whenever the user only wants delivery to stop. Archiving a campaign is not documented to cascade, so archive the children yourself if they should stop too. The result is READ BACK: it says archived only when OpenAI reports the archived status.",
      "group": "ads",
      "section": "ChatGPT Ads (OpenAI Advertiser API): read + manage. Ads that appear UNDER a ChatGPT answer. Same",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "level": {
            "description": "what to archive — default campaign",
            "type": "string",
            "enum": [
              "campaign",
              "adGroup",
              "ad"
            ]
          },
          "campaignId": {
            "type": "string"
          },
          "adGroupId": {
            "type": "string"
          },
          "adId": {
            "type": "string"
          },
          "confirm": {
            "description": "REQUIRED true — archiving cannot be undone",
            "type": "boolean"
          },
          "confirmName": {
            "description": "the object’s EXACT name, required when it has children or is live",
            "type": "string"
          },
          "confirmChildren": {
            "description": "the exact number of children reported by the unconfirmed call, required when it has any",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_pinterest_ads_campaigns",
      "title": "List Pinterest ad accounts / campaigns",
      "description": "Read the brand’s connected Pinterest AD account(s). Call with NO adAccountId to list the ad accounts shared with this brand — do this first to pick a target. Call WITH adAccountId to list that account’s campaigns (id, name, status, objective, budget, and Pinterest’s own summary status). All money is in the AD ACCOUNT’S currency, which is not necessarily dollars. Pinterest’s own list default hides DRAFT and ARCHIVED objects; this asks for all four statuses so nothing is silently missing. Read-only, free. Needs Pinterest connected (Settings ▸ Connectors ▸ Pinterest) and the ad account ticked under Manage accounts.",
      "group": "ads",
      "section": "PINTEREST ADS (2026-07-30) — rides the SAME Pinterest connection as the Pin tools; no reconnect needed.",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "description": "Pinterest ad account id — omit to list the ad accounts shared with this brand",
            "type": "string"
          },
          "statuses": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "ACTIVE",
                "PAUSED",
                "ARCHIVED",
                "DRAFT"
              ]
            }
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "pinterest_ads_report",
      "title": "Pinterest ads performance report",
      "description": "Performance for a Pinterest ad account — spend, impressions, clicks, CTR, effective CPC and conversions, by campaign. Window via since/until (YYYY-MM-DD) and granularity. Pinterest keeps only 90 days and refuses ranges longer than 90 days (at HOUR granularity: 8 days back, 3-day windows) — this refuses those up front with the reason rather than letting Pinterest return an opaque error. A report with ZERO rows genuinely means nothing delivered in that window; say exactly that and never present zeros as measured performance. Read-only, free.",
      "group": "ads",
      "section": "PINTEREST ADS (2026-07-30) — rides the SAME Pinterest connection as the Pin tools; no reconnect needed.",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "campaignIds": {
            "description": "break down by campaign — omit for the whole ad account",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "since": {
            "description": "YYYY-MM-DD, default 30 days ago",
            "type": "string"
          },
          "until": {
            "description": "YYYY-MM-DD, default today",
            "type": "string"
          },
          "granularity": {
            "description": "default TOTAL",
            "type": "string",
            "enum": [
              "TOTAL",
              "DAY",
              "HOUR",
              "WEEK",
              "MONTH"
            ]
          },
          "columns": {
            "description": "Pinterest metric column names — omit for the standard set",
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_pinterest_ads_campaign",
      "title": "Build a Pinterest ad campaign (paused)",
      "description": "Build a campaign on a connected Pinterest ad account. ALWAYS created PAUSED — worth knowing that Pinterest’s own API defaults new campaigns to ACTIVE, so this deliberately overrides that; it spends NOTHING until you activate it with set_pinterest_ads_status(confirm:true). Pinterest’s object graph is campaign → ad group → ad, and an ad PROMOTES AN EXISTING PIN, so a campaign ON ITS OWN CANNOT SERVE AN IMPRESSION: pass adGroup{name, targetingSpec, ad{pinId}} and this builds the whole tree. Pinterest has NO atomic multi-object write, so the levels are created in sequence and the campaign is ARCHIVED again if anything below it is rejected (Pinterest has no delete) — you never inherit a half-built campaign. Budgets are ordinary amounts in the ad account’s currency; the micro-currency conversion Pinterest requires is handled for you. Every ad group must target at least one place. Everything is READ BACK from Pinterest before you are told it exists; print the returned note verbatim, and if it says the campaign cannot serve yet, say that rather than calling it a finished ad.",
      "group": "ads",
      "section": "PINTEREST ADS (2026-07-30) — rides the SAME Pinterest connection as the Pin tools; no reconnect needed.",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "name": {
            "type": "string",
            "description": "campaign name, ≤255 characters"
          },
          "objective": {
            "type": "string",
            "enum": [
              "AWARENESS",
              "CONSIDERATION",
              "WEB_CONVERSION",
              "CATALOG_SALES",
              "VIDEO_COMPLETION",
              "APP_INSTALL",
              "SALES",
              "LEADS",
              "CTV_CONSIDERATION"
            ],
            "description": "Pinterest requires an objective and will not guess one"
          },
          "dailyBudget": {
            "description": "daily cap in the ad account’s currency",
            "type": "number"
          },
          "lifetimeBudget": {
            "description": "lifetime cap instead of a daily one — Pinterest then requires endTime",
            "type": "number"
          },
          "startTime": {
            "description": "Unix timestamp in SECONDS",
            "type": "number"
          },
          "endTime": {
            "description": "Unix timestamp in SECONDS",
            "type": "number"
          },
          "adGroup": {
            "description": "build the serving tree in the same call — WITHOUT this you get a campaign shell that can never show an ad",
            "type": "object",
            "properties": {
              "name": {
                "type": "string"
              },
              "billableEvent": {
                "description": "default CLICKTHROUGH",
                "type": "string",
                "enum": [
                  "CLICKTHROUGH",
                  "IMPRESSION",
                  "VIDEO_V_50_MRC"
                ]
              },
              "bid": {
                "description": "max bid in the ad account’s currency — REQUIRED by Pinterest for AWARENESS/IMPRESSION, CONSIDERATION/CLICKTHROUGH and CATALOG_SALES/CLICKTHROUGH",
                "type": "number"
              },
              "budget": {
                "description": "ad-group budget — only valid when the campaign is NOT budget-optimized (Pinterest optimizes at campaign level by default)",
                "type": "number"
              },
              "placementGroup": {
                "type": "string",
                "enum": [
                  "ALL",
                  "SEARCH",
                  "BROWSE",
                  "OTHER"
                ]
              },
              "pacing": {
                "type": "string",
                "enum": [
                  "STANDARD",
                  "ACCELERATED"
                ]
              },
              "targetingSpec": {
                "description": "Pinterest targeting object, e.g. {\"GEO\":[\"US\"],\"MINIMUM_AGE\":\"25\"} — at least one GEO or LOCATION is REQUIRED by Pinterest",
                "type": "object",
                "propertyNames": {
                  "type": "string"
                },
                "additionalProperties": {}
              },
              "status": {
                "description": "default PAUSED",
                "type": "string",
                "enum": [
                  "ACTIVE",
                  "PAUSED",
                  "DRAFT"
                ]
              },
              "ad": {
                "type": "object",
                "properties": {
                  "pinId": {
                    "type": "string",
                    "description": "the numeric id of an existing Pin — a Pinterest ad PROMOTES a Pin, so create one with post_to_pinterest first if there is nothing to promote"
                  },
                  "creativeType": {
                    "description": "default REGULAR",
                    "type": "string",
                    "enum": [
                      "REGULAR",
                      "VIDEO",
                      "SHOPPING",
                      "CAROUSEL",
                      "MAX_VIDEO",
                      "COLLECTION",
                      "IDEA",
                      "SHOWCASE",
                      "QUIZ",
                      "COLLAGE",
                      "APP"
                    ]
                  },
                  "name": {
                    "type": "string"
                  },
                  "destinationUrl": {
                    "description": "where the click goes",
                    "type": "string"
                  }
                },
                "required": [
                  "pinId"
                ]
              }
            }
          }
        },
        "required": [
          "name",
          "objective"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_pinterest_ads_ad_group",
      "title": "Add a Pinterest ad group",
      "description": "Add an ad group to an existing Pinterest campaign — optionally with its ad in the same call. Created PAUSED by default. Pinterest REQUIRES every ad group to target at least one place, so targetingSpec must carry a GEO array or a LOCATION object. If the parent campaign is already LIVE (ACTIVE), creating this ad group ACTIVE starts REAL AD SPEND on the next auction, exactly like activating it — show the user what would begin serving, get an explicit yes, then pass confirm:true. Leaving it paused never needs confirmation. Read back from Pinterest before you are told it exists.",
      "group": "ads",
      "section": "PINTEREST ADS (2026-07-30) — rides the SAME Pinterest connection as the Pin tools; no reconnect needed.",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "campaignId": {
            "type": "string",
            "description": "the campaign this ad group belongs to"
          },
          "name": {
            "type": "string",
            "description": "ad group name"
          },
          "billableEvent": {
            "description": "default CLICKTHROUGH",
            "type": "string",
            "enum": [
              "CLICKTHROUGH",
              "IMPRESSION",
              "VIDEO_V_50_MRC"
            ]
          },
          "bid": {
            "description": "max bid in the ad account’s currency — REQUIRED by Pinterest for AWARENESS/IMPRESSION, CONSIDERATION/CLICKTHROUGH and CATALOG_SALES/CLICKTHROUGH",
            "type": "number"
          },
          "budget": {
            "description": "ad-group budget — only valid when the campaign is NOT budget-optimized (Pinterest optimizes at campaign level by default)",
            "type": "number"
          },
          "placementGroup": {
            "type": "string",
            "enum": [
              "ALL",
              "SEARCH",
              "BROWSE",
              "OTHER"
            ]
          },
          "pacing": {
            "type": "string",
            "enum": [
              "STANDARD",
              "ACCELERATED"
            ]
          },
          "targetingSpec": {
            "description": "Pinterest targeting object, e.g. {\"GEO\":[\"US\"],\"MINIMUM_AGE\":\"25\"} — at least one GEO or LOCATION is REQUIRED by Pinterest",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "status": {
            "description": "default PAUSED",
            "type": "string",
            "enum": [
              "ACTIVE",
              "PAUSED",
              "DRAFT"
            ]
          },
          "ad": {
            "description": "create the ad in the same call",
            "type": "object",
            "properties": {
              "pinId": {
                "type": "string",
                "description": "the numeric id of an existing Pin — a Pinterest ad PROMOTES a Pin, so create one with post_to_pinterest first if there is nothing to promote"
              },
              "creativeType": {
                "description": "default REGULAR",
                "type": "string",
                "enum": [
                  "REGULAR",
                  "VIDEO",
                  "SHOPPING",
                  "CAROUSEL",
                  "MAX_VIDEO",
                  "COLLECTION",
                  "IDEA",
                  "SHOWCASE",
                  "QUIZ",
                  "COLLAGE",
                  "APP"
                ]
              },
              "name": {
                "type": "string"
              },
              "destinationUrl": {
                "description": "where the click goes",
                "type": "string"
              }
            },
            "required": [
              "pinId"
            ]
          },
          "confirm": {
            "description": "REQUIRED true to create this ACTIVE under a LIVE campaign",
            "type": "boolean"
          }
        },
        "required": [
          "campaignId",
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_pinterest_ads_ad",
      "title": "Create a Pinterest ad",
      "description": "Create the actual AD inside a Pinterest ad group — this is the object that carries the creative; a campaign or ad group alone shows nothing. A Pinterest ad PROMOTES AN EXISTING PIN, so pass pinId (post_to_pinterest returns one). Created PAUSED; if the parent ad group and campaign are both ACTIVE, creating this ACTIVE starts REAL AD SPEND immediately — get an explicit yes and pass confirm:true. Pinterest reviews ads: the read-back reports the review status and any rejection reason, and a REJECTED ad never serves until it is fixed.",
      "group": "ads",
      "section": "PINTEREST ADS (2026-07-30) — rides the SAME Pinterest connection as the Pin tools; no reconnect needed.",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "adGroupId": {
            "type": "string",
            "description": "the ad group this ad lives in"
          },
          "pinId": {
            "type": "string",
            "description": "the numeric id of an existing Pin — a Pinterest ad PROMOTES a Pin, so create one with post_to_pinterest first if there is nothing to promote"
          },
          "creativeType": {
            "description": "default REGULAR",
            "type": "string",
            "enum": [
              "REGULAR",
              "VIDEO",
              "SHOPPING",
              "CAROUSEL",
              "MAX_VIDEO",
              "COLLECTION",
              "IDEA",
              "SHOWCASE",
              "QUIZ",
              "COLLAGE",
              "APP"
            ]
          },
          "name": {
            "type": "string"
          },
          "destinationUrl": {
            "description": "where the click goes",
            "type": "string"
          },
          "status": {
            "description": "default PAUSED",
            "type": "string",
            "enum": [
              "ACTIVE",
              "PAUSED",
              "DRAFT"
            ]
          },
          "confirm": {
            "description": "REQUIRED true to create this ACTIVE in a live ad group",
            "type": "boolean"
          }
        },
        "required": [
          "adGroupId",
          "pinId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_pinterest_ads_budget",
      "title": "Set a Pinterest campaign budget",
      "description": "Change a Pinterest campaign’s budget — a DAILY cap or a LIFETIME cap, in the ad account’s currency. Pinterest allows only one of the two per campaign, so passing both is refused rather than silently picking one. Raising it on a LIVE (ACTIVE) campaign increases real spend immediately — you MUST show the user the new amount, get an explicit yes, then call with confirm:true. Read back after the change.",
      "group": "ads",
      "section": "PINTEREST ADS (2026-07-30) — rides the SAME Pinterest connection as the Pin tools; no reconnect needed.",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "campaignId": {
            "type": "string",
            "description": "the campaign whose budget changes"
          },
          "dailyBudget": {
            "type": "number"
          },
          "lifetimeBudget": {
            "type": "number"
          },
          "confirm": {
            "description": "REQUIRED true to change the budget of a LIVE campaign",
            "type": "boolean"
          }
        },
        "required": [
          "campaignId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_pinterest_ads_status",
      "title": "Activate, pause or archive a Pinterest campaign / ad group / ad",
      "description": "Turn a Pinterest campaign, AD GROUP or AD on (ACTIVE) or off (PAUSED) — and, because Pinterest has NO DELETE anywhere in its API, this is also the only way to retire one (ARCHIVED). Pass level:\"campaign\" + campaignId, level:\"adGroup\" + adGroupId, or level:\"ad\" + adId. ACTIVATING STARTS REAL AD SPEND, and ARCHIVING is effectively a delete: both require you to show the user exactly what changes, get an explicit yes, and call again with confirm:true. Pausing is always safe. The resulting status is READ BACK from Pinterest before you are told it took.",
      "group": "ads",
      "section": "PINTEREST ADS (2026-07-30) — rides the SAME Pinterest connection as the Pin tools; no reconnect needed.",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "level": {
            "description": "what to change — default campaign",
            "type": "string",
            "enum": [
              "campaign",
              "adGroup",
              "ad"
            ]
          },
          "campaignId": {
            "type": "string"
          },
          "adGroupId": {
            "type": "string"
          },
          "adId": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "ACTIVE",
              "PAUSED",
              "ARCHIVED",
              "DRAFT"
            ],
            "description": "ACTIVE = start spending; PAUSED = stop; ARCHIVED = retire (Pinterest’s delete)"
          },
          "confirm": {
            "description": "REQUIRED true for ACTIVE (real spend) or ARCHIVED (irreversible retirement)",
            "type": "boolean"
          }
        },
        "required": [
          "status"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_pinterest_ads_object",
      "title": "Archive (Pinterest’s delete) a campaign / ad group / ad",
      "description": "Retire a Pinterest campaign, ad group or ad. PINTEREST API v5 HAS NO DELETE for any of the three — ARCHIVED is its terminal state, and Pinterest’s own campaign docs call an archived campaign \"deleted\" and say reversing it means filing a ticket with their customer ops team, so there is no un-archive you or the user can call. Say ARCHIVED, not \"deleted\": the object stays on the account with its reporting history and simply drops out of the default list view. Pass level:\"campaign\" + campaignId, level:\"adGroup\" + adGroupId, or level:\"ad\" + adId. CALL IT WITHOUT confirm FIRST — nothing is archived and you get the object’s real name, status and child count read live from Pinterest; show the user exactly that. A target with children or live delivery additionally needs confirmName (its exact name) and confirmChildren (the count from that read-back). PAUSING is fully reversible — offer it first whenever the user only wants delivery to stop. Pinterest documents no cascade, so archive the children yourself if they should stop too.",
      "group": "ads",
      "section": "PINTEREST ADS (2026-07-30) — rides the SAME Pinterest connection as the Pin tools; no reconnect needed.",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "description": "Pinterest ad account id — omit to use the brand’s single shared account",
            "type": "string"
          },
          "level": {
            "description": "what to archive — default campaign",
            "type": "string",
            "enum": [
              "campaign",
              "adGroup",
              "ad"
            ]
          },
          "campaignId": {
            "type": "string"
          },
          "adGroupId": {
            "type": "string"
          },
          "adId": {
            "type": "string"
          },
          "confirm": {
            "description": "REQUIRED true — archiving is not self-service reversible",
            "type": "boolean"
          },
          "confirmName": {
            "description": "the object’s EXACT name, required when it has children or is live",
            "type": "string"
          },
          "confirmChildren": {
            "description": "the exact number of children reported by the unconfirmed call, required when it has any",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_reddit_ads_campaigns",
      "title": "List Reddit ad accounts / campaigns",
      "description": "Read the brand’s connected Reddit AD account(s). Call with NO adAccountId to list the ad accounts shared with this brand — do this first to pick a target. Call WITH adAccountId to read that account’s whole tree at once: campaigns, ad groups and ads, each with its configured status and Reddit’s own effective status (the effective one is what says whether it could actually serve — PENDING_APPROVAL, CAMPAIGN_PAUSED, REJECTED and so on). Read-only, free. Needs Reddit Ads connected (Settings ▸ Connectors ▸ Reddit Ads) and the ad account ticked under Manage accounts.",
      "group": "ads",
      "section": "REDDIT ADS (2026-07-31)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "description": "Reddit ad account id (a2_…) — omit to list the ad accounts shared with this brand",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "reddit_ads_report",
      "title": "Reddit ads performance report",
      "description": "Performance for a Reddit ad account — impressions, clicks, spend, CTR, CPC, eCPM, reach and any of Reddit’s ~450 metric fields, optionally broken down by campaign, ad group, ad, date, hour, community, country, gender, interest, keyword, placement and more. Money comes back in WHOLE UNITS of the ad account’s currency (Reddit reports micro-currency; the conversion is done for you) — and that currency is not necessarily dollars, so check the account. Reddit only accepts HOURLY window boundaries; plain YYYY-MM-DD dates are accepted here and snapped for you. A report with ZERO rows genuinely means nothing delivered in that window — say exactly that and never present zeros as measured performance. Read-only, free.",
      "group": "ads",
      "section": "REDDIT ADS (2026-07-31)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "since": {
            "description": "YYYY-MM-DD or full ISO timestamp, default 30 days ago",
            "type": "string"
          },
          "until": {
            "description": "YYYY-MM-DD or full ISO timestamp, default today",
            "type": "string"
          },
          "fields": {
            "description": "Reddit metric names, UPPER_SNAKE (IMPRESSIONS, CLICKS, SPEND, CTR, CPC, ECPM, REACH, FREQUENCY, CONVERSION_ROAS, VIDEO_WATCHED_100_PERCENT, CONVERSION_PURCHASE_TOTAL_VALUE…). Omit for a sensible default set; if you name one Reddit does not know, its error lists every valid value.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "breakdowns": {
            "description": "up to 3 (4 if both COUNTRY and REGION). Omit for one aggregate row.",
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "AD_ACCOUNT_ID",
                "AD_GROUP_ID",
                "AD_ID",
                "CAMPAIGN_ID",
                "COUNTRY",
                "DATE",
                "HOUR",
                "DMA",
                "METRO",
                "CAROUSEL_CARD",
                "GALLERY_ITEM_ID",
                "GENDER",
                "INTEREST",
                "KEYWORD",
                "PLACEMENT",
                "OS_TYPE",
                "ASSET_ID",
                "REGION",
                "COMMUNITY",
                "LANGUAGE"
              ]
            }
          },
          "filter": {
            "description": "Reddit filter expression to restrict rows to particular ids/values",
            "type": "string"
          },
          "timeZoneId": {
            "description": "IANA zone, e.g. America/New_York",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_reddit_ads_profiles",
      "title": "List the Reddit profiles an ad account can publish as",
      "description": "List the Reddit PROFILES attached to an ad account. A Reddit ad promotes a POST, and every post is published AS one of these profiles — so this is the first call in any Reddit creative build, and its id is what create_reddit_ads_post needs. If it comes back empty, the ad account has no profile attached and nothing can be advertised from it yet. Read-only, free.",
      "group": "ads",
      "section": "REDDIT ADS (2026-07-31)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "search_reddit_ads_targeting",
      "title": "Resolve Reddit communities / geolocations / interests for targeting",
      "description": "Look up the exact values Reddit ad-group targeting expects, so none of them has to be guessed. kind:\"communities\" searches subreddits by keyword and returns each one’s NAME plus its subscriber count — targeting wants the bare name (\"running\"), NOT the t5_ id and NOT \"r/running\". kind:\"geolocations\" lists targetable places (pass country like US, or a city search) — targeting accepts a country code or one of the returned ids. kind:\"interests\" lists Reddit’s interest taxonomy — targeting wants the id (\"pets_v3\"). Read-only, free. Use this before create_reddit_ads_ad_group rather than inventing a community name.",
      "group": "ads",
      "section": "REDDIT ADS (2026-07-31)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "kind": {
            "description": "default communities",
            "type": "string",
            "enum": [
              "communities",
              "geolocations",
              "interests"
            ]
          },
          "query": {
            "description": "keyword — required for communities, filters interests, searches cities for geolocations",
            "type": "string"
          },
          "country": {
            "description": "2-letter country code, geolocations only",
            "type": "string"
          },
          "postalCode": {
            "type": "string"
          },
          "limit": {
            "description": "max results, default 15",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "reddit_ads_forecast",
      "title": "Forecast Reddit audience size and delivery",
      "description": "Ask Reddit how big a targeting set is and roughly what a budget would buy — total reachable audience, the targeted slice, and estimated impressions, clicks and reach. Free, creates nothing, spends nothing, so run it BEFORE building an ad group to sanity-check targeting that may be far too narrow or far too broad. Budget is an ordinary amount in the ad account’s currency. These are Reddit’s estimates, not a guarantee — say so when reporting them.",
      "group": "ads",
      "section": "REDDIT ADS (2026-07-31)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "budget": {
            "type": "number",
            "description": "budget in the ad account’s currency (not micro-currency — the conversion is handled)"
          },
          "objective": {
            "description": "default CLICKS",
            "type": "string",
            "enum": [
              "APP_INSTALLS",
              "CATALOG_SALES",
              "CLICKS",
              "CONVERSIONS",
              "IMPRESSIONS",
              "LEAD_GENERATION",
              "VIDEO_VIEWABLE_IMPRESSIONS"
            ]
          },
          "goalType": {
            "type": "string",
            "enum": [
              "DAILY_SPEND",
              "LIFETIME_SPEND"
            ]
          },
          "bidType": {
            "type": "string",
            "enum": [
              "CPC",
              "CPM",
              "CPV",
              "CPV6"
            ]
          },
          "bidStrategy": {
            "type": "string",
            "enum": [
              "BIDLESS",
              "MANUAL_BIDDING",
              "MAXIMIZE_VOLUME",
              "TARGET_CPX"
            ]
          },
          "bidAmount": {
            "type": "number"
          },
          "startTime": {
            "description": "ISO 8601",
            "type": "string"
          },
          "endTime": {
            "type": "string"
          },
          "targeting": {
            "description": "same shape as create_reddit_ads_ad_group targeting",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          }
        },
        "required": [
          "budget"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "reddit_ads_bid_suggestion",
      "title": "Ask Reddit what to bid",
      "description": "Reddit’s own suggested bid for a given objective, bid type and targeting — a median, a sensible range, and the hard floor below which Reddit will not accept a bid. Amounts come back in the ad account’s currency. Free, creates nothing. Use it to pick bidAmount for create_reddit_ads_ad_group instead of guessing a number that either never wins an auction or overpays.",
      "group": "ads",
      "section": "REDDIT ADS (2026-07-31)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "budget": {
            "type": "number",
            "description": "budget in the ad account’s currency"
          },
          "objective": {
            "description": "default CLICKS",
            "type": "string",
            "enum": [
              "APP_INSTALLS",
              "CATALOG_SALES",
              "CLICKS",
              "CONVERSIONS",
              "IMPRESSIONS",
              "LEAD_GENERATION",
              "VIDEO_VIEWABLE_IMPRESSIONS"
            ]
          },
          "bidType": {
            "description": "default CPC — must fit the campaign objective",
            "type": "string",
            "enum": [
              "CPC",
              "CPM",
              "CPV",
              "CPV6"
            ]
          },
          "bidStrategy": {
            "type": "string",
            "enum": [
              "BIDLESS",
              "MANUAL_BIDDING",
              "MAXIMIZE_VOLUME",
              "TARGET_CPX"
            ]
          },
          "goalType": {
            "type": "string",
            "enum": [
              "DAILY_SPEND",
              "LIFETIME_SPEND"
            ]
          },
          "startTime": {
            "type": "string"
          },
          "endTime": {
            "type": "string"
          },
          "currency": {
            "type": "string"
          },
          "targeting": {
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          }
        },
        "required": [
          "budget"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_reddit_ads_posts",
      "title": "List a Reddit profile’s ad posts",
      "description": "List the POSTS on a Reddit profile — these are the creatives Reddit ads promote. Use it to find an existing post to advertise rather than creating a near-duplicate. Call list_reddit_ads_profiles first for redditProfileId. Read-only, free.",
      "group": "ads",
      "section": "REDDIT ADS (2026-07-31)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "redditProfileId": {
            "type": "string",
            "description": "the Reddit profile id (t2_…) from list_reddit_ads_profiles"
          },
          "type": {
            "type": "string",
            "enum": [
              "TEXT",
              "IMAGE",
              "VIDEO",
              "CAROUSEL"
            ]
          },
          "limit": {
            "type": "number"
          }
        },
        "required": [
          "redditProfileId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_reddit_ads_post",
      "title": "Create the Reddit post an ad will promote",
      "description": "Create the CREATIVE for a Reddit ad. This is the step people skip: a Reddit ad has no creative of its own — it points at a post — so a campaign and ad group with no post behind them can never serve. Types are TEXT (headline + body), IMAGE, VIDEO and CAROUSEL (up to 6 images). For image/video/carousel pass media[] with a PUBLIC mediaUrl; Reddit fetches and validates it itself (minimum 140×140), and a video also needs a thumbnailUrl. The destination for a click rides on the media entry’s destinationUrl, NOT on the ad. Reddit’s call-to-action values are human-readable strings with spaces and capitals — \"Learn More\", \"Shop Now\", \"Sign Up\" — not SCREAMING_SNAKE; the error lists all of them. The post is published on the profile immediately, so show the user the exact headline and body first.",
      "group": "ads",
      "section": "REDDIT ADS (2026-07-31)",
      "annotations": {
        "readOnlyHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "redditProfileId": {
            "type": "string",
            "description": "the Reddit profile id (t2_…) to publish as — from list_reddit_ads_profiles"
          },
          "type": {
            "description": "default TEXT",
            "type": "string",
            "enum": [
              "TEXT",
              "IMAGE",
              "VIDEO",
              "CAROUSEL"
            ]
          },
          "headline": {
            "type": "string",
            "description": "the post title — this is the ad’s headline"
          },
          "body": {
            "description": "body copy, TEXT posts",
            "type": "string"
          },
          "media": {
            "description": "one entry for IMAGE/VIDEO, up to 6 for CAROUSEL",
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "mediaUrl": {
                  "description": "PUBLIC url of the image/video — Reddit fetches it, minimum 140×140",
                  "type": "string"
                },
                "destinationUrl": {
                  "description": "where a click goes — required for image and carousel posts",
                  "type": "string"
                },
                "displayUrl": {
                  "description": "shown instead of the destination; must be the same domain",
                  "type": "string"
                },
                "caption": {
                  "description": "carousel card caption",
                  "type": "string"
                },
                "callToAction": {
                  "description": "e.g. \"Learn More\", \"Shop Now\" — exact human-readable strings",
                  "type": "string"
                }
              }
            }
          },
          "callToAction": {
            "description": "applies to every media entry that has none",
            "type": "string"
          },
          "thumbnailUrl": {
            "description": "required for VIDEO posts",
            "type": "string"
          },
          "allowComments": {
            "description": "Reddit ads can carry a public comment thread — decide deliberately",
            "type": "boolean"
          }
        },
        "required": [
          "redditProfileId",
          "headline"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_reddit_ads_post",
      "title": "Edit a Reddit ad post",
      "description": "Edit an existing Reddit ad post’s headline, body or comment setting. The post is already public, so an edit is publicly visible — show the user the exact new text first. A REDDIT AD POST CANNOT BE REMOVED THROUGH THE API AT ALL: Reddit publishes no delete endpoint for one and its update schema has no status, archived or deleted field (re-checked against Reddit’s own reference on 2026-08-05), so creating one is a one-way door and the only way to take it down is Reddit’s Ads Manager. Say that plainly rather than offering to delete it.",
      "group": "ads",
      "section": "REDDIT ADS (2026-07-31)",
      "annotations": {
        "readOnlyHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "postId": {
            "type": "string",
            "description": "the post id (t3_…)"
          },
          "headline": {
            "type": "string"
          },
          "body": {
            "type": "string"
          },
          "allowComments": {
            "type": "boolean"
          }
        },
        "required": [
          "postId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_reddit_ads_campaign",
      "title": "Create a Reddit campaign",
      "description": "Create the top tier of a Reddit ad — the campaign, which sets the OBJECTIVE everything under it optimises toward and (optionally) a lifetime spend cap. ALWAYS created PAUSED, with no override; it spends nothing until set_reddit_ads_status(confirm:true). Pick the objective deliberately, because the ad group’s bid type has to match it and it cannot be changed afterwards: CLICKS is Reddit’s name for traffic to a website (there is no TRAFFIC), CONVERSIONS optimises toward pixel events and needs a working pixel, LEAD_GENERATION drives in-feed lead forms, IMPRESSIONS and VIDEO_VIEWABLE_IMPRESSIONS buy reach, APP_INSTALLS and CATALOG_SALES are for apps and product feeds. A campaign on its own can never serve: create an ad group under it, then an ad pointing at a post. The result is read back from Reddit.",
      "group": "ads",
      "section": "REDDIT ADS (2026-07-31)",
      "annotations": {
        "readOnlyHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "description": "Reddit ad account id (a2_…) — omit when only one is shared",
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "objective": {
            "description": "default CLICKS — which is what Reddit calls website traffic",
            "type": "string",
            "enum": [
              "APP_INSTALLS",
              "CATALOG_SALES",
              "CLICKS",
              "CONVERSIONS",
              "IMPRESSIONS",
              "LEAD_GENERATION",
              "VIDEO_VIEWABLE_IMPRESSIONS"
            ]
          },
          "spendCapCents": {
            "description": "lifetime spend ceiling for the whole campaign, in minor units of the ad account’s currency",
            "type": "number"
          }
        },
        "required": [
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_reddit_ads_ad_group",
      "title": "Create a Reddit ad group (targeting, budget, bidding, schedule)",
      "description": "Create an ad group under an existing Reddit campaign — this is the tier that holds the budget, the bid and ALL the targeting. ALWAYS created PAUSED; it spends nothing until set_reddit_ads_status(confirm:true). Reddit requires more here than most platforms and refuses the create without it: a bidType, a bidStrategy, a startTime, a budget with its goalType, a bidAmount whenever the bid type is a paid rate, and a conversion pixel (resolved automatically when the ad account has exactly one). THE BID TYPE MUST FIT THE CAMPAIGN’S OBJECTIVE — a CLICKS campaign takes CPC and refuses CPM; Reddit’s error says which. Money is ordinary amounts in the ad account’s currency (micro-currency is handled for you). Resolve community names and interest ids with search_reddit_ads_targeting first, and consider reddit_ads_forecast + reddit_ads_bid_suggestion before committing. Everything is READ BACK from Reddit before you are told it exists — print the returned note verbatim.",
      "group": "ads",
      "section": "REDDIT ADS (2026-07-31)",
      "annotations": {
        "readOnlyHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "campaignId": {
            "type": "string",
            "description": "the campaign this ad group belongs to"
          },
          "name": {
            "type": "string"
          },
          "budget": {
            "type": "number",
            "description": "budget in the ad account’s currency, paired with goalType"
          },
          "goalType": {
            "description": "default DAILY_SPEND",
            "type": "string",
            "enum": [
              "DAILY_SPEND",
              "LIFETIME_SPEND"
            ]
          },
          "bidType": {
            "type": "string",
            "enum": [
              "CPC",
              "CPM",
              "CPV",
              "CPV6"
            ],
            "description": "must fit the campaign objective — CLICKS campaigns take CPC"
          },
          "bidStrategy": {
            "type": "string",
            "enum": [
              "BIDLESS",
              "MANUAL_BIDDING",
              "MAXIMIZE_VOLUME",
              "TARGET_CPX"
            ],
            "description": "MANUAL_BIDDING needs bidAmount"
          },
          "bidAmount": {
            "description": "bid in the ad account’s currency — required for paid bid types; ask reddit_ads_bid_suggestion",
            "type": "number"
          },
          "startTime": {
            "type": "string",
            "description": "ISO 8601, e.g. 2026-08-15T00:00:00Z — Reddit rejects the create without one"
          },
          "endTime": {
            "type": "string"
          },
          "conversionPixelId": {
            "description": "only needed when the ad account has more than one pixel",
            "type": "string"
          },
          "optimizationGoal": {
            "description": "cannot be changed later",
            "type": "string"
          },
          "savedAudienceId": {
            "description": "reuse a saved audience instead of spelling targeting out — from list_reddit_ads_saved_audiences",
            "type": "string"
          },
          "targeting": {
            "type": "object",
            "properties": {
              "communities": {
                "description": "bare subreddit NAMES, e.g. [\"running\"] — not t5_ ids, not \"r/running\"",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "excludedCommunities": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "geolocations": {
                "description": "country codes like [\"US\"], or ids from search_reddit_ads_targeting",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "excludedGeolocations": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "interests": {
                "description": "interest ids like [\"pets_v3\"]",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "excludedInterests": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "keywords": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "excludedKeywords": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "customAudienceIds": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "excludedCustomAudienceIds": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "carriers": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "languages": {
                "description": "e.g. [\"EN\"]",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "platforms": {
                "description": "ALL / DESKTOP / MOBILE_NATIVE / MOBILE_WEB — at least one mobile type is expected",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "locations": {
                "description": "FEED and/or COMMENTS_PAGE",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "viewModes": {
                "description": "ALL / CARD / CLASSIC / COMPACT / IMMERSIVE",
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "devices": {
                "type": "array",
                "items": {}
              },
              "gender": {
                "description": "MALE or FEMALE — omit for all",
                "type": "string"
              },
              "expandTargeting": {
                "description": "let Reddit widen the audience automatically",
                "type": "boolean"
              },
              "suppressionEventTypes": {
                "description": "[\"ALL_FEATURES\"] to stop showing this to people who already converted — that is the only value Reddit accepts",
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            }
          },
          "schedule": {
            "description": "weekly dayparting windows — omit to run all week",
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "startDay": {
                  "type": "number",
                  "description": "0 = Sunday … 6 = Saturday"
                },
                "endDay": {
                  "type": "number"
                },
                "startHour": {
                  "type": "number",
                  "description": "0-23, in the ad group’s time zone"
                },
                "endHour": {
                  "type": "number"
                }
              },
              "required": [
                "startDay",
                "endDay",
                "startHour",
                "endHour"
              ]
            }
          }
        },
        "required": [
          "campaignId",
          "name",
          "budget",
          "bidType",
          "bidStrategy",
          "startTime"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_reddit_ads_ad_group",
      "title": "Edit a Reddit ad group",
      "description": "Change an existing Reddit ad group’s name, budget, goal type, bid, schedule dates or targeting. Budget and bid are ordinary amounts in the ad account’s currency. Targeting is REPLACED by what you pass, not merged — send the whole set you want. This does NOT activate or pause anything; use set_reddit_ads_status for that. The result is read back from Reddit.",
      "group": "ads",
      "section": "REDDIT ADS (2026-07-31)",
      "annotations": {
        "readOnlyHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "adGroupId": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "budget": {
            "type": "number"
          },
          "goalType": {
            "type": "string",
            "enum": [
              "DAILY_SPEND",
              "LIFETIME_SPEND"
            ]
          },
          "bidAmount": {
            "type": "number"
          },
          "bidType": {
            "type": "string",
            "enum": [
              "CPC",
              "CPM",
              "CPV",
              "CPV6"
            ]
          },
          "bidStrategy": {
            "type": "string",
            "enum": [
              "BIDLESS",
              "MANUAL_BIDDING",
              "MAXIMIZE_VOLUME",
              "TARGET_CPX"
            ]
          },
          "startTime": {
            "type": "string"
          },
          "endTime": {
            "type": "string"
          },
          "savedAudienceId": {
            "description": "point this ad group at a saved audience instead",
            "type": "string"
          },
          "targeting": {
            "description": "same shape as create_reddit_ads_ad_group — REPLACES the existing targeting",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "schedule": {
            "type": "array",
            "items": {}
          }
        },
        "required": [
          "adGroupId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_reddit_ads_ad",
      "title": "Create a Reddit ad",
      "description": "Create the ad itself — the object that binds a POST (the creative) to an AD GROUP (the targeting and budget). Create the post first with create_reddit_ads_post and pass its id as postId; without a post there is nothing to show. ALWAYS created PAUSED, and Reddit additionally has to APPROVE it, so a fresh ad reports effective status PENDING_APPROVAL — report that rather than calling it live. GOTCHA: a TEXT (\"free form\") post’s ad may not carry clickUrl at all — Reddit refuses it. The click destination for image and link ads lives on the POST’s media destinationUrl, not here.",
      "group": "ads",
      "section": "REDDIT ADS (2026-07-31)",
      "annotations": {
        "readOnlyHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "adGroupId": {
            "type": "string",
            "description": "the ad group whose targeting and budget this ad runs under"
          },
          "name": {
            "type": "string"
          },
          "postId": {
            "type": "string",
            "description": "the post to promote (t3_…) from create_reddit_ads_post or list_reddit_ads_posts"
          },
          "clickUrl": {
            "description": "leave unset for TEXT-post ads — Reddit rejects \"Free form ads cannot have a click url\"",
            "type": "string"
          },
          "redditProfileId": {
            "description": "the post author profile — required for catalog sales campaigns",
            "type": "string"
          },
          "eventTrackers": {
            "description": "third-party measurement URLs; only Reddit-approved providers are accepted",
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "type": {
                  "type": "string",
                  "enum": [
                    "CLICK",
                    "VIEW"
                  ]
                },
                "url": {
                  "type": "string"
                }
              },
              "required": [
                "type",
                "url"
              ]
            }
          }
        },
        "required": [
          "adGroupId",
          "name",
          "postId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_reddit_ads_ad",
      "title": "Edit a Reddit ad",
      "description": "Rename a Reddit ad, point it at a different post, or change its click url. Does not activate or pause it — use set_reddit_ads_status. Swapping the post changes what people see, so confirm the new creative with the user first. The result is read back from Reddit.",
      "group": "ads",
      "section": "REDDIT ADS (2026-07-31)",
      "annotations": {
        "readOnlyHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "adId": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "postId": {
            "description": "promote a different post",
            "type": "string"
          },
          "clickUrl": {
            "description": "pass an empty string to clear it",
            "type": "string"
          }
        },
        "required": [
          "adId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_reddit_ads_campaign",
      "title": "Edit a Reddit campaign",
      "description": "Change an existing Reddit campaign’s name, spend cap, budget, goal type or flight dates. Amounts are ordinary numbers in the ad account’s currency. This does NOT activate, pause or archive anything — use set_reddit_ads_status for that. The result is read back from Reddit before you are told it took.",
      "group": "ads",
      "section": "REDDIT ADS (2026-07-31)",
      "annotations": {
        "readOnlyHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "campaignId": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "spendCap": {
            "description": "lifetime spend ceiling in the ad account’s currency",
            "type": "number"
          },
          "budget": {
            "type": "number"
          },
          "goalType": {
            "type": "string",
            "enum": [
              "DAILY_SPEND",
              "LIFETIME_SPEND"
            ]
          },
          "startTime": {
            "type": "string"
          },
          "endTime": {
            "type": "string"
          }
        },
        "required": [
          "campaignId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_reddit_ads_status",
      "title": "Activate, pause, archive or delete a Reddit campaign / ad group / ad",
      "description": "The one switch that arms real money on Reddit. Pass kind (\"campaign\", \"ad_group\" or \"ad\") plus the object id. ACTIVE starts real spend as soon as Reddit approves — show the user exactly what will run and get an explicit yes, then call again with confirm:true. PAUSED is always safe and never gated. REDDIT HAS NO DELETE OPERATION for these three: removal is a status, and ARCHIVED/DELETED here run the SAME blast-radius gate as delete_reddit_ads_object (call that one instead when you mean to remove something — it is the same code path and its unconfirmed call reports what goes with it). Remember Reddit’s three tiers all have to be ACTIVE for a single impression to serve: activating the campaign alone does nothing if its ad group and ad are still paused. The resulting status is READ BACK from Reddit.",
      "group": "ads",
      "section": "REDDIT ADS (2026-07-31)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "kind": {
            "type": "string",
            "enum": [
              "campaign",
              "ad_group",
              "ad"
            ]
          },
          "id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "ACTIVE",
              "PAUSED",
              "ARCHIVED",
              "DELETED"
            ],
            "description": "ACTIVE = start spending; PAUSED = stop; ARCHIVED = retire; DELETED = permanent, and blocked for 3h after any change"
          },
          "confirm": {
            "description": "REQUIRED true for ACTIVE (real spend), ARCHIVED and DELETED",
            "type": "boolean"
          }
        },
        "required": [
          "kind",
          "id",
          "status"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_reddit_ads_object",
      "title": "Delete or archive a Reddit campaign / ad group / ad",
      "description": "Remove a Reddit campaign, ad group or ad. REDDIT HAS NO DELETE VERB for any of the three — its whole Ads API has exactly four HTTP DELETE endpoints and none of them is a campaign, ad group or ad — so removal is the `configured_status` field: DELETED (permanent) or ARCHIVED (out of service, and can be set back to PAUSED). DELETED is additionally TIME-GATED: Reddit refuses to delete anything modified in the last 3 hours, and the refusal names ARCHIVED as the immediate alternative. CALL IT WITHOUT confirm FIRST — nothing changes, and you get the object’s real name, status and how many ad groups / ads sit under it, read live from Reddit. A target with children or live delivery then needs confirmName (its exact name) and confirmChildren (the count from that read-back). Reddit does NOT document whether removing a campaign cascades to its ad groups and ads — each carries its own status — so remove the children yourself if they should go too. To stop delivery without removing, use set_reddit_ads_status(status:\"PAUSED\").",
      "group": "ads",
      "section": "REDDIT ADS (2026-07-31)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "kind": {
            "type": "string",
            "enum": [
              "campaign",
              "ad_group",
              "ad"
            ]
          },
          "id": {
            "type": "string"
          },
          "status": {
            "description": "DELETED = permanent (default); ARCHIVED = out of service but reversible to PAUSED",
            "type": "string",
            "enum": [
              "DELETED",
              "ARCHIVED"
            ]
          },
          "confirm": {
            "description": "REQUIRED true",
            "type": "boolean"
          },
          "confirmName": {
            "description": "the object’s EXACT name, required when it has children or is live",
            "type": "string"
          },
          "confirmChildren": {
            "description": "the exact number of children reported by the unconfirmed call, required when it has any",
            "type": "number"
          }
        },
        "required": [
          "kind",
          "id"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_reddit_ads_pixels",
      "title": "List Reddit conversion pixels (and whether they are firing)",
      "description": "List the conversion pixels on a Reddit ad account, each with the LAST TIME IT FIRED — which is the difference between \"a pixel exists\" and \"conversion tracking works\". Call this before building anything: since 13 July 2026 Reddit REQUIRES a pixel on every ad group and every CBO campaign, so an account with none cannot run ads at all. IMPORTANT: the Reddit API has no operation that creates a pixel — if the account has none, the only fix is for the user to add it in Reddit’s Events Manager (ads.reddit.com ▸ Events Manager); never claim you can create one. Read-only, free.",
      "group": "ads",
      "section": "Reddit Ads wave 2: measurement, audiences, lead forms, changelog (2026-07-31)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "description": "Reddit ad account id (a2_…) — omit when only one is shared",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "send_reddit_ads_conversions",
      "title": "Send conversions to Reddit (Conversions API)",
      "description": "Report conversions to Reddit server-side — purchases, leads, sign-ups, or your own custom events — so Reddit can attribute them to the ads that caused them and optimise delivery toward them. This is what makes a CONVERSIONS campaign work; without it Reddit optimises blind. Send events as close to real time as you can: Reddit REFUSES anything older than seven days, and deduplication against the browser pixel only works inside two days. Pass ordinary email addresses and phone numbers — they are canonicalised and SHA-256 hashed on our server before they reach Reddit, and a value you already hashed is passed through untouched. The more match keys per event (email, phone, clickId, uuid, externalId, IP + user agent) the better the attribution. Set conversionId on every event if you ALSO run the browser pixel, or the same purchase is counted twice. Costs no credits and spends no ad money — this is measurement. Needs the \"adsconversions\" permission: if Reddit answers 403, the connection predates it and the user must reconnect Reddit Ads.",
      "group": "ads",
      "section": "Reddit Ads wave 2: measurement, audiences, lead forms, changelog (2026-07-31)",
      "annotations": {
        "readOnlyHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "pixelId": {
            "description": "from list_reddit_ads_pixels — only needed when the account has more than one",
            "type": "string"
          },
          "testId": {
            "description": "a test id from Events Manager ▸ Testing — events sent with it are visible there and NEVER counted in reporting",
            "type": "string"
          },
          "events": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "trackingType": {
                  "description": "default PAGE_VISIT",
                  "type": "string",
                  "enum": [
                    "PAGE_VISIT",
                    "VIEW_CONTENT",
                    "SEARCH",
                    "ADD_TO_CART",
                    "ADD_TO_WISHLIST",
                    "PURCHASE",
                    "LEAD",
                    "SIGN_UP",
                    "CUSTOM"
                  ]
                },
                "customEventName": {
                  "description": "required when trackingType is CUSTOM — free-form, CASE-SENSITIVE, max 64 chars; only the 20 most recent custom events show on Reddit’s dashboard",
                  "type": "string"
                },
                "eventAt": {
                  "description": "when it happened — ISO timestamp or Unix epoch; defaults to now. Must be within the last 7 days.",
                  "anyOf": [
                    {
                      "type": "number"
                    },
                    {
                      "type": "string"
                    }
                  ]
                },
                "actionSource": {
                  "description": "default WEBSITE — where the conversion happened",
                  "type": "string",
                  "enum": [
                    "WEBSITE",
                    "APP",
                    "OTHER",
                    "PHYSICAL_STORE"
                  ]
                },
                "clickId": {
                  "description": "Reddit’s own click id, the strongest match key there is",
                  "type": "string"
                },
                "eventSourceUrl": {
                  "description": "the page the conversion happened on",
                  "type": "string"
                },
                "user": {
                  "type": "object",
                  "properties": {
                    "email": {
                      "description": "plain address or a 64-char SHA-256 hash",
                      "type": "string"
                    },
                    "phone": {
                      "description": "E.164 like +15554441234, or a 64-char SHA-256 hash",
                      "type": "string"
                    },
                    "externalId": {
                      "type": "string"
                    },
                    "ipAddress": {
                      "type": "string"
                    },
                    "userAgent": {
                      "type": "string"
                    },
                    "idfa": {
                      "type": "string"
                    },
                    "aaid": {
                      "type": "string"
                    },
                    "uuid": {
                      "description": "the first-party _rdt_uuid cookie value",
                      "type": "string"
                    },
                    "screenWidth": {
                      "type": "number"
                    },
                    "screenHeight": {
                      "type": "number"
                    },
                    "limitedDataUse": {
                      "description": "flag this user as Limited Data Use (they did not consent to behavioural targeting); country is required",
                      "type": "object",
                      "properties": {
                        "country": {
                          "type": "string"
                        },
                        "region": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "country"
                      ]
                    }
                  }
                },
                "metadata": {
                  "type": "object",
                  "properties": {
                    "conversionId": {
                      "description": "YOUR unique id for this conversion — the deduplication key; use the order number for purchases",
                      "type": "string"
                    },
                    "currency": {
                      "type": "string"
                    },
                    "value": {
                      "description": "revenue, in that currency",
                      "type": "number"
                    },
                    "itemCount": {
                      "type": "number"
                    },
                    "products": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          },
                          "category": {
                            "type": "string"
                          },
                          "quantity": {
                            "type": "number"
                          },
                          "itemPrice": {
                            "type": "number"
                          }
                        },
                        "required": [
                          "id"
                        ]
                      }
                    }
                  }
                }
              }
            },
            "description": "up to 1,000 events per call"
          }
        },
        "required": [
          "events"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_reddit_ads_audiences",
      "title": "List Reddit custom audiences",
      "description": "List the CUSTOM AUDIENCES (uploaded customer lists) on a Reddit ad account, with each one’s match-size range and status. Reddit will not deliver to an audience under about 1,000 matched redditors, and the reply says which ones fall short — an audience that is too small silently reaches nobody rather than erroring. Use an id here as customAudienceIds in ad-group targeting to retarget it, or as excludedCustomAudienceIds to suppress existing customers from a prospecting campaign. Read-only, free.",
      "group": "ads",
      "section": "Reddit Ads wave 2: measurement, audiences, lead forms, changelog (2026-07-31)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "name": {
            "description": "filter by name",
            "type": "string"
          },
          "limit": {
            "description": "default 50, max 100",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_reddit_ads_audience",
      "title": "Create a Reddit custom audience (customer list)",
      "description": "Create an empty custom audience on a Reddit ad account, then fill it with update_reddit_ads_audience_users. Reddit only supports ONE kind of audience through the API — an uploaded CUSTOMER LIST matched on hashed emails and mobile advertising ids; pixel-retargeting, engagement and lookalike audiences are built by Reddit itself in Ads Manager and cannot be created here. The audience arrives empty and stays unusable until it matches roughly 1,000 redditors, and Reddit takes up to 4 hours to show a size change and up to 36 hours to finish processing a list — so do not create, upload and then report success on reach in the same breath. Free.",
      "group": "ads",
      "section": "Reddit Ads wave 2: measurement, audiences, lead forms, changelog (2026-07-31)",
      "annotations": {
        "readOnlyHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "name": {
            "type": "string",
            "description": "what this list is, e.g. \"Purchasers – last 180 days\""
          }
        },
        "required": [
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_reddit_ads_audience_users",
      "title": "Add or remove people in a Reddit custom audience",
      "description": "Add people to, or remove people from, a Reddit custom audience. Pass ordinary email addresses and/or mobile advertising ids — each one is canonicalised the way Reddit specifies and SHA-256 hashed on our server before it is sent, so raw customer data never reaches Reddit, and an identifier you already hashed is passed through untouched. Up to 2,500 rows per call; send bigger lists as repeated calls and the audience accumulates. EVERY ROW MUST CARRY THE SAME FIELDS: Reddit’s upload is positional, so if some rows have an email and others do not, the values shift into the wrong column and match nobody — split those into separate calls instead. After Reddit accepts the upload the size does not move for up to 4 hours and processing can take 36, so never re-send the same batch because the count looks unchanged. Free.",
      "group": "ads",
      "section": "Reddit Ads wave 2: measurement, audiences, lead forms, changelog (2026-07-31)",
      "annotations": {
        "readOnlyHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "customAudienceId": {
            "type": "string",
            "description": "from create_reddit_ads_audience or list_reddit_ads_audiences"
          },
          "action": {
            "description": "default ADD",
            "type": "string",
            "enum": [
              "ADD",
              "REMOVE"
            ]
          },
          "users": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "email": {
                  "description": "plain address or a 64-char SHA-256 hash",
                  "type": "string"
                },
                "maid": {
                  "description": "IDFA (uppercase hex, dashes) or AAID (lowercase hex, dashes), or a 64-char SHA-256 hash",
                  "type": "string"
                }
              }
            },
            "description": "up to 2,500 rows; every row must carry the same fields"
          }
        },
        "required": [
          "customAudienceId",
          "users"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_reddit_ads_audience",
      "title": "Delete a Reddit custom audience",
      "description": "Permanently delete a Reddit custom audience. This is one of the very few things Reddit really deletes — campaigns, ad groups and ads are only ever archived — and it cannot be undone: the uploaded list is gone and any ad group targeting it loses that audience. Confirm-gated: show the user the audience name and its size, get an explicit yes, then call again with confirm:true.",
      "group": "ads",
      "section": "Reddit Ads wave 2: measurement, audiences, lead forms, changelog (2026-07-31)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "customAudienceId": {
            "type": "string"
          },
          "confirm": {
            "description": "REQUIRED true — the deletion is permanent",
            "type": "boolean"
          }
        },
        "required": [
          "customAudienceId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_reddit_ads_saved_audiences",
      "title": "List Reddit saved audiences",
      "description": "List the SAVED AUDIENCES on a Reddit ad account — named, reusable targeting definitions (communities, interests, geos, devices and so on) that an ad group can point at instead of repeating the whole block. The reply says how many live ad groups each one is attached to, which is what makes editing one a decision rather than a formality. Read-only, free.",
      "group": "ads",
      "section": "Reddit Ads wave 2: measurement, audiences, lead forms, changelog (2026-07-31)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "limit": {
            "description": "default 50, max 100",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_reddit_ads_saved_audience",
      "title": "Create a reusable Reddit saved audience",
      "description": "Save a targeting definition under a name so every ad group can reuse it — define \"our people\" once, then pass savedAudienceId when creating ad groups instead of retyping communities and interests each time, and one later edit re-targets every ad group using it. Takes the same targeting block as create_reddit_ads_ad_group, so resolve community names and interest ids with search_reddit_ads_targeting first. Creates targeting only: no budget, no spend. Free.",
      "group": "ads",
      "section": "Reddit Ads wave 2: measurement, audiences, lead forms, changelog (2026-07-31)",
      "annotations": {
        "readOnlyHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "targeting": {
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {},
            "description": "same shape as create_reddit_ads_ad_group targeting — an empty block is refused, because a saved audience IS its targeting"
          }
        },
        "required": [
          "name",
          "targeting"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_reddit_ads_saved_audience",
      "title": "Edit a Reddit saved audience",
      "description": "Rename a Reddit saved audience or replace its targeting. Targeting is REPLACED, never merged — send the whole set you want. Editing one that live ad groups already use re-targets all of them immediately, so say how many are affected and get a yes before changing targeting on a running account. The result is read back from Reddit.",
      "group": "ads",
      "section": "Reddit Ads wave 2: measurement, audiences, lead forms, changelog (2026-07-31)",
      "annotations": {
        "readOnlyHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "savedAudienceId": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "targeting": {
            "description": "REPLACES the existing targeting",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          }
        },
        "required": [
          "savedAudienceId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_reddit_ads_saved_audience",
      "title": "Delete a Reddit saved audience",
      "description": "Delete a Reddit saved audience — the named, reusable targeting block ad groups point at. Reddit publishes NO delete verb for one (its whole Ads API has four, and this is not among them), so removal is its `status` field set to DELETED. THE BLAST RADIUS IS THE AD GROUPS USING IT: every live ad group pointing at this audience loses that targeting definition the moment it goes, and Reddit’s own `active_ad_groups_count` is what says how many. CALL IT WITHOUT confirm FIRST — nothing is deleted and you get its real name and that count; show the user exactly that, and if any ad group uses it you must then pass confirmName (its exact name) and confirmChildren (the count). The result is READ BACK from Reddit.",
      "group": "ads",
      "section": "Reddit Ads wave 2: measurement, audiences, lead forms, changelog (2026-07-31)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "savedAudienceId": {
            "type": "string"
          },
          "confirm": {
            "description": "REQUIRED true",
            "type": "boolean"
          },
          "confirmName": {
            "description": "the audience’s EXACT name, required when live ad groups use it",
            "type": "string"
          },
          "confirmChildren": {
            "description": "the exact number of live ad groups reported by the unconfirmed call",
            "type": "number"
          }
        },
        "required": [
          "savedAudienceId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_reddit_ads_lead_forms",
      "title": "List Reddit lead generation forms",
      "description": "List the lead generation forms on a Reddit ad account, with the fields each one asks for. Reddit publishes NO endpoint for reading the leads a form has collected — the user downloads those from Reddit’s Ads Manager. Say that plainly if asked for the leads themselves; do not imply they can be fetched. Read-only, free.",
      "group": "ads",
      "section": "Reddit Ads wave 2: measurement, audiences, lead forms, changelog (2026-07-31)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "limit": {
            "description": "default 50, max 100",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_reddit_ads_lead_form",
      "title": "Create a Reddit lead generation form",
      "description": "Create a lead generation form — the in-feed form redditors fill in without leaving Reddit, used by LEAD_GENERATION campaigns. Reddit requires a link to a real privacy policy on every form. Ask for the FEWEST fields that make a lead useful: every extra question costs completions. KNOW THE LIMIT BEFORE YOU PROMISE ANYTHING: Reddit exposes no way to attach a form to an ad through the API — there is no lead-form field on an ad, an ad group or a post — so the user picks this form in Reddit’s Ads Manager when building the creative, and downloads its leads from there. There is also no update and no delete, so get the questions right the first time. Free.",
      "group": "ads",
      "section": "Reddit Ads wave 2: measurement, audiences, lead forms, changelog (2026-07-31)",
      "annotations": {
        "readOnlyHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "name": {
            "type": "string",
            "description": "internal name — redditors do not see it"
          },
          "prompt": {
            "type": "string",
            "description": "the line shown above the form telling people what they are signing up for"
          },
          "privacyLink": {
            "type": "string",
            "description": "full https:// URL to your privacy policy — Reddit requires it"
          },
          "questions": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "type": {
                  "type": "string",
                  "enum": [
                    "EMAIL",
                    "FIRST_NAME",
                    "LAST_NAME",
                    "PHONE_NUMBER",
                    "POSTAL_CODE",
                    "JOB_TITLE",
                    "COMPANY",
                    "COMPANY_EMAIL"
                  ]
                },
                "required": {
                  "description": "default true",
                  "type": "boolean"
                }
              },
              "required": [
                "type"
              ]
            },
            "description": "at least one"
          }
        },
        "required": [
          "name",
          "prompt",
          "privacyLink",
          "questions"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "reddit_ads_history",
      "title": "Reddit ad account changelog",
      "description": "Read the CHANGELOG for a Reddit ad account — what was changed, from what to what, by which member, and when. This is the tool for \"performance fell off a cliff on Tuesday, what changed?\" and for auditing what an agent or a teammate actually did. Call it with nothing but the ad account to get every change; narrow it with a date window, change types (BUDGET, BID, STATUS, TARGETING…) or specific campaign / ad group / ad ids. An empty result genuinely means nothing was changed in that window — say that, do not read it as missing data. Read-only, free.",
      "group": "ads",
      "section": "Reddit Ads wave 2: measurement, audiences, lead forms, changelog (2026-07-31)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "since": {
            "description": "YYYY-MM-DD or full ISO timestamp",
            "type": "string"
          },
          "until": {
            "description": "YYYY-MM-DD or full ISO timestamp",
            "type": "string"
          },
          "changeTypes": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "AD_ACCOUNT",
                "AD",
                "AD_GROUP",
                "AUDIENCE",
                "BID",
                "BUDGET",
                "CAMPAIGN",
                "STATUS",
                "TARGETING"
              ]
            }
          },
          "entityType": {
            "description": "required when you pass entityIds",
            "type": "string",
            "enum": [
              "AD",
              "AD_GROUP",
              "CAMPAIGN"
            ]
          },
          "entityIds": {
            "description": "restrict to these objects",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "includeChildEntities": {
            "description": "also return changes to what lives under those objects",
            "type": "boolean"
          },
          "memberIds": {
            "description": "restrict to changes made by these Reddit members",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "limit": {
            "description": "default 50, max 200",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_linkedin_pages",
      "title": "List the LinkedIn company Pages this account administers",
      "description": "List the LinkedIn COMPANY PAGES the connected account administers — id, name and the role held on each. ALWAYS call this before post_to_linkedin_page when there is more than one Page: publishing to the wrong company Page is a public mistake and Hermoso never chooses for the user. If it comes back empty, the account holds no Page admin role, or LinkedIn has not granted this app the organization scopes — say that plainly rather than guessing an id. Read-only, free.",
      "group": "ads",
      "section": "LINKEDIN COMPANY PAGES + ADS (2026-07-30)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "post_to_linkedin_page",
      "title": "Publish to a LinkedIn company Page",
      "description": "Publish a post to one of the user’s LinkedIn COMPANY PAGES — text, plus optionally an image, a video, or a 2–20 image CAROUSEL (LinkedIn calls it a MultiImage post; pass the slides in order as imageUrls[]). The media need not be a Hermoso render — it must be Hermoso-HOSTED because we upload the bytes to LinkedIn ourselves, and upload_file turns ANY file the user already has into such a URL. ORGANIC CAROUSELS ARE COMPANY-PAGE ONLY — a personal profile cannot publish one and is refused by name, so send a deck here rather than to post_to_linkedin. This is a DIFFERENT thing from post_to_linkedin, which publishes to the person’s own profile: pick the one the user actually asked for and never substitute. organizationId comes from list_linkedin_pages; omit it only when the account administers exactly one Page. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. LinkedIn does NOT allow the image or video of a published post to be swapped afterwards, so get the visual right first (the copy can still be edited with manage_linkedin_post).",
      "group": "ads",
      "section": "LINKEDIN COMPANY PAGES + ADS (2026-07-30)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "hook": {
            "description": "WHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer \"which hooks work\", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. \"direct_callout\", \"mid_problem\", \"before_after\") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.",
            "type": "string"
          },
          "subject": {
            "description": "WHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. \"winter coat\", \"free trial\", \"founder story\"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.",
            "type": "string"
          },
          "organizationId": {
            "description": "numeric Page id from list_linkedin_pages",
            "type": "string"
          },
          "text": {
            "type": "string",
            "description": "the post text"
          },
          "imageUrl": {
            "description": "a Hermoso-hosted image URL — a render (list_library), or ANY image of the user’s own passed through upload_file first. An arbitrary external host is refused.",
            "type": "string"
          },
          "videoUrl": {
            "description": "a Hermoso-hosted video URL — a render, or the user’s own footage via upload_file. LinkedIn processes it before publishing, which takes a minute.",
            "type": "string"
          },
          "imageUrls": {
            "description": "CAROUSEL — an ORDERED list of image (and, where the channel allows, video) URLs published as ONE post the viewer swipes through. THIS IS NOT “post several” — it is a single post with several slides, which is what a multi-slide creative (a listicle, a “1/6 · SWIPE” deck) actually needs; publishing only its first slide tells the viewer to swipe at something that cannot. The ORDER is the product. Limits per channel: Instagram 2–10 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "idempotencyKey": {
            "description": "SAFE RETRIES. Publishing can take minutes (a video upload, a carousel of ten slides) and a transport can time out while the post SUCCEEDS — retrying blind is how the same thing gets posted twice. Pass any stable string here and a repeat of the SAME publish returns the ORIGINAL post id instead of posting again (24h). You do not have to: an identical publish is auto-recognised for 10 minutes anyway. If a call times out or errors ambiguously, CALL AGAIN WITH THE SAME KEY — that is the safe move, and it will either report the original post or publish it for the first time. It never posts twice.",
            "type": "string"
          },
          "allowDuplicate": {
            "description": "post it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land.",
            "type": "boolean"
          },
          "altText": {
            "type": "string"
          },
          "title": {
            "description": "video title",
            "type": "string"
          },
          "visibility": {
            "description": "default PUBLIC",
            "type": "string",
            "enum": [
              "PUBLIC",
              "CONNECTIONS"
            ]
          }
        },
        "required": [
          "text"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "manage_linkedin_post",
      "title": "Edit or delete a LinkedIn post",
      "description": "Edit or delete a published LinkedIn post — personal profile or company Page. Pass postUrn, the full urn returned when it was published. action:\"edit\" changes ONLY THE COPY: LinkedIn does not allow the image or video of a published post to be replaced, so a new visual means a NEW post — tell the user that instead of promising a swap. action:\"delete\" is immediate and public and requires confirm:true.",
      "group": "ads",
      "section": "LINKEDIN COMPANY PAGES + ADS (2026-07-30)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "postUrn": {
            "type": "string",
            "description": "the full LinkedIn post urn returned by publishing"
          },
          "action": {
            "type": "string",
            "enum": [
              "edit",
              "delete"
            ]
          },
          "text": {
            "description": "the new copy, for action:\"edit\"",
            "type": "string"
          },
          "confirm": {
            "description": "REQUIRED true to delete",
            "type": "boolean"
          }
        },
        "required": [
          "postUrn",
          "action"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "linkedin_page_analytics",
      "title": "Organic performance of a LinkedIn company Page",
      "description": "ORGANIC performance for one of the brand’s LinkedIn COMPANY PAGES: total followers, followers gained (organic vs paid) across the window, Page views (all / unique / desktop / mobile), and the impressions, unique impressions, clicks, likes, comments, shares and engagement rate of the Page’s posts. This is what answers “is our LinkedIn actually working” and “did that post land”. It is NOT linkedin_ads_report — that covers PAID campaigns; LinkedIn excludes sponsored activity from these figures entirely. Pass postUrns (the urn:li:share:… / urn:li:ugcPost:… that post_to_linkedin_page returned) for PER-POST numbers; LinkedIn forbids a date range together with named posts, so that switches to lifetime-per-post. Only Pages the user ticked in Manage accounts are readable — a Page the account merely administers is refused, by design. LinkedIn keeps 12 months, follower figures run about 2 days behind, and it OMITS posts with no recorded activity rather than returning zeros: report an absent post or an unavailable section as MISSING data, never as zero. Read-only, 0 credits. Needs LinkedIn connected with the organization scopes.",
      "group": "ads",
      "section": "LINKEDIN COMPANY PAGES + ADS (2026-07-30)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "organizationId": {
            "description": "numeric Page id from list_linkedin_pages — omit only when exactly one Page is shared with this brand",
            "type": "string"
          },
          "startDate": {
            "description": "YYYY-MM-DD, default 28 days ago (LinkedIn keeps 12 months)",
            "type": "string"
          },
          "endDate": {
            "description": "YYYY-MM-DD, default today",
            "type": "string"
          },
          "postUrns": {
            "description": "urn:li:share:… / urn:li:ugcPost:… — switches to per-post lifetime numbers instead of the Page total",
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_linkedin_ads_campaigns",
      "title": "List LinkedIn ad accounts / campaigns",
      "description": "Read the LinkedIn ad accounts this connection can reach, and — with adAccountId — that account’s campaign groups and campaigns: name, status, objective, budgets, and LinkedIn’s own servingStatuses, which explain WHY something is not delivering (billing hold, start-date hold, parent-status hold). LinkedIn’s Advertising API is an approval-gated product, and on its Development tier each ad account must ALSO be mapped to the app in LinkedIn’s Developer Portal — so if nothing is reachable, say that rather than implying the user has no ad account. Read-only, free.",
      "group": "ads",
      "section": "LINKEDIN COMPANY PAGES + ADS (2026-07-30)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "description": "LinkedIn ad account id — omit to list the reachable accounts",
            "type": "string"
          },
          "campaignId": {
            "description": "also return the CREATIVES (the actual ads) under this campaign, each with its intendedStatus, whether it isServing, and LinkedIn’s own servingHoldReasons",
            "type": "string"
          },
          "campaignIds": {
            "description": "same, for several campaigns at once",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "statuses": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "ACTIVE",
                "PAUSED",
                "ARCHIVED",
                "DRAFT"
              ]
            }
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "linkedin_ads_report",
      "title": "LinkedIn ads performance report",
      "description": "LinkedIn ad performance — impressions, clicks, cost, website conversions, leads and social actions — pivoted by CAMPAIGN (default), CAMPAIGN_GROUP, CREATIVE, ACCOUNT, CONVERSION, PLACEMENT_NAME, IMPRESSION_DEVICE_TYPE, SERVING_LOCATION… or by AUDIENCE DEMOGRAPHICS: MEMBER_COMPANY_SIZE, MEMBER_INDUSTRY, MEMBER_SENIORITY, MEMBER_JOB_TITLE, MEMBER_JOB_FUNCTION, MEMBER_COUNTRY_V2, MEMBER_REGION_V2, MEMBER_COMPANY. The MEMBER_* pivots are what LinkedIn is uniquely good at — job title, seniority and company size are targeting dimensions no other platform reports — and LinkedIn allows exactly ONE pivot per report, so ask for them one at a time and join the answers yourself. An unknown pivot or granularity is refused BY NAME rather than forwarded. On a demographic pivot LinkedIn returns only the top 100 values, DROPS any value under 3 events (so the rows will not sum to the campaign total) and lags 12–24 hours behind the performance numbers — the note says so, every time. Window via since/until (YYYY-MM-DD). ZERO rows genuinely means no delivery in that window; say exactly that and never present zeros as measured performance. A LinkedIn TEST ad account NEVER returns analytics, and the note says so when that is what you are looking at. Read-only, free.",
      "group": "ads",
      "section": "LINKEDIN COMPANY PAGES + ADS (2026-07-30)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "campaignIds": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "since": {
            "description": "YYYY-MM-DD, default 30 days ago",
            "type": "string"
          },
          "until": {
            "description": "YYYY-MM-DD",
            "type": "string"
          },
          "pivot": {
            "description": "CAMPAIGN (default), CAMPAIGN_GROUP, CREATIVE, ACCOUNT, CONVERSION, PLACEMENT_NAME, IMPRESSION_DEVICE_TYPE, SERVING_LOCATION, or one MEMBER_* demographic pivot — an unknown value is refused with the full list",
            "type": "string"
          },
          "granularity": {
            "description": "default ALL",
            "type": "string",
            "enum": [
              "ALL",
              "DAILY",
              "MONTHLY",
              "YEARLY"
            ]
          },
          "fields": {
            "description": "metric names — omit for the standard set (LinkedIn returns ONLY impressions and clicks if none are named)",
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "linkedin_audience_count",
      "title": "How many LinkedIn members a targeting spec reaches",
      "description": "HOW MANY LINKEDIN MEMBERS a targeting spec reaches, before any budget is committed — the cheapest sanity check there is on a B2B audience, and it needs no ad account. Pass locations plus optional include:{titles, industries, seniorities, staffCountRanges, jobFunctions, skills, …}; search_linkedin_ads_targeting resolves any of those names to the URNs LinkedIn demands, free. THE CRITICAL THING TO SAY WHEN REPORTING: a returned total of 0 means the audience is UNDER 300 PEOPLE, not that it is empty — LinkedIn suppresses any count below 300 to protect member privacy, and 300 is also the minimum audience a campaign may run against, so a 0 means this targeting is too narrow to advertise to. The figure is a rounded approximation, so quote it as an estimate and never as a headcount. Read-only, 0 credits.",
      "group": "ads",
      "section": "LINKEDIN PLANNING READS (2026-08-05)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "locations": {
            "description": "geo URNs or bare geo ids, e.g. [\"103644278\"] for the United States",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "include": {
            "description": "more facets ANDed onto locations, e.g. {titles:[…], industries:[…], seniorities:[…], staffCountRanges:[…]}",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "targetingCriteria": {
            "description": "LinkedIn’s raw targeting object — overrides locations/include",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "linkedin_bid_pricing",
      "title": "LinkedIn suggested bid and budget range",
      "description": "LinkedIn’s OWN suggested bid and daily-budget range for a specific audience — the suggested bid with a low/mid/high range, the hard bid limits, and the minimum, default and maximum daily budget, all in the ad account’s currency. Use it before proposing a number to a user instead of guessing what LinkedIn costs, and pair it with linkedin_audience_count to answer \"can we afford this audience?\" in one go. Below LinkedIn’s minimum bid it says delivery \"may be poor\" for Sponsored Update campaigns and is impossible for every other format. These are ESTIMATES for this audience, not prices, and nothing is committed until a campaign is activated with set_linkedin_ads_status(confirm:true). Read-only, 0 credits.",
      "group": "ads",
      "section": "LINKEDIN PLANNING READS (2026-08-05)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "locations": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "include": {
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "targetingCriteria": {
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "campaignType": {
            "description": "default SPONSORED_UPDATES",
            "type": "string",
            "enum": [
              "TEXT_AD",
              "SPONSORED_UPDATES",
              "SPONSORED_INMAILS"
            ]
          },
          "bidType": {
            "description": "default CPM",
            "type": "string",
            "enum": [
              "CPM",
              "CPC",
              "CPV"
            ]
          },
          "matchType": {
            "description": "default EXACT",
            "type": "string",
            "enum": [
              "EXACT",
              "AUDIENCE_EXPANDED"
            ]
          },
          "objectiveType": {
            "description": "optional — LinkedIn prices some objective/optimization combinations and not others",
            "type": "string"
          },
          "currency": {
            "type": "string"
          },
          "dailyBudget": {
            "type": "number"
          },
          "countryCode": {
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_linkedin_ads_campaign_group",
      "title": "Create a LinkedIn campaign group (draft)",
      "description": "Create a LinkedIn CAMPAIGN GROUP — the container LinkedIn has required every campaign to live inside since 2020. Created DRAFT, which is LinkedIn’s own structural safety net: it REFUSES to hold an ACTIVE campaign inside a DRAFT group, so while the group is a draft nothing beneath it can serve whatever its own status says. Creating it ACTIVE removes that protection and therefore requires confirm:true. LinkedIn REQUIRES a run schedule on a campaign group: it starts today unless you pass startDate, and if you set totalBudget you MUST also pass endDate — that pairing is LinkedIn’s own rule and it is refused here before anything is created. Read back from LinkedIn before you are told it exists.",
      "group": "ads",
      "section": "LINKEDIN PLANNING READS (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "name": {
            "type": "string",
            "description": "campaign group name"
          },
          "totalBudget": {
            "description": "optional group-level total budget, in the ad account’s currency",
            "type": "number"
          },
          "currencyCode": {
            "description": "must match the ad account’s currency or LinkedIn refuses it",
            "type": "string"
          },
          "status": {
            "description": "default DRAFT",
            "type": "string",
            "enum": [
              "DRAFT",
              "ACTIVE",
              "PAUSED",
              "ARCHIVED"
            ]
          },
          "startDate": {
            "description": "YYYY-MM-DD — when campaigns under this group may start; defaults to today",
            "type": "string"
          },
          "endDate": {
            "description": "YYYY-MM-DD — REQUIRED by LinkedIn whenever totalBudget is set",
            "type": "string"
          },
          "runSchedule": {
            "description": "LinkedIn raw runSchedule object, passed through; startDate/endDate are easier",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "confirm": {
            "description": "REQUIRED true to create it ACTIVE",
            "type": "boolean"
          }
        },
        "required": [
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_linkedin_ads_campaign",
      "title": "Create a LinkedIn campaign (draft)",
      "description": "Create a LinkedIn campaign inside an existing campaign group. Created DRAFT — it spends NOTHING until activated with set_linkedin_ads_status(confirm:true) — and a campaign on its own carries no creative, so it cannot serve an impression. Budget amounts are in the ad account’s currency; tell the user that LinkedIn may spend UP TO 150% of a daily budget on a high-opportunity day before they pick a number. Two LinkedIn behaviours to repeat rather than hide: on manual, target-cost or cost-cap bidding a unitCost of 0 means the campaign never delivers, and LinkedIn DEFERS some validation on DRAFT objects, so a clean create can still fail at activation — never promise it will run. TARGETING IS MANDATORY on LinkedIn — a campaign with no audience is refused outright — so pass locations (and optionally include/exclude facets like titles, industries, seniorities or staffCountRanges), or a raw targetingCriteria. Resolve every targeting value with search_linkedin_ads_targeting first: they are opaque URNs and MUST NOT be invented. LinkedIn’s own enums for type, objectiveType and costType are passed straight through, and LinkedIn’s refusal is surfaced verbatim if one is wrong. Read back before you are told it exists.",
      "group": "ads",
      "section": "LINKEDIN PLANNING READS (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "campaignGroupId": {
            "type": "string",
            "description": "the campaign group this campaign lives in — LinkedIn requires one"
          },
          "name": {
            "type": "string"
          },
          "type": {
            "description": "LinkedIn campaign type, e.g. SPONSORED_UPDATES",
            "type": "string"
          },
          "objectiveType": {
            "description": "LinkedIn objective, e.g. WEBSITE_VISIT",
            "type": "string"
          },
          "costType": {
            "description": "CPM / CPC / CPV",
            "type": "string"
          },
          "dailyBudget": {
            "type": "number"
          },
          "totalBudget": {
            "type": "number"
          },
          "unitCost": {
            "description": "the bid",
            "type": "number"
          },
          "currencyCode": {
            "type": "string"
          },
          "locale": {
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "country": {
            "description": "campaign locale country, default US — this is the ad UI language market, NOT geo targeting. NEVER derive it from where the ad should run: targeting Canada still uses the US/en locale (LinkedIn refuses en_CA). Leave it alone unless the user explicitly asks for a different interface language.",
            "type": "string"
          },
          "language": {
            "description": "campaign locale language, default en",
            "type": "string"
          },
          "locations": {
            "description": "REQUIRED unless targetingCriteria is given — geo URNs or bare geo ids from search_linkedin_ads_targeting",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "include": {
            "description": "further targeting facets ANDed onto locations, e.g. {titles:[…], industries:[…], seniorities:[…], staffCountRanges:[…]}",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "exclude": {
            "description": "facets to exclude, same shape",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "excludeLocations": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "targetingCriteria": {
            "description": "LinkedIn’s raw targeting object — passed through and overrides locations/include/exclude",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "startDate": {
            "description": "YYYY-MM-DD; defaults to today",
            "type": "string"
          },
          "endDate": {
            "description": "YYYY-MM-DD; omit for an open-ended run",
            "type": "string"
          },
          "runSchedule": {
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "organizationId": {
            "description": "the LinkedIn company Page this campaign advertises — LinkedIn REQUIRES it for Sponsored Content, Dynamic and Lead Gen campaigns",
            "type": "string"
          },
          "format": {
            "type": "string"
          },
          "optimizationTargetType": {
            "type": "string"
          },
          "audienceExpansionEnabled": {
            "type": "boolean"
          },
          "offsiteDeliveryEnabled": {
            "description": "also serve on the LinkedIn Audience Network; default false",
            "type": "boolean"
          },
          "politicalIntent": {
            "type": "string",
            "enum": [
              "POLITICAL",
              "NOT_POLITICAL",
              "NOT_DECLARED"
            ]
          },
          "status": {
            "description": "default DRAFT",
            "type": "string",
            "enum": [
              "DRAFT",
              "ACTIVE",
              "PAUSED",
              "ARCHIVED"
            ]
          },
          "confirm": {
            "description": "REQUIRED true to create it ACTIVE under a LIVE campaign group",
            "type": "boolean"
          }
        },
        "required": [
          "campaignGroupId",
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_linkedin_ads_budget",
      "title": "Set a LinkedIn campaign budget",
      "description": "Change a LinkedIn campaign’s daily and/or total budget. On a LIVE (ACTIVE) campaign this changes real spend immediately — and LinkedIn can spend up to 150% of a daily budget on a high-opportunity day — so show the user the new amount, get an explicit yes, then call with confirm:true. The currency must match the ad account’s. Read back after the change.",
      "group": "ads",
      "section": "LINKEDIN PLANNING READS (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "campaignId": {
            "type": "string"
          },
          "dailyBudget": {
            "type": "number"
          },
          "totalBudget": {
            "type": "number"
          },
          "currencyCode": {
            "type": "string"
          },
          "confirm": {
            "description": "REQUIRED true to change the budget of a LIVE campaign",
            "type": "boolean"
          }
        },
        "required": [
          "campaignId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_linkedin_ads_status",
      "title": "Activate or pause a LinkedIn campaign group / campaign / ad",
      "description": "Turn a LinkedIn campaign group, campaign or CREATIVE (the ad itself) on (ACTIVE) or off (PAUSED). Pass level:\"campaign\" + campaignId, level:\"campaignGroup\" + campaignGroupId, or level:\"creative\" + creativeId. All three tiers must be ACTIVE for an ad to serve — activating only the campaign leaves a DRAFT creative sitting there showing nothing. ACTIVATING STARTS REAL AD SPEND — you MUST first show the user the campaign and its budget, get an explicit yes, then call with status:\"ACTIVE\" and confirm:true. Pausing is always safe. The resulting status is READ BACK from LinkedIn along with its servingStatuses before you are told it took: LinkedIn defers validation on drafts, so activation is exactly where a hidden problem surfaces, and the note reports what LinkedIn actually says rather than what was requested.",
      "group": "ads",
      "section": "LINKEDIN PLANNING READS (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "level": {
            "description": "default campaign",
            "type": "string",
            "enum": [
              "campaign",
              "campaignGroup",
              "creative"
            ]
          },
          "campaignId": {
            "type": "string"
          },
          "campaignGroupId": {
            "type": "string"
          },
          "creativeId": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "ACTIVE",
              "PAUSED",
              "ARCHIVED",
              "DRAFT"
            ]
          },
          "confirm": {
            "description": "REQUIRED true to set ACTIVE (real spend)",
            "type": "boolean"
          }
        },
        "required": [
          "status"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "search_linkedin_ads_targeting",
      "title": "Find LinkedIn targeting URNs",
      "description": "Look up LinkedIn TARGETING entities by name and get their URNs — locations, job titles, industries, seniorities, company sizes, skills, job functions, interests, employers, degrees, fields of study, member behaviours. LinkedIn’s targeting values are opaque URNs (urn:li:geo:103644278 is the United States) with no guessable form, so ALWAYS resolve an audience here before passing it to create_linkedin_ads_campaign, and NEVER invent a URN — a made-up one either 400s or, worse, targets somebody else. If nothing matches, say so plainly. Read-only, free.",
      "group": "ads",
      "section": "LINKEDIN PLANNING READS (2026-08-05)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "facet": {
            "description": "facet name, default \"locations\" — e.g. locations, titles, industries, seniorities, staffCountRanges, skills, jobFunctions, interests, employers, degrees, fieldsOfStudy, memberBehaviors",
            "type": "string"
          },
          "query": {
            "type": "string",
            "description": "the name to search for, e.g. \"United States\", \"Software Engineer\", \"Marketing\""
          },
          "language": {
            "description": "default en",
            "type": "string"
          },
          "country": {
            "description": "default US",
            "type": "string"
          }
        },
        "required": [
          "query"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_linkedin_ads_creative",
      "title": "Create a LinkedIn ad (creative, draft)",
      "description": "Create the AD ITSELF on LinkedIn — a CREATIVE inside an existing campaign. A LinkedIn campaign holds no copy and no visual, so until this runs the campaign CANNOT show an impression no matter what its status says; say that rather than calling a campaign \"live\". Two ways in: pass postUrn to sponsor a post that already exists (LinkedIn’s \"boost this post\"), or pass text and/or imageUrl / videoUrl to author a DIRECT SPONSORED CONTENT post — a real post by a company Page the user administers that is NEVER shown on the Page’s feed and exists only as an ad. Created DRAFT: it spends nothing until you activate it with set_linkedin_ads_status(level:\"creative\", status:\"ACTIVE\", confirm:true). Creating it ACTIVE under an already-live campaign starts REAL AD SPEND on the very next auction and therefore requires confirm:true. The whole tree — group, campaign and every creative — is read back from LinkedIn before you are told anything exists.",
      "group": "ads",
      "section": "LINKEDIN PLANNING READS (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "campaignId": {
            "type": "string",
            "description": "the campaign this ad belongs to"
          },
          "name": {
            "description": "the creative’s name in Campaign Manager",
            "type": "string"
          },
          "postUrn": {
            "description": "sponsor an EXISTING post — urn:li:share:… / urn:li:ugcPost:… (what post_to_linkedin_page returned)",
            "type": "string"
          },
          "organizationId": {
            "description": "the company Page that authors the Direct Sponsored Content post; omit only when the connection administers exactly one Page",
            "type": "string"
          },
          "text": {
            "description": "the ad copy",
            "type": "string"
          },
          "imageUrl": {
            "description": "a Hermoso-hosted image to attach — a render, or the user’s OWN creative put through upload_file first (an arbitrary external host is refused)",
            "type": "string"
          },
          "videoUrl": {
            "description": "a Hermoso-hosted video to attach — a render, or the user’s own footage via upload_file",
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "altText": {
            "type": "string"
          },
          "allowReshare": {
            "type": "boolean"
          },
          "intendedStatus": {
            "description": "default DRAFT",
            "type": "string",
            "enum": [
              "DRAFT",
              "ACTIVE",
              "PAUSED",
              "ARCHIVED"
            ]
          },
          "confirm": {
            "description": "REQUIRED true to create it ACTIVE under a LIVE campaign (real spend)",
            "type": "boolean"
          }
        },
        "required": [
          "campaignId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_linkedin_ads_object",
      "title": "Delete a LinkedIn campaign group / campaign / ad",
      "description": "Delete a LinkedIn campaign group, campaign or creative (level:\"creative\" + creativeId). LinkedIn HARD-deletes only DRAFT objects; anything that has ever run is moved to PENDING_DELETION instead — it stops serving and its reporting history is retained. The returned note says which of the two actually happened, and you must repeat that rather than claiming a clean delete. Irreversible either way, so it requires confirm:true.",
      "group": "ads",
      "section": "LINKEDIN PLANNING READS (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "adAccountId": {
            "type": "string"
          },
          "level": {
            "description": "default campaign",
            "type": "string",
            "enum": [
              "campaign",
              "campaignGroup",
              "creative"
            ]
          },
          "campaignId": {
            "type": "string"
          },
          "campaignGroupId": {
            "type": "string"
          },
          "creativeId": {
            "type": "string"
          },
          "confirm": {
            "type": "boolean",
            "description": "REQUIRED true — this is irreversible"
          }
        },
        "required": [
          "confirm"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_meta_object",
      "title": "Edit a Meta campaign / ad set / ad",
      "description": "Update an EXISTING campaign, ad set, or ad — rename, change its daily budget, retarget (ad sets), or change status (PAUSED / ACTIVE / ARCHIVED). Pass objectId (from list_meta_ads) + adAccountId. Setting something ACTIVE can start REAL AD SPEND — show the user what will run + its budget, get a yes, then pass confirm:true. Pausing / renaming / archiving is always safe.",
      "group": "ads",
      "section": "LINKEDIN PLANNING READS (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "objectId": {
            "type": "string",
            "description": "the campaign / ad set / ad id (from list_meta_ads)"
          },
          "adAccountId": {
            "type": "string",
            "description": "ad account id (for auth + scope)"
          },
          "name": {
            "description": "new name",
            "type": "string"
          },
          "status": {
            "description": "ACTIVE starts spend (needs confirm:true); PAUSED / ARCHIVED are safe",
            "type": "string",
            "enum": [
              "ACTIVE",
              "PAUSED",
              "ARCHIVED"
            ]
          },
          "dailyBudgetUsd": {
            "description": "new daily budget in USD (1–10000; ad-set or campaign level)",
            "type": "number"
          },
          "targeting": {
            "description": "replacement targeting spec (ad sets) — a Meta targeting object",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {}
          },
          "confirm": {
            "description": "REQUIRED true ONLY to set status ACTIVE (real spend)",
            "type": "boolean"
          }
        },
        "required": [
          "objectId",
          "adAccountId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_meta_object",
      "title": "Delete a Meta campaign / ad set / ad",
      "description": "PERMANENTLY delete a campaign, ad set, or ad. Pass objectId (from list_meta_ads) + adAccountId. DELETING A CAMPAIGN ALSO DELETES EVERY AD SET AND AD UNDER IT, and deleting an ad set deletes its ads — one id, the whole tree. Call it WITHOUT confirm first: it reports what the object is, its name, and how many children go with it. Show the user that, get an unambiguous yes, then call again with confirm:true plus confirmChildren set to the number it reported (only needed when there is at least one child). To stop delivery without deleting anything, use update_meta_object(status:\"PAUSED\") instead — that is reversible and this is not.",
      "group": "ads",
      "section": "LINKEDIN PLANNING READS (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "objectId": {
            "type": "string",
            "description": "the campaign / ad set / ad id to delete"
          },
          "adAccountId": {
            "type": "string",
            "description": "ad account id (for auth + scope)"
          },
          "confirm": {
            "description": "REQUIRED true — deletion is permanent",
            "type": "boolean"
          },
          "confirmChildren": {
            "description": "the number of child ad sets + ads this delete also destroys, as reported by the unconfirmed call — required whenever that is above zero",
            "type": "number"
          }
        },
        "required": [
          "objectId",
          "adAccountId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "manage_meta_post",
      "title": "Edit or delete a published post",
      "description": "Edit the text of, or delete, a post you published with post_to_meta. target:\"facebook\" → edit the message (action:\"edit\", message:…) OR delete (action:\"delete\"); target:\"threads\" → delete only (Threads has no edit API); Instagram posts can’t be edited or deleted via the API. Deleting is permanent — confirm with the user, then pass confirm:true.",
      "group": "ads",
      "section": "LINKEDIN PLANNING READS (2026-08-05)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "postId": {
            "type": "string",
            "description": "the post id returned by post_to_meta"
          },
          "action": {
            "type": "string",
            "enum": [
              "edit",
              "delete"
            ],
            "description": "edit the text (FB only) or delete the post"
          },
          "target": {
            "description": "default facebook",
            "type": "string",
            "enum": [
              "facebook",
              "threads",
              "instagram"
            ]
          },
          "message": {
            "description": "the new post text (action:\"edit\" on facebook)",
            "type": "string"
          },
          "confirm": {
            "description": "REQUIRED true to delete (permanent)",
            "type": "boolean"
          }
        },
        "required": [
          "postId",
          "action"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "save_to_drive",
      "title": "Save file(s) to Google Drive",
      "description": "Save a Hermoso render — or ANY file — into the user’s connected Google Drive. Pass a Hermoso render URL as url (or urls[] for several); for a local/external file, call upload_file first and pass the url it returns. Optional folder (created if new) + name. Returns the Drive file(s) with a webViewLink. Needs Google Drive connected (Settings ▸ Connectors ▸ Google Drive — one connection covers Drive, Sheets and Docs). NOTE: Hermoso uses the drive.file scope, so it reaches ONLY the files it created plus any the user explicitly handed over with the Google file picker in the app — never their whole Drive.",
      "group": "files",
      "section": "Google Drive: full CRUD over the files Hermoso created in the user's Drive (drive.file scope)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "url": {
            "description": "a single Hermoso render URL to save",
            "type": "string"
          },
          "urls": {
            "description": "several render URLs (up to 20) to save in one call",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "folder": {
            "description": "Drive folder name to save into (created if new)",
            "type": "string"
          },
          "name": {
            "description": "file name (single save)",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_drive_files",
      "title": "List Google Drive files",
      "description": "List the Google Drive files & folders Hermoso can reach — the ones it created, plus any the user handed over with the Google file picker in the app (the drive.file scope exposes nothing else, never their entire Drive). This is how you find the id of a file the user picked. Filter by query (name contains …), folderId (contents of a folder), or onlyFolders:true. Paginate with pageToken. Read-only.",
      "group": "files",
      "section": "Google Drive: full CRUD over the files Hermoso created in the user's Drive (drive.file scope)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "query": {
            "description": "only files whose name contains this",
            "type": "string"
          },
          "folderId": {
            "description": "list the contents of this folder id",
            "type": "string"
          },
          "onlyFolders": {
            "description": "list folders only",
            "type": "boolean"
          },
          "pageSize": {
            "description": "rows per page (1–200, default 50)",
            "type": "number"
          },
          "pageToken": {
            "description": "cursor from a previous call",
            "type": "string"
          },
          "includeTrashed": {
            "description": "include trashed files (default false)",
            "type": "boolean"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "get_drive_file",
      "title": "Get a Drive file’s details",
      "description": "Fetch one Drive file’s metadata — name, type, size, modified time, a webViewLink to open it and a webContentLink to download it. Pass fileId (from list_drive_files). Read-only.",
      "group": "files",
      "section": "Google Drive: full CRUD over the files Hermoso created in the user's Drive (drive.file scope)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "fileId": {
            "type": "string",
            "description": "the Drive file id (from list_drive_files)"
          }
        },
        "required": [
          "fileId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_drive_file",
      "title": "Rename / move / trash a Drive file",
      "description": "Update a Drive file: rename (name), move it into a folder (moveToFolderId, optionally removeFromFolderId to move OUT of the old one), or trash / untrash it (trash:true|false). Pass fileId (from list_drive_files). To delete permanently, use delete_drive_file.",
      "group": "files",
      "section": "Google Drive: full CRUD over the files Hermoso created in the user's Drive (drive.file scope)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "fileId": {
            "type": "string",
            "description": "the Drive file id"
          },
          "name": {
            "description": "new name",
            "type": "string"
          },
          "moveToFolderId": {
            "description": "folder id to move the file into (from create_drive_folder / list_drive_files)",
            "type": "string"
          },
          "removeFromFolderId": {
            "description": "the old parent folder id to remove (when moving)",
            "type": "string"
          },
          "trash": {
            "description": "true → move to Trash; false → restore from Trash",
            "type": "boolean"
          }
        },
        "required": [
          "fileId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_drive_file",
      "title": "Delete a Drive file",
      "description": "Delete a Drive file. By default it goes to Trash (recoverable); pass permanent:true to delete it forever. Pass fileId (from list_drive_files) + confirm:true. Irreversible when permanent — confirm with the user first.",
      "group": "files",
      "section": "Google Drive: full CRUD over the files Hermoso created in the user's Drive (drive.file scope)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "fileId": {
            "type": "string",
            "description": "the Drive file id"
          },
          "permanent": {
            "description": "true = delete forever; default trashes (recoverable)",
            "type": "boolean"
          },
          "confirm": {
            "description": "REQUIRED true",
            "type": "boolean"
          }
        },
        "required": [
          "fileId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_drive_folder",
      "title": "Create a Drive folder",
      "description": "Create a folder in the user’s Google Drive (optionally nested under parentId) to organize saved files. Returns the folder id + webViewLink. Use that ID as update_drive_file’s moveToFolderId or as parentId for a nested folder. NOTE: save_to_drive’s `folder` is a NAME, not this id — it find-or-creates a folder by that name, so pass the folder NAME there (or omit and just save, then move with update_drive_file).",
      "group": "files",
      "section": "Google Drive: full CRUD over the files Hermoso created in the user's Drive (drive.file scope)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "folder name"
          },
          "parentId": {
            "description": "parent folder id for a nested folder (default: Drive root)",
            "type": "string"
          }
        },
        "required": [
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_sheet",
      "title": "Create a Google Sheet",
      "description": "Create a new Google Spreadsheet in the user’s Drive and optionally fill it with rows — e.g. export a swipefile, ad list, or performance report. Pass rows as an array of row arrays (first row = headers). Returns the spreadsheet id + URL. Needs Google Drive connected (Settings ▸ Connectors ▸ Google Drive — one connection covers Drive, Sheets and Docs).",
      "group": "files",
      "section": "Google Sheets: export structured data to a spreadsheet the app creates (drive.file scope)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "title": {
            "description": "spreadsheet title",
            "type": "string"
          },
          "rows": {
            "description": "rows to write — array of row arrays; first row = headers",
            "type": "array",
            "items": {
              "type": "array",
              "items": {
                "anyOf": [
                  {
                    "type": "string"
                  },
                  {
                    "type": "number"
                  },
                  {
                    "type": "boolean"
                  }
                ]
              }
            }
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "append_to_sheet",
      "title": "Append rows to a Google Sheet",
      "description": "Append rows to a Google Sheet Hermoso can reach — one it created (pass the spreadsheetId from create_sheet) or one the user handed over with the Google file picker in the app (find its id with list_drive_files). rows = array of row arrays.",
      "group": "files",
      "section": "Google Sheets: export structured data to a spreadsheet the app creates (drive.file scope)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "spreadsheetId": {
            "type": "string",
            "description": "the spreadsheet id from create_sheet"
          },
          "rows": {
            "type": "array",
            "items": {
              "type": "array",
              "items": {
                "anyOf": [
                  {
                    "type": "string"
                  },
                  {
                    "type": "number"
                  },
                  {
                    "type": "boolean"
                  }
                ]
              }
            },
            "description": "rows to append — array of row arrays"
          },
          "range": {
            "description": "range to append at (default A1 / first sheet)",
            "type": "string"
          }
        },
        "required": [
          "spreadsheetId",
          "rows"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "read_sheet",
      "title": "Read a Google Sheet range",
      "description": "Read cells from a Google Sheet Hermoso can reach — one it created, or one the user handed over with the Google file picker in the app (that is how an EXISTING spreadsheet becomes readable; find its id with list_drive_files). Pass the spreadsheetId (from create_sheet) OR paste a Google Sheets URL as sheetUrl. If Google answers that the file was not found, the user has not picked it yet — ask them to pick it in the app rather than retrying. Returns a 2-D array of values.",
      "group": "files",
      "section": "Google Sheets: export structured data to a spreadsheet the app creates (drive.file scope)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "spreadsheetId": {
            "description": "the spreadsheet id (from create_sheet)",
            "type": "string"
          },
          "sheetUrl": {
            "description": "a Google Sheets URL to read — the spreadsheet id is extracted from it",
            "type": "string"
          },
          "range": {
            "description": "A1 range, e.g. \"A1:D50\" (default A1:Z1000)",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_doc",
      "title": "Create a Google Doc",
      "description": "Create a new Google Doc in the user’s Drive with a title + optional body text — e.g. export ad copy, a creative brief, or a report. Returns the document id + URL. Needs Google Drive connected (Settings ▸ Connectors ▸ Google Drive — one connection covers Drive, Sheets and Docs).",
      "group": "files",
      "section": "Google Docs: export copy / brief / report as a doc the app creates (drive.file scope)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "title": {
            "description": "document title",
            "type": "string"
          },
          "text": {
            "description": "body text to insert",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "append_to_doc",
      "title": "Append text to a Google Doc",
      "description": "Append text to the end of a Google Doc Hermoso can reach — one it created (pass the documentId from create_doc) or one the user handed over with the Google file picker in the app (find its id with list_drive_files).",
      "group": "files",
      "section": "Google Docs: export copy / brief / report as a doc the app creates (drive.file scope)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "documentId": {
            "type": "string",
            "description": "the document id from create_doc"
          },
          "text": {
            "type": "string",
            "description": "text to append at the end of the doc"
          }
        },
        "required": [
          "documentId",
          "text"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "read_doc",
      "title": "Read a Google Doc",
      "description": "Read the text of a Google Doc Hermoso can reach — one it created, or one the user handed over with the Google file picker in the app (that is how an EXISTING doc becomes readable; find its id with list_drive_files). Pass documentId (from create_doc) OR paste a Google Docs URL as docUrl. Under the drive.file scope it reaches nothing else in the user’s Drive; if Google answers that the file was not found, the user has not picked it yet — ask them to pick it in the app rather than retrying. Returns the plain text. Read-only, free.",
      "group": "files",
      "section": "Google Docs: export copy / brief / report as a doc the app creates (drive.file scope)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "documentId": {
            "description": "the document id (from create_doc)",
            "type": "string"
          },
          "docUrl": {
            "description": "a Google Docs URL to read — the document id is extracted from it",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "save_to_onedrive",
      "title": "Save file(s) to OneDrive",
      "description": "Save a Hermoso render — or ANY file — into the user’s connected Microsoft OneDrive. Pass a Hermoso render URL as url (or urls[] for several); for a local/external file, call upload_file first and pass the url it returns. Optional folder (created if new) + name. Returns the OneDrive file(s) with a webViewLink. Needs OneDrive connected (Settings ▸ Connectors ▸ OneDrive).",
      "group": "files",
      "section": "Microsoft OneDrive: full CRUD over the user's OneDrive (Files.ReadWrite)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "url": {
            "description": "a single Hermoso render URL to save",
            "type": "string"
          },
          "urls": {
            "description": "several render URLs (up to 20) to save in one call",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "folder": {
            "description": "OneDrive folder name to save into (created if new)",
            "type": "string"
          },
          "name": {
            "description": "file name (single save)",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_onedrive_files",
      "title": "List OneDrive files",
      "description": "List files & folders in the user’s OneDrive — the root by default, a folder’s contents (folderId), or a name search (query). onlyFolders:true lists folders only. Paginate with pageToken (the cursor from a previous call). Read-only.",
      "group": "files",
      "section": "Microsoft OneDrive: full CRUD over the user's OneDrive (Files.ReadWrite)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "query": {
            "description": "search — only items whose name matches this",
            "type": "string"
          },
          "folderId": {
            "description": "list the contents of this folder id",
            "type": "string"
          },
          "onlyFolders": {
            "description": "list folders only",
            "type": "boolean"
          },
          "pageSize": {
            "description": "rows per page (1–200, default 50)",
            "type": "number"
          },
          "pageToken": {
            "description": "cursor from a previous call",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "get_onedrive_file",
      "title": "Get a OneDrive file’s details",
      "description": "Fetch one OneDrive item’s metadata — name, type, size, modified time, a webViewLink to open it and a webContentLink to download it. Pass fileId (from list_onedrive_files). Read-only.",
      "group": "files",
      "section": "Microsoft OneDrive: full CRUD over the user's OneDrive (Files.ReadWrite)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "fileId": {
            "type": "string",
            "description": "the OneDrive item id (from list_onedrive_files)"
          }
        },
        "required": [
          "fileId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_onedrive_file",
      "title": "Rename / move a OneDrive file",
      "description": "Update a OneDrive item: rename (name) and/or move it into a folder (moveToFolderId). Pass fileId (from list_onedrive_files). To remove an item, use delete_onedrive_file.",
      "group": "files",
      "section": "Microsoft OneDrive: full CRUD over the user's OneDrive (Files.ReadWrite)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "fileId": {
            "type": "string",
            "description": "the OneDrive item id"
          },
          "name": {
            "description": "new name",
            "type": "string"
          },
          "moveToFolderId": {
            "description": "folder id to move the item into (from create_onedrive_folder / list_onedrive_files)",
            "type": "string"
          }
        },
        "required": [
          "fileId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_onedrive_file",
      "title": "Delete a OneDrive file",
      "description": "Delete a OneDrive item — it moves to the OneDrive recycle bin (recoverable there). Pass fileId (from list_onedrive_files) + confirm:true. Confirm the exact file with the user first.",
      "group": "files",
      "section": "Microsoft OneDrive: full CRUD over the user's OneDrive (Files.ReadWrite)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "fileId": {
            "type": "string",
            "description": "the OneDrive item id"
          },
          "confirm": {
            "description": "REQUIRED true",
            "type": "boolean"
          }
        },
        "required": [
          "fileId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "convert_onedrive_file",
      "title": "Convert a OneDrive file to PDF or JPG",
      "description": "Turn a file already in the user’s OneDrive into a PDF or a JPG — Microsoft does the conversion on its own servers, so nothing is re-encoded here and nothing is lost in a screenshot. It reads about 130 source formats, which is the point: PowerPoint and Word decks, Excel, Photoshop PSD, Illustrator AI, Sketch, 3D (fbx/glb/obj), video (mp4/mov/webm), HEIC from an iPhone, and the raw camera formats (CR2, NEF, ARW, DNG) that nothing else in this product can open. Use it to turn a client’s deck into images you can actually put in an ad, to get a usable JPG out of a designer’s PSD or a photographer’s raw file, or to hand someone a PDF of a spreadsheet. CONVERTING TO JPG REQUIRES BOTH width AND height — Microsoft refuses the call without them. The result is stored at a durable Hermoso URL you can pass straight to a render or a post; Microsoft’s own conversion link expires within minutes, so do not hand that one to anyone. Needs OneDrive connected — no new permission.",
      "group": "files",
      "section": "Microsoft OneDrive: full CRUD over the user's OneDrive (Files.ReadWrite)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "fileId": {
            "type": "string",
            "description": "the OneDrive item id, from list_onedrive_files"
          },
          "format": {
            "description": "default pdf",
            "type": "string",
            "enum": [
              "pdf",
              "jpg"
            ]
          },
          "width": {
            "description": "REQUIRED for jpg — output width in pixels",
            "type": "number"
          },
          "height": {
            "description": "REQUIRED for jpg — output height in pixels",
            "type": "number"
          }
        },
        "required": [
          "fileId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "create_onedrive_folder",
      "title": "Create a OneDrive folder",
      "description": "Create a folder in the user’s OneDrive (optionally nested under parentId) to organize saved files. Returns the folder id + webViewLink. Use that id as update_onedrive_file’s moveToFolderId or as parentId for a nested folder. NOTE: save_to_onedrive’s `folder` is a NAME (find-or-created), not this id.",
      "group": "files",
      "section": "Microsoft OneDrive: full CRUD over the user's OneDrive (Files.ReadWrite)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "folder name"
          },
          "parentId": {
            "description": "parent folder id for a nested folder (default: OneDrive root)",
            "type": "string"
          }
        },
        "required": [
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "plan_ad",
      "title": "Plan an ad concept",
      "description": "Creative director: turn a brand + product/brief into a finished ad CONCEPT — copy variants (headline/primary/cta) plus an image_concept.prompt OR a video_storyboard, with the resolved recipe + the model ids to render with. Renders nothing; chain its output into generate_image / generate_video. THE USER’S EXPLICIT LENGTH IS SOVEREIGN: when they name a duration (\"a 30 second ad\", \"make it 45s\"), pass it as durationSeconds — the board is then AUTHORED to that length (its scenes sum to it) and render_ad renders it as one clip or stitched acts accordingly. Leaving it out lets the planner pick its own default, which is how an explicit ask silently becomes a 15s spot. Spends LLM tokens, 0 ScrapeCreators credits.",
      "group": "create",
      "section": "planning (LLM, 0 SC credits)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "brand": {
            "description": "brand name, or a brand profile object {name,domain,category,palette,products,…}. OMIT to use the workspace’s SAVED brand + memory automatically (see get_brand); use draft_brand to onboard a new one",
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "object",
                "properties": {},
                "additionalProperties": {}
              }
            ]
          },
          "product": {
            "type": "string",
            "description": "what to advertise + any angle/offer the user specified"
          },
          "format": {
            "description": "'image', 'video', or 'auto' when unspecified",
            "type": "string",
            "enum": [
              "auto",
              "image",
              "video"
            ]
          },
          "durationSeconds": {
            "description": "VIDEO ONLY — the total spot length the user explicitly asked for, in seconds, copied verbatim (30 for \"a 30 second ad\"). The planner authors the storyboard TO it: the scenes’ seconds sum to it and the script is word-budgeted for it. Supported range 4–180; anything outside is CLAMPED to it (the reply says so). One model clip caps at 15s, so ≤15 renders as a single continuous pass and anything longer is STITCHED from acts filled to 15s with the remainder last (40 → 15+15+10, 17 → 13+4) — never time-compressed. Omit when the user named no length; do NOT pass a guess, an omitted value keeps the recipe-aware default.",
            "type": "number"
          },
          "hook": {
            "description": "force the VISUAL scroll-stop mechanic the opening beat is built on — a hook id from list_hooks (e.g. \"direct_callout\", \"mid_problem\", \"macro_asmr\"). Omit to let the planner pick. A hook that cannot be delivered in this brief is DROPPED with the reason rather than rendered wrongly — an on-screen-text hook on an authentic/UGC ad is the one that bites, because that register carries zero on-screen text.",
            "type": "string"
          },
          "setting": {
            "description": "force the WHERE — a setting id from list_hooks (e.g. \"kitchen\", \"gym\", or a surreal one like \"volcano_rim\" / \"airplane_wing\", which are played 100% straight and never acknowledged). Omit for a neutral setting.",
            "type": "string"
          },
          "recipe": {
            "description": "a recipe id from hermoso_capabilities to force an archetype",
            "type": "string"
          },
          "reference": {
            "description": "a reference ad URL to remix the angle from — Facebook Ad Library, LinkedIn Ad Library or Google Ads Transparency links (the real ad’s copy/advertiser are fetched and fed into the concept)",
            "type": "string"
          },
          "language": {
            "description": "output language for the ad copy (e.g. Spanish) — default English",
            "type": "string"
          }
        },
        "required": [
          "product"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "generate_image",
      "title": "Generate ad image",
      "description": "Render a finished ad IMAGE and return its served URL. refImages (local paths or URLs) force product-accurate compositing (drops a real product into the scene). MULTI-BRAND CAUTION: useBrand hydration pulls the SAVED workspace brand — when working a brand that is NOT the saved one (a fresh draft_brand), pass that brand's own productImages/logo as refImages (and useBrand:false) or the output composites the WRONG brand's product. model = a catalog id from hermoso_capabilities (omit for the default). Fast (seconds). Spends credits.",
      "group": "create",
      "section": "image (synchronous)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "prompt": {
            "type": "string",
            "description": "the full image prompt — subject, composition, lighting, and any on-image ad text"
          },
          "refImages": {
            "description": "local file paths or URLs of product/logo references to composite in",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "useBrand": {
            "description": "default true: with no refImages, the server hydrates the SAVED brand’s product/logo references so the output lands on-brand; pass false for a pure prompt-only render",
            "type": "boolean"
          },
          "aspectRatio": {
            "description": "e.g. '1:1', '9:16', '16:9'",
            "type": "string"
          },
          "model": {
            "description": "image model id from hermoso_capabilities",
            "type": "string"
          },
          "imageSize": {
            "description": "pixel-size preset for models that support it (e.g. 1K/2K) — omit for the default",
            "type": "string"
          }
        },
        "required": [
          "prompt"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "make_thumbnail",
      "title": "Make video thumbnail",
      "description": "Render a click-driving YOUTUBE / Shorts / Instagram THUMBNAIL or video cover — the full production pipeline (concept framework → casting → scene → render → surgical tweaks → text), not a bare image prompt. Use this for any \"thumbnail\", \"video cover\", \"video preview\" or MrBeast-style packaging ask INSTEAD of generate_image. About 9 credits per variant; the headline overlay is free.\n\nCONCEPT — every thumbnail must open an INFORMATION GAP (the image raises a question the title answers) while staying truthful to the video. Brainstorm ≥5 concepts across the 16 frameworks before you pick, and feel free to combine two. Frameworks (pass as `framework`): before_after · social_ui · three_step · screenshot · posed_portrait (the default) · posed_action · specific_day · graphical · landscape · map_aerial · product · adding_text · repetition · size_difference · news_clip · amplified_reality. Call hermoso_capabilities for each one's full 'realize it with' note plus the emotion, overlay-style, font and rim-colour catalogs.\n\nTHREE GATES, all BEFORE you render:\n1. WHO IS IN FRAME — never assume and never silently substitute a stranger. If the framework puts a person in frame and no face photo is attached, the tool refuses (nothing rendered, nothing charged) and tells you to ask the user once: themselves (send a face photo → the identity gets locked), a generated person (`castGenericPerson:true`), or a people-free framework.\n2. TEXT — the default is a CLEAN render with the headline TYPESET OVER THE TOP afterwards (free, always legible, correctly spelled). Just pass `headline`. Only set `bakeText:true` if the user explicitly asks for the words painted INTO the image — verified live, that renders the asked-for words correctly but leaks garbled invented text across the rest of the frame. Never infer text intent from the topic or the framework.\n3. HOW MANY — ask once whether they want one thumbnail or a SET (offer 4: the same concept at different emotions and/or camera takes). Default is 1; `variants` caps at 16.\n\nIDENTITY LOCK is automatic for every attached face photo. `emotion` is the single biggest CTR lever on a face: shock · hype · fear · confusion · determination · smug · charisma · disgust · awe · rage · laugh (or your own phrase). Finished thumbnail needs a fix? Re-call with `tweak` + `sourceImage` for a surgical, pixel-faithful edit (emotion / background / background_color / rim_light) instead of re-rendering — tweaks chain. ALWAYS check the returned postRenderCheck against the image before you present it.\n\nPROMPT LANGUAGE — write every DESCRIPTIVE field in ENGLISH (`sceneBrief`, `keyElements`, `location`, `composition`, `background`, `topic`, each person's `describe`, and every `reference` field), translating the user's wording where needed: the image models are trained on English and a non-English scene description renders noticeably worse. Text that gets BAKED OR TYPESET stays verbatim in the user's own language — `headline`, `headlineLines` and `bakedUiText` are never translated.",
      "group": "create",
      "section": "YouTube / social thumbnails + video covers",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "framework": {
            "description": "concept framework id (default 'posed_portrait'); see the list in this description / hermoso_capabilities",
            "type": "string"
          },
          "frameworkRequested": {
            "description": "true ONLY when the USER named this framework — it is what authorizes a text-carrying framework (social_ui / news_clip / specific_day / map_aerial) to bake its short UI label",
            "type": "boolean"
          },
          "sceneBrief": {
            "description": "what the thumbnail depicts — the concept in one dense sentence, rendered exactly",
            "type": "string"
          },
          "topic": {
            "description": "the video's topic — used to pick the hero object when you don't name keyElements",
            "type": "string"
          },
          "headline": {
            "description": "2–4 word headline. Typeset OVER the finished render by default (free, always legible); newlines split it into stacked lines",
            "type": "string"
          },
          "headlineLines": {
            "description": "explicit headline lines (up to 3) — overrides splitting `headline` on newlines",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "bakeText": {
            "description": "default false. true paints the headline INTO the generation — only on an explicit user ask; it leaks garbled text elsewhere in the frame",
            "type": "boolean"
          },
          "bakedUiText": {
            "description": "short label for a text-carrying framework (a chat bubble, a DAY N badge, a news lower-third, a map callout) — needs frameworkRequested:true",
            "type": "string"
          },
          "overlayStyle": {
            "description": "headline style: 'beast' (default, white + heavy black stroke) / 'fire' / 'neon-lime' / 'clean-glass' / 'marker'",
            "type": "string"
          },
          "font": {
            "description": "headline font (default Anton). Alternatives incl. Bebas Neue, Oswald, Archivo Black, Montserrat, Inter, Playfair Display",
            "type": "string"
          },
          "headlinePlace": {
            "description": "where the headline sits — never over the face (default 'bottom')",
            "type": "string",
            "enum": [
              "bottom",
              "top",
              "center"
            ]
          },
          "faceImages": {
            "description": "up to 3 face photos (URLs or local paths) — each becomes a locked CHARACTER identity, in order",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "people": {
            "description": "people described in prose instead of by photo (each still gets the chosen expression)",
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "describe": {
                  "type": "string"
                }
              },
              "required": [
                "describe"
              ],
              "additionalProperties": {}
            }
          },
          "castGenericPerson": {
            "description": "pass true only after the user has explicitly chosen a generated stranger over their own face",
            "type": "boolean"
          },
          "emotion": {
            "description": "the expression on the face (default 'shock') — a preset id or your own phrase",
            "type": "string"
          },
          "emotions": {
            "description": "render one variant per emotion (variants = emotions × takes, max 16)",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "takes": {
            "description": "camera takes per emotion, 1–4: designed framing / low-angle hero / extreme close-up / wide dutch tilt",
            "type": "number"
          },
          "variants": {
            "description": "how many thumbnails to render (default 1, max 16). Each is its own billed render — offer a set of 4 rather than assuming",
            "type": "number"
          },
          "aspectRatio": {
            "description": "'16:9' (YouTube, default) / '9:16' (Shorts) / '4:5' (Instagram) / '4:3' / '1:1'",
            "type": "string"
          },
          "keyElements": {
            "description": "signature props / effects that make it pop — oversized, flying toward camera",
            "type": "string"
          },
          "location": {
            "description": "place, time of day, weather, atmosphere",
            "type": "string"
          },
          "composition": {
            "description": "override the default large-foreground-subject composition",
            "type": "string"
          },
          "background": {
            "description": "override the default bold saturated colour-field background",
            "type": "string"
          },
          "rimColor": {
            "description": "colored back+hair light — ONLY when the user names one: 'ice-blue' / 'neon-magenta' / 'toxic-lime' / 'amber-gold' / 'pure-white'",
            "type": "string"
          },
          "restrainedGrade": {
            "description": "true for a calm / premium / muted look instead of the default punchy poster grade",
            "type": "boolean"
          },
          "logo": {
            "description": "a brand logo URL or path to place into the composition",
            "type": "string"
          },
          "logo3d": {
            "description": "first turn the flat logo into a volumetric 3D render (one extra billed image), then composite that",
            "type": "boolean"
          },
          "split": {
            "description": "split/panel LAYOUT — only when the user asks for one (\"split\", \"before/after\", \"versus screen\"). \"X vs Y\" as a SCENE stays one unified frame",
            "type": "object",
            "properties": {
              "mode": {
                "type": "string",
                "enum": [
                  "plain",
                  "before_after",
                  "versus",
                  "custom"
                ]
              },
              "panels": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            },
            "required": [
              "mode"
            ],
            "additionalProperties": {}
          },
          "reference": {
            "description": "fields YOU extracted by eye from a reference thumbnail. Extract ALL of: brief (one dense sentence on the concept), subject (pose/action generically, NEVER a specific identity), elements, location, composition, background, split (boolean), split_count, person_count (0-3), emotion (one of the 11 presets or 'other'), emotion_detail (one vivid sentence covering eyes, brows, mouth, head angle). emotion + emotion_detail carry the reference's actual facial performance, which is the single biggest CTR lever on a face; split/split_count reproduce its panel structure. The reference image itself is never sent to the model",
            "type": "object",
            "properties": {},
            "additionalProperties": {}
          },
          "tweak": {
            "type": "object",
            "properties": {
              "kind": {
                "type": "string",
                "enum": [
                  "emotion",
                  "background",
                  "background_color",
                  "rim_light"
                ]
              },
              "value": {
                "type": "string"
              }
            },
            "required": [
              "kind",
              "value"
            ],
            "description": "surgical pixel-faithful edit of a FINISHED thumbnail — needs sourceImage"
          },
          "sourceImage": {
            "description": "the finished thumbnail URL a `tweak` edits; tweaks chain, so feed each accepted output into the next",
            "type": "string"
          },
          "forceGenerate": {
            "description": "render the 'screenshot' framework anyway (it is normally a real video frame, not a generation)",
            "type": "boolean"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "generate_voice",
      "title": "Generate voiceover",
      "description": "RAW text-to-speech from the voice-model catalog: speak a script in a chosen voice and return the served MP3 URL. For a standalone voiceover / narration clip — NOT for adding audio to a video (render_ad and generate_video voice their own spots; change_voice re-voices a finished clip). engine picks the voice model (default 'seed-audio'; also 'eleven-v3', 'minimax-speech', 'kokoro'); voice is a preset name from that engine (see hermoso_capabilities → voice engines). Paid (a couple of credits by length; ≤900 characters).",
      "group": "create",
      "section": "raw playground: voice (TTS) + writing models",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "text": {
            "type": "string",
            "description": "the script to speak (≤900 characters)"
          },
          "engine": {
            "description": "voice-engine id: 'seed-audio' (default), 'eleven-v3', 'minimax-speech', or 'kokoro' — listed in hermoso_capabilities",
            "type": "string"
          },
          "voice": {
            "description": "a voice preset from the chosen engine (e.g. 'Aria'/'George' on eleven-v3, 'stokie_en' on seed-audio) — omit for the engine default",
            "type": "string"
          }
        },
        "required": [
          "text"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "generate_text",
      "title": "Generate text",
      "description": "RAW text generation against the writing-model catalog (Claude, Gemini, GPT, Llama, DeepSeek…) — ad copy, hooks, scripts, rewrites, brainstorms. Prompt-only, no ad assembly (for a finished on-brand creative use plan_ad → render_ad). model = a writing-model id from hermoso_capabilities (omit for the default Claude orchestrator). Paid (a credit or two by length).",
      "group": "create",
      "section": "raw playground: voice (TTS) + writing models",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "prompt": {
            "type": "string",
            "description": "the writing task / question"
          },
          "model": {
            "description": "a writing-model id from hermoso_capabilities (a Claude / Gemini / GPT / Llama / DeepSeek id) — omit for the default",
            "type": "string"
          }
        },
        "required": [
          "prompt"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "render_ad",
      "title": "Render ad video",
      "description": "RECOMMENDED for finished video ADS: render a plan_ad concept through the SAME quality pipeline as the Hermoso web Studio — timed shot list, exact/clean speech (no garbled words), text composited in post (never model-painted), brand end card, licensed music bed, real product references. Pass plan_ad’s full structured output as `creative`. Honors the plan’s render_plan structure/duration: a ≤15s storyboard renders as ONE single-pass clip; a longer plan automatically renders as STITCHED ACTS (fewest balanced ≤15s clips) — never time-compressed into one clip. CAST A SAVED CREATOR with `creator` so the SAME person stars in this ad as in the last one (list_creators is the roster) — otherwise every render invents a new face. Renders take 1–3 min; keep polling get_job if it returns still-rendering. Spends credits.",
      "group": "create",
      "section": "video / avatar / stitch (job-based, polled to completion)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "creative": {
            "type": "object",
            "properties": {},
            "additionalProperties": {},
            "description": "the FULL structured output of plan_ad (must contain video_storyboard)"
          },
          "creator": {
            "description": "CAST A SAVED CREATOR in this ad — their id from list_creators, or the name you know them by (“Sarah”). Their saved portrait becomes the on-camera identity for the whole spot, so the same face carries across every act and across every ad you render for this brand — and because we already have their picture, the character portrait this pipeline would otherwise generate is skipped, so casting somebody costs LESS than not casting them. Omit to let the ad cast a fresh person. Refused for free, with nothing rendered, if the name matches nobody or more than one creator, if the plan has nobody on camera, or if they are a REAL person with no likeness consent on file.",
            "type": "string"
          },
          "model": {
            "description": "video model id from hermoso_capabilities (default: the plan’s pick). Naming one is a DELIBERATE pick — the server asks before ever swapping it (no silent fallback)",
            "type": "string"
          },
          "durationSeconds": {
            "description": "total ad length in seconds (supported range 4–180; outside that it is clamped). Omit to honor the plan’s own duration — that is almost always right. This only RE-TIMES an already-authored board (its scenes are scaled to fit), it does NOT re-write it, so to change the length of the ad the user asked for, re-run plan_ad with durationSeconds instead. ≤15s renders as one clip; longer is stitched from acts filled to 15s with the remainder last — use dryRun:true to see the exact act split for free before spending.",
            "type": "number"
          },
          "aspectRatio": {
            "description": "output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9",
            "type": "string"
          },
          "resolution": {
            "description": "'720p' default; '480p' = cheap fast draft pass, '1080p'/'4k' = premium final delivery (more credits)",
            "type": "string",
            "enum": [
              "480p",
              "720p",
              "1080p",
              "4k"
            ]
          },
          "captions": {
            "description": "composited caption pills on/off (default: the recipe decides)",
            "type": "boolean"
          },
          "endCard": {
            "description": "branded end card on/off (default: on, except organic recipes)",
            "type": "boolean"
          },
          "music": {
            "description": "licensed music bed on/off (default on)",
            "type": "boolean"
          },
          "lockup": {
            "description": "persistent brand-logo lockup overlay on/off",
            "type": "boolean"
          },
          "ttsVoice": {
            "description": "voiceover voice name (e.g. Rachel / George) when the plan voices over",
            "type": "string"
          },
          "dryRun": {
            "description": "return the routing decision (single pass vs stitched acts, resolved model + act lengths) WITHOUT submitting a render — free, nothing charged",
            "type": "boolean"
          },
          "allowGenericProduct": {
            "description": "proceed even though this brand has NO product photo on file and the ad features a product — the packaging will be INVENTED. Only pass true after telling the user that and hearing they are fine with a generic stand-in",
            "type": "boolean"
          }
        },
        "required": [
          "creative"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "make_template_ad",
      "title": "Make template ad",
      "description": "Render a NATIVE-STYLE TEMPLATE ad from pure HTML — no AI video/image model in the loop, renders in ~30 seconds for a couple of credits. Perfect for native-feel social ads at volume. YOU author the content (short, casual, believable — never marketing-speak). Templates (pass as config.template): 'imessage-chat' (VIDEO ~15s: a real-looking iMessage thread where a friend reveals the product as a rich-link card; config: { thread: { contactName, messages: [{from:'them'|'me', text?, product?:{image,title,domain}}] }, theme?:'dark'|'light', endCard:{headline,cta,domain,logo?,color} } — 4-6 short lowercase bubbles, product card mid-thread from 'me', 1-2 excited replies after); 'chatgpt-chat' (VIDEO: a ChatGPT answer streams the punchline; config: { question, answer (may **bold** the brand), productImage?, endCard }); 'apple-notes' (VIDEO: an iPhone note types itself out; config: { title, lines: string[], theme?, endCard }); 'value-prop' (VIDEO ~17s kinetic typography: config: { hook (≤40 chars), claims: string[] (3-5 COMPLETE phrases, ≤6 words / ≤34 chars each — a finished thought, NEVER a clipped clause like 'Looks good on any'), productImages: string[] (2-3 DISTINCT photos — one rotates per card), palette: string[], endCard }); 'static-mockup' (IMAGE: config: { style:'imessage'|'notes'|'card', size?:{w,h}, ...style fields }); 'airdrop-carousel' (VIDEO ~10s: an iOS AirDrop share card springs up and cycles 3-16 REAL product photos to a full-lineup payoff; config: { brandName, products: [{image, title?}], contactLine?, endCard }); 'app-ui-tour' (VIDEO ~12-16s for APP brands: floating-iPhone mockup walks through REAL app screenshots with kinetic captions; config: { hook?, appName, iconImage?, beats: [{screenImage, caption}] (2-6), palette?, fontStack?, endCard }); 'imessage-cascade' (VIDEO ~12s: iOS notification banners spring in and stack over a blurred backdrop; config: { notifications: [{sender, text}] (4-8), backgroundImage?, endCard }); 'photo-grid' (VIDEO ~8s: collage assembles real photos one at a time; config: { title?, photos: [{image, label?}] (4-9), palette?, fontStack?, endCard }); 'vignette' (VIDEO ~12s: cinematic Ken-Burns hero film; config: { hook, lines: [2-4 ≤40ch], heroImage, palette?, fontStack?, endCard }); 'kinetic-type' (VIDEO ~9-15s typographic motion design with NO VOICEOVER — it is NOT a silent asset: it always carries its own synthesised SFX (whoosh/tick/chime) and, once a curated track is on file, the family's loudest music bed at -16 LUFS; config.music:'off' silences the bed but never the SFX: 3-6 short phrases each land word by word on a full-bleed brand card (product beats caption the phrase over the photo instead), the longest word picked out in the brand accent, and a skewed accent slab wipes every cut; supply productImages and every OTHER beat becomes a full-bleed product shot with its phrase captioned over it — with none it renders as pure typography, so it needs NO photos; config: { phrases: string[] (3-6, ≤34 chars each — punchy, declarative, ONE idea per phrase, a finished thought never a clipped clause), productImages?: string[] (up to 4 DISTINCT photos), palette?: string[], fontStack?, endCard }); 'myth-vs-fact' (VIDEO ~15-26s VO-FIRST kinetic explainer with a real VOICEOVER — the family's ONE paid-audio format: a calm-authority read busts 2-4 myths, each MYTH line slamming in with a red per-line strike then the counter FACT line landing bold+affirmative, word-level KARAOKE lighting each word as the VO speaks it; config: { pairs: [{ myth (≤50ch, the common wrong belief), fact (≤60ch, the corrective truth — wrap its payoff phrase in [brackets] to accent it) }] (2-4), palette?, fontStack?, endCard }. Real product truths only — NEVER invent stats. Costs the flat template credits PLUS a small voiceover charge); 'carousel' (MULTI-IMAGE: 5-10 branded 1080×1080 PNG slides for Meta/LinkedIn/IG carousels — returns an images[] array, one PNG per slide; config: { cover: { hook?, title }, slides: [{ headline (≤8 words), support? (≤16 words), stat?: { value, label } }] (3-8; a stat slide is a REAL user-supplied number like '94%' or '40k+' + a label, never invented), cta: { headline, cta?, domain? }, productImage?, logo?, palette?, fontStack?, endCardColor? }). Every VIDEO format except myth-vs-fact (VO-first, deliberately dry) also gets a mood-matched MUSIC BED when a curated track is on file (the library ships empty — no track means no bed, never a paid generation) under its own SFX, from the curated library — free, no model, no extra credits; set config.music:'off' for a silent cut or a mood name (upbeat/calm/warm/epic/tense/playful/elegant/hype/chill/dramatic) to re-mood it. Image URLs may be any public URL — the server localizes them. Spends a couple of credits.",
      "group": "create",
      "section": "video / avatar / stitch (job-based, polled to completion)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "config": {
            "type": "object",
            "properties": {},
            "additionalProperties": {},
            "description": "the template config — MUST include config.template (one of the template ids above) plus that template's fields"
          }
        },
        "required": [
          "config"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "finish_video",
      "title": "Finish video",
      "description": "Post-process an EXISTING rendered video (its served mp4 URL) with the proven direct-response 'reviewer' finish and/or a film-grain pass — no AI model, ~30s, a couple of credits. pills=true composites a header pill (e.g. '10/10 would buy again'), a brand-accent sub-pill, and 3-4 green-check proof pills cascading in on the beat (YOU author the copy: header ≤40 chars, sub ≤34, each point ≤44 — concrete real benefits, never fabricated stats). grain=true applies a subtle camera-grain finish that makes photoreal AI renders look phone-shot ('less AI') — works alone or with pills. Returns a NEW video; the original is untouched.",
      "group": "create",
      "section": "video / avatar / stitch (job-based, polled to completion)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "videoUrl": {
            "type": "string",
            "description": "the served URL of the video to finish (from a previous render/job)"
          },
          "header": {
            "description": "header pill copy, ≤40 chars (required when pills is on)",
            "type": "string"
          },
          "sub": {
            "description": "accent sub-pill copy, ≤34 chars (usually the product/brand)",
            "type": "string"
          },
          "points": {
            "description": "3-4 proof points, ≤44 chars each",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "accent": {
            "description": "brand accent hex for the sub-pill",
            "type": "string"
          },
          "pills": {
            "description": "default true — set false for a grain-only pass",
            "type": "boolean"
          },
          "grain": {
            "description": "default false — anti-AI film-grain finish",
            "type": "boolean"
          }
        },
        "required": [
          "videoUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "post_edit",
      "title": "Post-production edit",
      "description": "MECHANICAL post-production on an EXISTING rendered video (its served mp4 URL) — an ordered plan of whitelisted primitives executed by ffmpeg (+ Chrome for typeset cards) in seconds for ~2 credits flat, NO AI model, the original untouched (returns a NEW video). The lane for: append a branded end card ('add an end card with our logo and website' — ADDS its seconds, never re-renders), trim, speed (0.5-2x), mute (whole or a window), audio_gain (-20..+6 dB), fade_out, corner logo watermark, anti-AI film grain. Up to 6 ops per plan, applied in order. Brand assets (name/domain/logo/accent) load from the workspace brand automatically; override per-call if needed. NEVER use generate_video/render_ad for these mechanical asks.",
      "group": "create",
      "section": "video / avatar / stitch (job-based, polled to completion)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "videoUrl": {
            "type": "string",
            "description": "the served URL of the video to edit"
          },
          "ops": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "op": {
                  "type": "string",
                  "enum": [
                    "trim",
                    "speed",
                    "mute",
                    "audio_gain",
                    "fade_out",
                    "append_card",
                    "watermark",
                    "grain"
                  ]
                },
                "start": {
                  "description": "trim/mute window start (s)",
                  "type": "number"
                },
                "end": {
                  "description": "trim/mute window end (s)",
                  "type": "number"
                },
                "factor": {
                  "description": "speed 0.5-2",
                  "type": "number"
                },
                "db": {
                  "description": "audio_gain -20..+6 dB",
                  "type": "number"
                },
                "seconds": {
                  "description": "fade_out 0.3-3s / append_card 2-5s",
                  "type": "number"
                },
                "headline": {
                  "description": "append_card: big line (defaults to the brand name)",
                  "type": "string"
                },
                "tagline": {
                  "description": "append_card: smaller line under the headline",
                  "type": "string"
                },
                "sub": {
                  "description": "append_card: the pill line (defaults to the brand website)",
                  "type": "string"
                },
                "background": {
                  "description": "append_card: card background — hex or a color name ('red', 'navy'…); the user's stated color always wins over the brand palette",
                  "type": "string"
                },
                "card_html": {
                  "description": "append_card: your OWN full-frame card design as inline-styled HTML ({{logo}} inserts the real brand logo) — use when the standard layout cannot honor the request",
                  "type": "string"
                },
                "corner": {
                  "description": "watermark corner (default br)",
                  "type": "string",
                  "enum": [
                    "tl",
                    "tr",
                    "bl",
                    "br"
                  ]
                },
                "intensity": {
                  "description": "grain look",
                  "type": "string",
                  "enum": [
                    "default",
                    "strong"
                  ]
                }
              },
              "required": [
                "op"
              ]
            },
            "description": "the ordered edit plan (max 6 ops)"
          },
          "brandName": {
            "description": "override the workspace brand name",
            "type": "string"
          },
          "domain": {
            "description": "override the brand website",
            "type": "string"
          },
          "accent": {
            "description": "override the brand accent hex",
            "type": "string"
          }
        },
        "required": [
          "videoUrl",
          "ops"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "fix_beat",
      "title": "Fix a video beat",
      "description": "Surgically re-render ONE time window (1.5-8s) of an existing rendered video and splice it back on the VIDEO TRACK ONLY — the rest of the video and ALL audio stay byte-identical. Use when one beat/shot is broken ('the shot at 8 seconds glitches') and a full re-render would waste the parts that worked; bills only the replacement clip's seconds (~1/3 of a full render). Do NOT pick a window covering spoken dialogue (a video-only splice under speech breaks lip-sync) — pass speechWindows to enforce this.",
      "group": "create",
      "section": "video / avatar / stitch (job-based, polled to completion)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "videoUrl": {
            "type": "string",
            "description": "the served URL of the master video to fix"
          },
          "startSeconds": {
            "type": "number",
            "description": "window start in seconds"
          },
          "endSeconds": {
            "type": "number",
            "description": "window end in seconds (window 1.5-8s)"
          },
          "prompt": {
            "type": "string",
            "description": "what the replacement footage should show — describe the shot, matching the master's style"
          },
          "refImage": {
            "description": "optional product/style anchor image URL",
            "type": "string"
          },
          "speechWindows": {
            "description": "[[start,end],...] windows with spoken lines — the fix window must not overlap these",
            "type": "array",
            "items": {
              "type": "array",
              "items": {
                "type": "number"
              }
            }
          }
        },
        "required": [
          "videoUrl",
          "startSeconds",
          "endSeconds",
          "prompt"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "clip_video",
      "title": "Clip a long video",
      "description": "Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, conference talk, long ad cut → Reels/Shorts/TikTok). Transcribes the source with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, which is why it's fast and cheap. ACCEPTS: (a) a YouTube link (or Vimeo / Loom / Dailymotion / Streamable / Rumble / Wistia / Twitch / TED) — the server pulls the video down itself; (b) a direct https .mp4/.mov/.webm; (c) a Hermoso /generated/ URL (upload_file turns a local file into one). NOT supported: TikTok / Instagram / Facebook links, and anything age-restricted, private, members-only, geo-blocked or still LIVE — those fail fast with the real reason and are fully refunded, so ask for a direct file or an upload rather than retrying. Source must be at least ~15s and under ~600MB; only the first ~40 minutes is analysed (the result reports truncated:true when it hits that). Cost: a ~7-credit hold, settled to the exact transcription + encode cost, plus the clip-selection model's tokens billed as their own small event. RETURNS clips[] — each with its OWN served mp4 URL, title, hook, ready-to-post caption, 0-100 score and source timecode — not a single video.",
      "group": "create",
      "section": "THREE BUILT LANES (clipper / explainer / hypermotion). Each has been a real WORKERS entry on POST /api/jobs for",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "video": {
            "type": "string",
            "description": "the long video to clip — a YouTube/Vimeo/Loom/Dailymotion/Streamable/Rumble/Wistia/Twitch/TED watch URL, a direct https .mp4/.mov/.webm, or a Hermoso /generated/ URL"
          },
          "count": {
            "description": "how many clips to cut, 1-8 (default 4)",
            "type": "number"
          },
          "aspectRatio": {
            "description": "clip shape — '9:16' (default) vertical for Reels/Shorts/TikTok; 'keep' leaves the source framing untouched",
            "type": "string",
            "enum": [
              "9:16",
              "1:1",
              "16:9",
              "keep"
            ]
          }
        },
        "required": [
          "video"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "make_explainer",
      "title": "Make an explainer video",
      "description": "Turn a TOPIC into a finished narrated explainer video. Writes a sectioned script, paints a BURST of pictures per section (about one every 1.5s — most of them one-detail edits of the frame before, so it reads as movement rather than a slideshow), narrates each section with TTS, holds each picture PERFECTLY STILL for its own slice of the narration (the motion is the CUT RATE, exactly as Higgsfield's stills pipeline does it — a slow move on a still shimmers), then composites the end card (and any on-screen text you asked for) with the Chrome+ffmpeg engine the ads use (text is never model-painted, so it never garbles). BURNED ON-SCREEN TEXT IS OFF BY DEFAULT — the narration carries the point and the pictures carry the story, so the film ships clean unless the user asks otherwise; `captions:true` adds held key points and `subtitles:true` adds narration-timed CAPS (see both). It is an image film WITH motion, not N video-model renders — that's what keeps it affordable. `style` picks the visual family: the default 'cinematic' is photoreal editorial; every other id is a STYLED, strictly non-photoreal look (illustrated / collage / clay / pixel …) that first renders ONE style-key image and then locks every scene to it, so the whole film holds one look. Cost at the default frame density: a ~130-credit hold for a 60s explainer on the default style, ~100 styled; `frameDensity:'lean'` roughly halves it and `'minimal'` (one picture per section) is ~30. All settle to the exact per-frame image + narration spend (a longer target = more sections = more). Takes SEVERAL minutes — one image render per frame; independent frames are painted concurrently, so it is far faster than the frame count suggests. Needs the writing model and a narration voice engine connected. NOT the tool for a short product ad — use render_ad or generate_video for those, and make_template_ad for the deterministic native formats.",
      "group": "create",
      "section": "THREE BUILT LANES (clipper / explainer / hypermotion). Each has been a real WORKERS entry on POST /api/jobs for",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "topic": {
            "type": "string",
            "description": "what the explainer should teach or explain — a topic or a short brief"
          },
          "durationSeconds": {
            "description": "target length 20-120s (default 60); drives the section count — ~10s of narration each, 3-8 sections",
            "type": "number"
          },
          "frameDensity": {
            "description": "how many pictures per second of narration, and therefore what it costs. 'standard' (default) is a frame about every 1.5s — the density Higgsfield's own stills pipeline enforces; 'lean' is one about every 2.5s (the longest hold that still reads as a film, ~40% of the frames and ~40% of the cost); 'minimal' is ONE picture per narration section, which is cheapest and is frankly a slideshow. Only drop below the default if the user asked for something cheaper.",
            "type": "string",
            "enum": [
              "standard",
              "lean",
              "minimal"
            ]
          },
          "aspectRatio": {
            "description": "'9:16' default",
            "type": "string",
            "enum": [
              "9:16",
              "16:9",
              "1:1",
              "4:5",
              "3:4"
            ]
          },
          "style": {
            "description": "visual style. 'cinematic' (default) is photoreal; the rest are non-photoreal styled looks — editorial_collage (halftone cutouts + marker accents), flat_vector, stickman, whiteboard, ink_marker, silhouette, storybook (gouache), paper_diorama, isometric, claymation, pixel_art, watercolor, fluffy_toy (felted plush), low_poly, stylized_3d (matte clay render), studio_3d (preschool toy 3D on a white sweep — the Kids default), mannequin (clay-render reenactment figures — a History alternate). Ask the user which they want rather than picking silently; a styled pick costs more (see the cost note).",
            "type": "string",
            "enum": [
              "cinematic",
              "editorial_collage",
              "flat_vector",
              "stickman",
              "whiteboard",
              "ink_marker",
              "silhouette",
              "storybook",
              "paper_diorama",
              "isometric",
              "claymation",
              "pixel_art",
              "watercolor",
              "fluffy_toy",
              "low_poly",
              "stylized_3d",
              "studio_3d",
              "mannequin"
            ]
          },
          "channel": {
            "description": "the CHANNEL TYPE — it sets the pacing, the narration register and the default look, and is orthogonal to `style` (a named style always wins): explainer (casual second-person, fast cuts), history (witty chronological retelling / documentary), kids (fastest, question-first, warm teacher), fairytale (slow, atmospheric myth or folklore). Default 'explainer'.",
            "type": "string",
            "enum": [
              "explainer",
              "history",
              "kids",
              "fairytale"
            ]
          },
          "voice": {
            "description": "narration voice name — omit for the default warm read",
            "type": "string"
          },
          "captions": {
            "description": "turn ON-SCREEN TEXT on. DEFAULT FALSE, and leave it false unless the user asks — the narration already says the point and the pictures carry it, so the clean film is the better default. `captions:true` on its own burns SUBTITLES (see below), because that is what a caption is for: showing what is being said when the phone is on mute. Slim white CAPS, thin black outline, bottom safe band, no plate, no box.",
            "type": "boolean"
          },
          "subtitles": {
            "description": "which on-screen text, once `captions` is on. LEAVE IT UNSET (or true) for SUBTITLES — every spoken word, in order, timed to the narration; free, no extra render, no extra credits, and there is NO cue limit, so the whole film is subtitled however long it runs (at most 5 words / 32 characters a line). Set it FALSE only if the user explicitly wants section HEADINGS instead: one short summary label held over each ~7-15s section. That is NOT what is being said — it is a label about it — so it is the wrong answer to \"add captions\" and to anyone watching on mute. `subtitles:true` also implies `captions:true`. TIMING: each cue is anchored to that section’s REAL measured narration length and distributed inside the section by character count — exact at every section boundary, approximate to a few tenths of a second within one. It is not a word-level speech clock, so never promise frame-accurate sync.",
            "type": "boolean"
          },
          "music": {
            "description": "music bed under the narration, measured to sit about 14 dB under the voice and sidechain-ducked beneath it. Omit and the KIDS and FAIRYTALE channels get their recommended bed COMPOSED for this film — those two are the only channels a bed is due on unasked, and it costs a small flat fee; every other channel ships dry. 'off' forces silence. 'library' takes a free curated track only, and ships dry when none is on file. NAME A MOOD — upbeat / calm / warm / epic / tense / playful / elegant / hype / chill / dramatic — to compose one on ANY channel, at the same fee. hermoso_capabilities reports the exact figure as explainerMusicCredits; quote it before you turn a bed on or pick a mood.",
            "type": "string"
          },
          "upscale": {
            "description": "optional FINAL upscale — 2 doubles each side, 4 quadruples. Captions and the end card are burned BEFORE it so they upscale with the frame. It is priced BY LENGTH and it is the expensive part — several times the cost of rendering the film itself. hermoso_capabilities reports the exact figures per length as explainerUpscaleCredits. Never turn it on unasked: quote the number and let the user choose.",
            "type": "number"
          },
          "endCard": {
            "description": "append the branded end card (default true)",
            "type": "boolean"
          },
          "brandName": {
            "description": "brand name for the end card — omit to leave it unbranded",
            "type": "string"
          }
        },
        "required": [
          "topic"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "product_sizzle",
      "title": "Product sizzle (music-led)",
      "description": "Render an 18-30s music-led PRODUCT SIZZLE: ONE 15s Seedance 2.0 hero clip of the product, diced into fast cuts and intercut with typeset spec/CTA cards on a brand-coloured grain background, mixed to a music bed. Faceless by design — no people, no voiceover, no spoken lines; the cards carry every word, so nothing is left to a video model's spelling. Pass a real packshot as refImage or the label will not be yours. EXPENSIVE — the hero clip is the only paid leg and it is a full 15s Seedance render: ≈1,040 credits at the DEFAULT 1080p, ≈470 at 720p, ≈220 at 480p, ≈4,130 at 4k (call hermoso_capabilities for the live seedance-2 per-duration numbers; the dicing and the cards are free, and the music bed is already included in the quoted figure). Confirm the spend with the user before calling. For a talking/UGC ad use render_ad or generate_avatar; for a cheap deterministic format use make_template_ad.",
      "group": "create",
      "section": "THREE BUILT LANES (clipper / explainer / hypermotion). Each has been a real WORKERS entry on POST /api/jobs for",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "prompt": {
            "type": "string",
            "description": "what the sizzle should show — the product, the setting, the look"
          },
          "seconds": {
            "description": "finished length, clamped to 18-30s (default 25). The PAID hero render is always 15s regardless — this only changes how the cuts and cards are packed",
            "type": "number"
          },
          "refImage": {
            "description": "product packshot URL that anchors the real label — strongly recommended",
            "type": "string"
          },
          "aspectRatio": {
            "description": "'9:16' default; anything the seedance-2 catalog entry does not list falls back to 9:16",
            "type": "string"
          },
          "resolution": {
            "description": "hero-clip resolution and therefore the whole cost — DEFAULT '1080p' (≈1,040 credits); '720p' ≈470, '480p' ≈220, '4k' ≈4,130",
            "type": "string",
            "enum": [
              "480p",
              "720p",
              "1080p",
              "4k"
            ]
          },
          "specs": {
            "description": "up to 4 spec lines for the typeset cards, ≤26 chars each",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "cta": {
            "description": "closing CTA line, ≤30 chars",
            "type": "string"
          },
          "brandName": {
            "description": "brand name on the cards — defaults to the workspace brand",
            "type": "string"
          },
          "musicMood": {
            "description": "music-bed mood, e.g. driving / cinematic / upbeat",
            "type": "string"
          }
        },
        "required": [
          "prompt"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "generate_video",
      "title": "Generate video",
      "description": "Render a RAW video clip from your own prompt and return its served mp4 URL. For finished brand ADS prefer render_ad (it runs the Studio quality pipeline — composited text, clean speech, end card, music); use this for raw/experimental clips or precise manual control. ONE generation = one continuous clip up to the model’s longest listed duration (seedance-2 goes to 15s single-pass with a full multi-beat arc — never assume a generic 8–10s cap); durationSeconds must be one of the model’s durations from hermoso_capabilities. Renders take 1–3 min. refImage anchors the opening frame; ttsScript adds a voiceover. AUDIO IS NOT FREE AND NOT OPTIONAL BY DEFAULT: a clip delivered with no audio of its own gets a music bed composed and CHARGED on top of the render (see musicMood and audio) — on a cheap short draft the bed can cost as much as the clip. Pass refVideo (a clip URL) to EDIT an existing video instead of generating from scratch — the omni engine transforms that clip per your prompt, inheriting the source clip’s canvas + length (aspectRatio/durationSeconds are ignored for an edit). Spends credits (Starter plan is video-blocked server-side).",
      "group": "create",
      "section": "THREE BUILT LANES (clipper / explainer / hypermotion). Each has been a real WORKERS entry on POST /api/jobs for",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "prompt": {
            "type": "string",
            "description": "the video prompt / shot description (for a refVideo edit, this is the transformation instruction)"
          },
          "refImage": {
            "description": "local path or URL to anchor the first frame",
            "type": "string"
          },
          "refVideo": {
            "description": "URL of an existing video to EDIT rather than generate from scratch — the omni engine accepts a raw clip and transforms it per your prompt, inheriting the SOURCE clip’s canvas (aspect ratio) and length (aspectRatio/durationSeconds are ignored for an edit). Omit to generate a fresh clip.",
            "type": "string"
          },
          "durationSeconds": {
            "description": "length of THIS ONE clip in seconds — pick one of the chosen model’s listed durations from hermoso_capabilities (seedance-2/kling-3: 5/10/15). This is a single continuous generation, so it CANNOT exceed the model’s longest clip: a longer ask is REFUSED with nothing rendered and nothing charged (it is never quietly truncated). For a spot longer than one clip, use plan_ad with durationSeconds then render_ad, which stitches ≤15s acts (40s = 15+15+10).",
            "type": "number"
          },
          "aspectRatio": {
            "description": "default '9:16'",
            "type": "string"
          },
          "model": {
            "description": "video model id from hermoso_capabilities. Naming one is a DELIBERATE pick — the server asks before ever swapping it (no silent fallback); omit it to let the router pick",
            "type": "string"
          },
          "resolution": {
            "description": "'720p' default; '480p' = cheap fast draft pass, '1080p'/'4k' = premium final delivery (more credits)",
            "type": "string",
            "enum": [
              "480p",
              "720p",
              "1080p",
              "4k"
            ]
          },
          "ttsScript": {
            "description": "voiceover script to speak",
            "type": "string"
          },
          "ttsVoice": {
            "description": "voice name, e.g. Rachel / George",
            "type": "string"
          },
          "musicMood": {
            "description": "WHICH mood the music bed is composed in (upbeat / calm / warm / epic / tense / playful / elegant / hype / chill / dramatic). It does NOT decide WHETHER there is one: a clip that comes back with no audio track — every model hermoso_capabilities lists as \"silent\", plus any audio model that returned mute — gets a bed composed and CHARGED automatically, at the flat per-track fee hermoso_capabilities reports as explainerMusicCredits, and omitting this field only means the mood defaults to \"warm\". Pass audio:false for a genuinely silent clip with no bed and no bed charge.",
            "type": "string"
          },
          "audio": {
            "description": "default true. false = render SILENT: no native model audio, no music bed, and no bed charge held or billed. This is the ONLY way to decline the automatic bed (see musicMood) — leave it alone for anything that should have sound, and do not combine it with ttsScript.",
            "type": "boolean"
          }
        },
        "required": [
          "prompt"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "generate_avatar",
      "title": "Generate talking avatar",
      "description": "Render a TALKING-AVATAR / creator lip-sync clip from a portrait image + a script. Blocks until done (1–3 min). Requires the avatar capability (canAvatar in hermoso_capabilities). Spends credits.",
      "group": "create",
      "section": "THREE BUILT LANES (clipper / explainer / hypermotion). Each has been a real WORKERS entry on POST /api/jobs for",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "image": {
            "type": "string",
            "description": "local path or URL of the presenter portrait"
          },
          "script": {
            "type": "string",
            "description": "the words the avatar speaks"
          },
          "voice": {
            "description": "voice name (Rachel/Sarah/George/Adam)",
            "type": "string"
          },
          "resolution": {
            "description": "'720p' (default) or '480p' draft",
            "type": "string"
          }
        },
        "required": [
          "image",
          "script"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "stitch_video",
      "title": "Stitch multi-scene video",
      "description": "Render a multi-scene STITCHED video (≥2 scenes) — ONLY for spots LONGER than one model clip (>15s). A ≤15s multi-beat ad renders better and cheaper as ONE single-pass generate_video/render_ad on seedance-2 (it handles the full hook→demo→payoff arc in one take) — never stitch those. Blocks until done. Spends credits.",
      "group": "create",
      "section": "THREE BUILT LANES (clipper / explainer / hypermotion). Each has been a real WORKERS entry on POST /api/jobs for",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "scenes": {
            "minItems": 2,
            "type": "array",
            "items": {
              "type": "object",
              "properties": {},
              "additionalProperties": {}
            },
            "description": "array of scene objects (visual + optional voiceover/seconds)"
          },
          "aspectRatio": {
            "description": "output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9",
            "type": "string"
          },
          "voiceover": {
            "description": "full voiceover script spoken across the scenes",
            "type": "string"
          },
          "voice": {
            "description": "voiceover voice name, e.g. Rachel / George",
            "type": "string"
          },
          "resolution": {
            "description": "720p (default), 480p draft, or 1080p final",
            "type": "string"
          },
          "model": {
            "description": "video model id from hermoso_capabilities — omit to let the router pick",
            "type": "string"
          },
          "durationSeconds": {
            "description": "total spot length in seconds (defaults to the sum of the scenes’ seconds)",
            "type": "number"
          }
        },
        "required": [
          "scenes"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "get_job",
      "title": "Get render job",
      "description": "Poll a render job by id. Returns status (queued|running|done|error), progress, and on done the served media URL. Renders take 1–3 minutes: keep calling this until done/error without asking the user — several calls is normal, not a stall.",
      "group": "create",
      "section": "THREE BUILT LANES (clipper / explainer / hypermotion). Each has been a real WORKERS entry on POST /api/jobs for",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "the job id, e.g. job_xxx"
          }
        },
        "required": [
          "id"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_skills",
      "title": "List skills",
      "description": "List the bundled Hermoso SKILLS — multi-step workflow instructions (SKILL.md) that orchestrate the other tools (research an ad space, plan+render a finished ad, product photoshoot, raw generation) — plus the in-app strategy skills and creative recipes. Call get_skill to load a bundle. Read-only, free.",
      "group": "workspace",
      "section": "skills (Higgsfield get_workflow_instructions parity: workflows ship as SKILL.md bundles)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "get_skill",
      "title": "Get skill",
      "description": "Load a bundled skill’s full SKILL.md workflow instructions by name (from list_skills). Follow the loaded instructions to run that workflow with the other tools. Read-only, free.",
      "group": "workspace",
      "section": "skills (Higgsfield get_workflow_instructions parity: workflows ship as SKILL.md bundles)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "bundle name from list_skills, e.g. hermoso-generate"
          }
        },
        "required": [
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "save_skill",
      "title": "Save a skill",
      "description": "Save a reusable custom SKILL — a named creative directive/playbook applied to future ads (a hook formula, a UGC recipe, a compliance rule, “our founder-story style”). Distill an imperative, self-contained directive. Merges into the workspace Skills library (list_skills shows built-ins + your custom skills).",
      "group": "workspace",
      "section": "workspace management: Memory / Skills / Employees / Brand / Connectors / Team / raw store (r-m-w over the store seam)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "short skill name, e.g. “Founder-story hook”"
          },
          "directive": {
            "type": "string",
            "description": "the full instruction the skill applies when used (1–6 sentences, imperative)"
          }
        },
        "required": [
          "name",
          "directive"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_skill",
      "title": "Delete a custom skill",
      "description": "Delete one of the workspace’s CUSTOM skills by id (from list_skills). Built-in skills/recipes can’t be deleted. Minor + re-creatable, so no confirm needed.",
      "group": "workspace",
      "section": "workspace management: Memory / Skills / Employees / Brand / Connectors / Team / raw store (r-m-w over the store seam)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "the custom skill id (from list_skills)"
          }
        },
        "required": [
          "id"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_memory",
      "title": "List memory",
      "description": "List the durable facts & preferences saved in this workspace’s Memory (what the studio remembers about the brand, audience, taste, and do/don’t rules) — the same Memory the web app shows. These shape every future ad. Read-only, free.",
      "group": "workspace",
      "section": "workspace management: Memory / Skills / Employees / Brand / Connectors / Team / raw store (r-m-w over the store seam)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "category": {
            "description": "filter to one bucket (Brand/Audience/Taste/Do/Don’t/Preference)",
            "type": "string"
          },
          "limit": {
            "description": "max items (default 50, max 200)",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "remember",
      "title": "Remember a fact",
      "description": "Save a durable fact or PREFERENCE about the brand, audience, or the user’s creative TASTE (e.g. “audience is first-time homebuyers”, “prefers bold lime accents”, “always captions off”) into the workspace Memory so it shapes FUTURE ads. For lasting things, not one-off requests. Merges into the existing Memory (never overwrites); de-dupes on identical text.",
      "group": "workspace",
      "section": "workspace management: Memory / Skills / Employees / Brand / Connectors / Team / raw store (r-m-w over the store seam)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "text": {
            "type": "string",
            "description": "the fact/preference, concise"
          },
          "category": {
            "description": "short bucket: Brand, Audience, Taste, Do, Don’t, or Preference (default General)",
            "type": "string"
          }
        },
        "required": [
          "text"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "forget",
      "title": "Forget a memory",
      "description": "Delete a saved Memory item by its id (from list_memory). Records a cross-device delete so it doesn’t come back. Minor + re-creatable (you can remember it again), so no confirm needed.",
      "group": "workspace",
      "section": "workspace management: Memory / Skills / Employees / Brand / Connectors / Team / raw store (r-m-w over the store seam)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "the memory item id (from list_memory)"
          }
        },
        "required": [
          "id"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_swipefile",
      "title": "List the swipefile",
      "description": "List this workspace's SWIPEFILE — the saved-ad research board: every named collection and the ads/creatives kept in it (advertiser, headline, body copy, media URL, platform, when it was saved, and any taste tags). The SAME board the web app's Swipefile tab shows. Use it to answer \"what have we saved?\", to mine the user's own taste before planning an ad, or to find a reference to remix. Read-only, free.",
      "group": "workspace",
      "section": "SWIPEFILE — the workspace's saved-ad research board: named COLLECTIONS holding the ads and creatives the user",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "collection": {
            "description": "only list ads in this collection (by name or id) — omit for every collection",
            "type": "string"
          },
          "limit": {
            "description": "max ads to return (default 50, max 500)",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "save_to_swipefile",
      "title": "Save ads to the swipefile",
      "description": "Save one or more ads/creatives to a named SWIPEFILE collection, creating the collection if it does not exist — the headless twin of the ♥ on every ad card in the web app. Use it whenever research turns up something worth keeping: a competitor ad from search_meta_ads / pull_competitor_ads, an organic post, or one of your own renders. Saved ads persist to the workspace board the web Swipefile tab shows, and feed the taste signal every future ad is planned against. De-dupes: re-saving the same ad (same key/link/media) MOVES it into the named collection instead of duplicating it. Free.",
      "group": "workspace",
      "section": "SWIPEFILE — the workspace's saved-ad research board: named COLLECTIONS holding the ads and creatives the user",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "collection": {
            "type": "string",
            "description": "the collection name — an existing one, or a new one to create"
          },
          "items": {
            "minItems": 1,
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "key": {
                  "description": "a stable id for this ad if you have one (an ad_archive_id, creativeId, …). Omit and one is derived from the link/media so re-saving is idempotent",
                  "type": "string"
                },
                "advertiser": {
                  "description": "the brand running the ad",
                  "type": "string"
                },
                "title": {
                  "description": "headline / hook",
                  "type": "string"
                },
                "body": {
                  "description": "the ad copy",
                  "type": "string"
                },
                "image": {
                  "description": "image URL",
                  "type": "string"
                },
                "video": {
                  "description": "video URL",
                  "type": "string"
                },
                "link": {
                  "description": "link to the ad in its library / the destination URL",
                  "type": "string"
                },
                "platform": {
                  "description": "where it ran — 'meta', 'google', 'linkedin', 'tiktok', 'generated', …",
                  "type": "string"
                }
              },
              "additionalProperties": {}
            },
            "description": "the ads to save"
          }
        },
        "required": [
          "collection",
          "items"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_playbooks",
      "title": "List playbooks",
      "description": "List the PLAYBOOKS saved in this workspace — the reusable strategy cards (winning hooks, angles, formats and the concrete plays to run) kept from teardowns, angle mining and creatives worth repeating. The same Playbooks the web app's Playbooks tab lists. Read one before planning an ad so you re-run what already worked instead of starting cold. Read-only, free.",
      "group": "workspace",
      "section": "PLAYBOOKS — the \"what's working + the plays to run\" cards, distinct from the swipefile's raw creative. Same",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "limit": {
            "description": "max playbooks to return (default 25, max 100)",
            "type": "number"
          },
          "full": {
            "description": "true to return every hook/angle/play in the text, not just the headline counts",
            "type": "boolean"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "save_playbook",
      "title": "Save a playbook",
      "description": "Save a reusable PLAYBOOK — the strategy takeaways worth re-running: the hooks that work, the angles, the formats, and the concrete plays. Use it to keep what a competitor_teardown or mine_angles just found, or to bank a creative you want to repeat. Lands in the same Playbooks library the web app lists, runs and manages. Distinct from save_skill (a directive applied to every ad) and from the swipefile (raw saved creative). Free.",
      "group": "workspace",
      "section": "PLAYBOOKS — the \"what's working + the plays to run\" cards, distinct from the swipefile's raw creative. Same",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "the playbook headline — what it is, in a few words"
          },
          "hooks": {
            "description": "the opening hooks worth reusing, verbatim",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "angles": {
            "description": "the persuasion angles ({title, detail})",
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "title": {
                  "type": "string"
                },
                "detail": {
                  "type": "string"
                }
              },
              "required": [
                "title"
              ],
              "additionalProperties": {}
            }
          },
          "formats": {
            "description": "the formats/recipes this plays best in (e.g. ugc_selfie, cinematic, static)",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "plays": {
            "description": "the concrete plays to run ({title, detail}) — the actionable half",
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "title": {
                  "type": "string"
                },
                "detail": {
                  "type": "string"
                }
              },
              "required": [
                "title"
              ],
              "additionalProperties": {}
            }
          },
          "brand": {
            "description": "which brand this is for (defaults to the workspace brand)",
            "type": "string"
          },
          "source": {
            "description": "where it came from, e.g. “teardown · Ridge”",
            "type": "string"
          }
        },
        "required": [
          "name"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_playbook",
      "title": "Delete a playbook",
      "description": "Delete a saved playbook by id (from list_playbooks). Records a cross-device delete so it does not come back on the next sync. Minor + re-creatable, so no confirm needed.",
      "group": "workspace",
      "section": "PLAYBOOKS — the \"what's working + the plays to run\" cards, distinct from the swipefile's raw creative. Same",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "the playbook id (from list_playbooks)"
          }
        },
        "required": [
          "id"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_employees",
      "title": "List AI employees",
      "description": "List the hireable AI Employee personas in this workspace — the built-in specialists (Short-Form Ad Strategist, UGC Scriptwriter, Product Photographer, …) PLUS any custom personas saved here, and which one is currently active. Read-only, free.",
      "group": "workspace",
      "section": "PLAYBOOKS — the \"what's working + the plays to run\" cards, distinct from the swipefile's raw creative. Same",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "save_employee",
      "title": "Save an AI employee",
      "description": "Create a custom AI Employee persona for this workspace — a named specialist with a role + a DIRECTIVE that frames how the studio behaves while it’s hired. Merges into the workspace Employees. Use set_active_employee to hire it.",
      "group": "workspace",
      "section": "PLAYBOOKS — the \"what's working + the plays to run\" cards, distinct from the swipefile's raw creative. Same",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "the persona’s name (e.g. “Nadia”)"
          },
          "directive": {
            "type": "string",
            "description": "how it should shape ads (2–5 sentences, imperative)"
          },
          "title": {
            "description": "job title (e.g. “Short-Form Ad Strategist”)",
            "type": "string"
          },
          "pitch": {
            "description": "one-line pitch",
            "type": "string"
          },
          "emoji": {
            "description": "an emoji badge (default ✦)",
            "type": "string"
          }
        },
        "required": [
          "name",
          "directive"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_active_employee",
      "title": "Hire (activate) an AI employee",
      "description": "Set which AI Employee persona is HIRED for this workspace (by id, from list_employees) — or pass none/empty to unhire. Records the selection for the workspace so list_employees reflects it.",
      "group": "workspace",
      "section": "PLAYBOOKS — the \"what's working + the plays to run\" cards, distinct from the swipefile's raw creative. Same",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": {
            "description": "the employee id to hire (from list_employees) — omit or \"\" to unhire",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_creators",
      "title": "List saved creators",
      "description": "List this workspace’s SAVED CREATORS — the reusable on-camera cast (AI creators made here, a person pulled from a social profile, a consented photo upload). Read-only, FREE. Each entry gives the name, the PORTRAIT URL, where the portrait came from and whether a real person’s likeness consent is on file, how many extra pose plates exist, and any chosen or cloned voice. TO PUT ONE IN A FINISHED AD, pass their id or name as render_ad’s `creator` — that casts them for the whole spot (and skips the character-portrait render, so it costs less than not casting anyone). THE PORTRAIT URL IS THE REUSE HANDLE for the raw lanes — pass it as generate_avatar’s `image` (a talking clip of them), generate_video’s `refImage` (they star in the scene), recast_motion’s `image` (they perform a reference clip’s motion), or generate_image’s `refImages`. CALL THIS BEFORE OFFERING TO GENERATE A NEW PERSON: re-casting somebody the workspace already has keeps the SAME face across every ad, while a fresh person costs credits and breaks that continuity. An empty answer means the workspace genuinely has no cast yet — say so and offer generate_avatar / save_creator, never invent a roster.",
      "group": "workspace",
      "section": "SAVED CREATORS — the workspace's reusable on-camera cast (`heist.avatars.v1`)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "limit": {
            "description": "max creators to return (default 24)",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "save_creator",
      "title": "Save a creator",
      "description": "Add a portrait to this workspace’s reusable CAST so the SAME person can star in future ads — the headless twin of the app’s ＋ ▸ Pick a creator ▸ save. Pass the portrait’s public url (a generate_image render of a person, a headshot, any public photo) plus a name to call them by; from then on list_creators returns them and their url can be re-passed to generate_avatar / generate_video / recast_motion. Saving is FREE and renders nothing. LIKENESS — `source` says what the portrait IS: leave it \"generated\" for an AI-made person, and use \"upload\"/\"social\" ONLY for a REAL person. Pass consented:true only when the user has told you that person agreed to their likeness being used; never assert that on their behalf.",
      "group": "workspace",
      "section": "SAVED CREATORS — the workspace's reusable on-camera cast (`heist.avatars.v1`)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "what to call this creator (e.g. “Sarah”) — list_creators and the app’s picker match on it"
          },
          "image": {
            "type": "string",
            "description": "public https url of the portrait (an existing render’s url, or any public photo). Not a local file path — upload it with upload_file first and save the url that returns"
          },
          "source": {
            "description": "\"generated\" (default) = an AI-made person; \"upload\" / \"social\" = a REAL person",
            "type": "string",
            "enum": [
              "generated",
              "upload",
              "social"
            ]
          },
          "consented": {
            "description": "REAL people only: the user has confirmed that person consented to their likeness being used in ads",
            "type": "boolean"
          },
          "voice": {
            "description": "a default voice name for this persona (engines + voices are in hermoso_capabilities)",
            "type": "string"
          },
          "poses": {
            "description": "up to 4 extra full-body / angle plates of the SAME person (public urls) — they make a wider shot hold the identity",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "look": {
            "description": "their canonical wardrobe/appearance in words — reused to hold the look steady across ads",
            "type": "string"
          }
        },
        "required": [
          "name",
          "image"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "delete_creator",
      "title": "Delete a creator",
      "description": "Remove a saved creator from this workspace’s cast by id (from list_creators). Records a cross-device delete so they don’t reappear on the user’s other devices. It only drops the roster entry — ads already rendered with that person are untouched — and the same portrait can be saved again with save_creator, so no confirm is needed.",
      "group": "workspace",
      "section": "SAVED CREATORS — the workspace's reusable on-camera cast (`heist.avatars.v1`)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "the creator id (from list_creators)"
          }
        },
        "required": [
          "id"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_brand",
      "title": "Update brand fields",
      "description": "Patch SPECIFIC fields of the workspace brand profile (name, domain, sells, summary, category, audience, positioning, voice, style, goal) WITHOUT overwriting the rest — a read-modify-write on the saved brand. Use for “change our voice to playful”, “we sell to dentists now”. To onboard a brand from scratch, use draft_brand. Only pass the fields you’re changing.",
      "group": "workspace",
      "section": "SAVED CREATORS — the workspace's reusable on-camera cast (`heist.avatars.v1`)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "domain": {
            "description": "website domain",
            "type": "string"
          },
          "sells": {
            "description": "what the brand sells",
            "type": "string"
          },
          "summary": {
            "description": "one-line description",
            "type": "string"
          },
          "category": {
            "type": "string"
          },
          "audience": {
            "type": "string"
          },
          "positioning": {
            "type": "string"
          },
          "voice": {
            "description": "brand voice/tone",
            "type": "string"
          },
          "style": {
            "description": "visual style — palette, typography, aesthetic",
            "type": "string"
          },
          "goal": {
            "description": "current marketing goal",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "store_get",
      "title": "Read a workspace store",
      "description": "Read one of this workspace’s data stores by key, for visibility into what the app holds — playbooks, swipefile, saved locations, avatars, creations, chats, brand, memory, skills, employees. Read-only, free. Allowed keys: heist.memory.v1, heist.skills.v1, heist.employees.v1, heist.playbooks.v1, heist.avatars.v1, heist.locations.v1, heist.chats.v1, heist.creations.v1, heist.assets.v1, heist.brand.v1, adInspo.swipefile.v1. (The typed tools — list_memory / list_skills / list_employees / get_brand — are friendlier for those; use store_get for the rest.)",
      "group": "workspace",
      "section": "SAVED CREATORS — the workspace's reusable on-camera cast (`heist.avatars.v1`)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "key": {
            "type": "string",
            "description": "the store key to read (one of the allowlisted keys)"
          },
          "limit": {
            "description": "max array items to return (default 50)",
            "type": "number"
          }
        },
        "required": [
          "key"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "get_settings",
      "title": "Read app settings",
      "description": "Read this account's app settings — the LANGUAGE Hermoso writes ads, copy and answers in, the app appearance (theme), and whether the weekly competitor-watch email is on. Same settings as the web app's Settings pane. Read-only, free.",
      "group": "workspace",
      "section": "APP SETTINGS. The web Settings pane reads and writes the SAME account row, so a language chosen here shows up",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "update_settings",
      "title": "Change app settings",
      "description": "Change this account's app settings. language = the language EVERY ad, script, plan and answer is written in from now on (say the language in plain English, e.g. \"German\", \"Japanese\", \"Brazilian Portuguese\") — it applies to renders made over MCP as well as in the app. theme = the app's appearance, \"dark\" or \"light\". watchEmail = the weekly competitor-watch email on/off. Only pass what you are changing. Account-wide (every brand), and it takes effect on the next call.",
      "group": "workspace",
      "section": "APP SETTINGS. The web Settings pane reads and writes the SAME account row, so a language chosen here shows up",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "language": {
            "description": "language for generated ads, copy and answers — e.g. \"English\", \"German\", \"Japanese\"",
            "type": "string"
          },
          "theme": {
            "description": "app appearance",
            "type": "string",
            "enum": [
              "dark",
              "light"
            ]
          },
          "watchEmail": {
            "description": "weekly competitor-watch email on/off",
            "type": "boolean"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_connectors",
      "title": "List connectors",
      "description": "List the third-party accounts connected to this workspace (Meta, Google Ads, Google Drive/Sheets/Docs, YouTube, LinkedIn, OneDrive, Slack, …) — provider, status and the connected account label — PLUS which providers are available to connect. Read-only, free.",
      "group": "workspace",
      "section": "APP SETTINGS. The web Settings pane reads and writes the SAME account row, so a language chosen here shows up",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_connector_accounts",
      "title": "List a connector’s accounts",
      "description": "Show every identity a connected account can act as, and which ones this BRAND is currently allowed to use — Facebook Pages + Instagram + Meta ad accounts, Google Ads customers, LinkedIn company Pages (and the personal profile), Pinterest ad accounts, Microsoft Advertising accounts. One person often administers several; only the ticked ones can be posted to or spent from. Call this before set_connector_accounts, and let the USER pick — never guess. Providers: meta, google_ads, linkedin, pinterest, linkedin_ads, reddit_ads, microsoft_ads, google_business. Read-only, free.",
      "group": "workspace",
      "section": "CONNECTOR WRITES. Connecting needs a browser (OAuth consent) and is correctly NOT headless — but the other two",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "provider": {
            "type": "string",
            "enum": [
              "meta",
              "google_ads",
              "linkedin",
              "pinterest",
              "linkedin_ads",
              "reddit_ads",
              "microsoft_ads",
              "google_business"
            ],
            "description": "which connector’s accounts to list"
          }
        },
        "required": [
          "provider"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_connector_accounts",
      "title": "Choose which accounts a brand may use",
      "description": "Set WHICH of a connector's accounts this brand is allowed to post to and spend from — Facebook Pages / Instagram / Meta ad accounts, Google Ads customers, LinkedIn company Pages (and the personal profile), Pinterest or Microsoft Advertising ad accounts. Pass ids from list_connector_accounts. This REPLACES the current selection: anything you leave out is un-shared, and an EMPTY list shares nothing (publishing then refuses — it fails closed by design, and the server re-verifies every id against the live connection, so an id the account cannot actually reach is rejected rather than saved). Ask the user which accounts they mean; posting as the wrong Page is a public mistake. Providers: meta, google_ads, linkedin, pinterest, linkedin_ads, reddit_ads, microsoft_ads, google_business. Free.",
      "group": "workspace",
      "section": "CONNECTOR WRITES. Connecting needs a browser (OAuth consent) and is correctly NOT headless — but the other two",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "provider": {
            "type": "string",
            "enum": [
              "meta",
              "google_ads",
              "linkedin",
              "pinterest",
              "linkedin_ads",
              "reddit_ads",
              "microsoft_ads",
              "google_business"
            ],
            "description": "which connector to scope"
          },
          "accountIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "the ids (from list_connector_accounts) this brand may use — an empty array shares nothing"
          }
        },
        "required": [
          "provider",
          "accountIds"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "disconnect_connector",
      "title": "Disconnect a connected account",
      "description": "Disconnect a third-party account from this workspace (Meta, Google Ads, Google Drive/Sheets/Docs, YouTube, TikTok, LinkedIn, X, Reddit, Pinterest, Google Business, Microsoft Advertising/OneDrive, Slack, …). This revokes our access at the provider and drops the stored credentials, so every tool for that provider stops working immediately and posts/campaigns already published are NOT affected. RECONNECTING NEEDS A BROWSER (the provider's consent screen) — an agent cannot undo this. Name the provider to the user, then call with confirm:true. Use list_connectors for the exact provider ids.",
      "group": "workspace",
      "section": "CONNECTOR WRITES. Connecting needs a browser (OAuth consent) and is correctly NOT headless — but the other two",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "provider": {
            "type": "string",
            "description": "provider id exactly as list_connectors reports it, e.g. \"meta\", \"google_ads\", \"youtube\", \"linkedin\""
          },
          "confirm": {
            "description": "REQUIRED true — reconnecting needs the user's browser",
            "type": "boolean"
          }
        },
        "required": [
          "provider"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_team",
      "title": "List team members",
      "description": "List the members of the current brand workspace — email, role (admin/member) and status. Read-only, free.",
      "group": "workspace",
      "section": "CONNECTOR WRITES. Connecting needs a browser (OAuth consent) and is correctly NOT headless — but the other two",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "invite_member",
      "title": "Invite a teammate",
      "description": "Invite someone to this brand workspace by email (role: member = read-only on billing, admin = full). This SENDS a real email invite / share link — an account change. Confirm the exact email + role with the user, then call with confirm:true.",
      "group": "workspace",
      "section": "CONNECTOR WRITES. Connecting needs a browser (OAuth consent) and is correctly NOT headless — but the other two",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "email": {
            "type": "string",
            "description": "the invitee’s email"
          },
          "role": {
            "description": "default member",
            "type": "string",
            "enum": [
              "member",
              "admin"
            ]
          },
          "confirm": {
            "description": "REQUIRED true — this invites a real person",
            "type": "boolean"
          }
        },
        "required": [
          "email"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "remove_member",
      "title": "Remove a teammate",
      "description": "Remove a member from this brand workspace by email — they lose access (you can re-invite them later). Confirm the exact person with the user, then call with confirm:true.",
      "group": "workspace",
      "section": "CONNECTOR WRITES. Connecting needs a browser (OAuth consent) and is correctly NOT headless — but the other two",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "email": {
            "type": "string",
            "description": "the member’s email"
          },
          "confirm": {
            "description": "REQUIRED true",
            "type": "boolean"
          }
        },
        "required": [
          "email"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_role",
      "title": "Change a teammate’s role",
      "description": "Change a workspace member’s role — admin (full access incl. billing) or member (read-only on billing). A privilege change: confirm the exact person + new role with the user, then call with confirm:true.",
      "group": "workspace",
      "section": "CONNECTOR WRITES. Connecting needs a browser (OAuth consent) and is correctly NOT headless — but the other two",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "email": {
            "type": "string",
            "description": "the member’s email"
          },
          "role": {
            "type": "string",
            "enum": [
              "admin",
              "member"
            ],
            "description": "the new role"
          },
          "confirm": {
            "description": "REQUIRED true",
            "type": "boolean"
          }
        },
        "required": [
          "email",
          "role"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_jobs",
      "title": "List render jobs",
      "description": "List the most recent render jobs + how many are currently running, so you can report on or resume in-flight work.",
      "group": "workspace",
      "section": "CONNECTOR WRITES. Connecting needs a browser (OAuth consent) and is correctly NOT headless — but the other two",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_errors",
      "title": "List errors users hit",
      "description": "The errors actually recorded against this workspace, GROUPED by fingerprint — the same failure at the same call site is one row with a hit count and first/last seen, sorted defects-first. Each row says whose side it is: `ours` (a defect worth fixing), `user` (a refusal we deliberately authored, e.g. not-connected or out-of-credits), or `unknown` (a vendor 4xx we cannot attribute — never guessed). Free text, tokens, emails and creative are redacted before anything is stored, so an input echo shows shapes and lengths, not content. Filter by surface (http/mcp/agent/job/client) or kind. Read-only, 0 credits. Scoped to your own workspace; an operator whose client carries the admin key sees every account.",
      "group": "workspace",
      "section": "error triage (read-only, free)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "kind": {
            "description": "'ours' = a defect; 'user' = a refusal we authored; 'unknown' = we could not tell",
            "type": "string",
            "enum": [
              "ours",
              "user",
              "unknown"
            ]
          },
          "surface": {
            "description": "where it happened: http (an API route), mcp (an agent tool), agent (the in-app Studio agent), job (an async render/publish), client (a browser crash)",
            "type": "string",
            "enum": [
              "http",
              "mcp",
              "agent",
              "job",
              "client"
            ]
          },
          "since": {
            "description": "ISO timestamp — only groups last seen at or after this",
            "type": "string"
          },
          "limit": {
            "description": "how many groups to return (default 50, max 200)",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "error_detail",
      "title": "Error detail",
      "description": "One error group in full by fingerprint (from list_errors): every field, plus the most recent redacted occurrences — status, connector, job id, workspace, and a shape-only echo of the inputs. This is what makes a bug reproducible. Read-only, 0 credits.",
      "group": "workspace",
      "section": "error triage (read-only, free)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "fingerprint": {
            "type": "string",
            "description": "the `fp` value from list_errors"
          }
        },
        "required": [
          "fingerprint"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "find_competitors",
      "title": "Find competitors",
      "description": "Discover a brand's competitor / similar / adjacent brands from its domain (Claude grounded by web search). mode=competitors (default, excludes the searched company), inspiration (best relevant ads incl. it), or company. 0 ScrapeCreators credits.",
      "group": "research",
      "section": "research / discovery",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "domain": {
            "type": "string",
            "description": "the brand domain, e.g. flourish.com"
          },
          "mode": {
            "description": "'competitors' (default, excludes the searched company), 'inspiration' (best relevant ads incl. it), or 'company'",
            "type": "string",
            "enum": [
              "competitors",
              "inspiration",
              "company"
            ]
          }
        },
        "required": [
          "domain"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "pull_competitor_ads",
      "title": "Pull competitor ads",
      "description": "Pull a brand's real running ads across Meta / Google / LinkedIn ad libraries (deduped, sorted, right page resolved). Spends ScrapeCreators credits.",
      "group": "research",
      "section": "research / discovery",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "companyName": {
            "description": "the advertiser name",
            "type": "string"
          },
          "domain": {
            "description": "the advertiser domain",
            "type": "string"
          },
          "platforms": {
            "description": "default ['facebook']; add 'google','linkedin'",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "country": {
            "description": "2-letter, default 'US'",
            "type": "string"
          },
          "limit": {
            "description": "max ads per platform (default 30)",
            "type": "number"
          },
          "sort": {
            "description": "'longest_running' (default) etc.",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "research_ads",
      "title": "Research ads",
      "description": "Natural-language ad research: a Claude tool-use loop over Meta/Google/LinkedIn ad libraries + organic TikTok. Returns a summary + the found ads (with their served URLs). Spends LLM tokens + ScrapeCreators credits.",
      "group": "research",
      "section": "research / discovery",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "query": {
            "type": "string",
            "description": "what to research, e.g. \"the longest-running protein-pancake ads on Meta\""
          },
          "brand": {
            "description": "brand name or profile object to tailor the research to; omit to use the workspace’s saved brand",
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "object",
                "properties": {},
                "additionalProperties": {}
              }
            ]
          }
        },
        "required": [
          "query"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "search_meta_ads",
      "title": "Search Meta ads",
      "description": "Structured Meta (Facebook/Instagram) Ad Library pull — use when you know exactly WHAT to fetch: a keyword (query) OR one advertiser (companyName / pageId). Returns compact JSON {page_name, body, cta, link, dates, media} per ad. For open-ended research that needs judgment across platforms, use research_ads instead. Spends ScrapeCreators credits (~1–2).",
      "group": "research",
      "section": "structured ad-spy (webapp Explore-chat parity: direct library/social pulls, no LLM loop)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "query": {
            "description": "keyword search across ALL advertisers (use INSTEAD of companyName/pageId)",
            "type": "string"
          },
          "companyName": {
            "description": "one advertiser’s ads by brand name",
            "type": "string"
          },
          "pageId": {
            "description": "one advertiser’s ads by Facebook page id (most precise)",
            "type": "string"
          },
          "country": {
            "description": "2-letter code or 'ALL' (default ALL)",
            "type": "string"
          },
          "status": {
            "description": "ACTIVE = currently running; default ALL (includes proven past winners)",
            "type": "string",
            "enum": [
              "ACTIVE",
              "INACTIVE",
              "ALL"
            ]
          },
          "mediaType": {
            "description": "filter by creative type (default ALL)",
            "type": "string",
            "enum": [
              "ALL",
              "IMAGE",
              "VIDEO",
              "MEME",
              "IMAGE_AND_MEME",
              "NONE"
            ]
          },
          "limit": {
            "description": "max ads returned (1–25, default 8)",
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "search_google_ads",
      "title": "Search Google ads",
      "description": "Structured Google Ads Transparency pull for ONE advertiser (by domain or advertiserId) — use when you know the brand; use research_ads for open-ended research. Deliberately fetches the cheap BASIC listing (get_ad_details=false, ~1 credit — the detailed variant with per-ad headlines costs 25 credits/call and is not exposed here). Returns compact JSON {advertiser, format, adUrl, image, firstShown, lastShown} per ad.",
      "group": "research",
      "section": "structured ad-spy (webapp Explore-chat parity: direct library/social pulls, no LLM loop)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "domain": {
            "description": "the advertiser's domain, e.g. nike.com",
            "type": "string"
          },
          "advertiserId": {
            "description": "Google advertiser id (AR…) when the domain is ambiguous",
            "type": "string"
          },
          "region": {
            "description": "2-letter region, default US",
            "type": "string"
          },
          "limit": {
            "description": "max ads returned (1–25, default 8)",
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "search_linkedin_ads",
      "title": "Search LinkedIn ads",
      "description": "Structured LinkedIn Ad Library search by company name, keyword, or companyId — use for a targeted B2B pull; use research_ads for open-ended research. Returns compact JSON {advertiser, headline, description, cta, link, media, dates, impressions} per ad — LinkedIn is the one library exposing real impression counts. Spends ScrapeCreators credits (~1).",
      "group": "research",
      "section": "structured ad-spy (webapp Explore-chat parity: direct library/social pulls, no LLM loop)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "company": {
            "description": "advertiser company name",
            "type": "string"
          },
          "keyword": {
            "description": "keyword across all advertisers",
            "type": "string"
          },
          "companyId": {
            "description": "LinkedIn company id (numeric) when the name is ambiguous",
            "type": "string"
          },
          "countries": {
            "description": "CSV of 2-letter codes like 'US,CA'; omit or 'ALL' = worldwide",
            "type": "string"
          },
          "limit": {
            "description": "max ads returned (1–25, default 8)",
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "search_tiktok",
      "title": "Search TikTok",
      "description": "Organic TikTok keyword search (there is NO TikTok ad library) — top-performing videos to mine for hooks/trends/remixable creative. Returns compact JSON {desc, author, handle, plays, likes, link, cover} per video, ranked by plays. Use research_ads for open-ended research. Spends ScrapeCreators credits (~1).",
      "group": "research",
      "section": "structured ad-spy (webapp Explore-chat parity: direct library/social pulls, no LLM loop)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "query": {
            "type": "string",
            "description": "keyword or hashtag (no # needed)"
          },
          "limit": {
            "description": "max videos returned (1–25, default 8)",
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          }
        },
        "required": [
          "query"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "search_instagram",
      "title": "Search Instagram",
      "description": "Organic Instagram REELS keyword search (/v2/instagram/reels/search — ScrapeCreators' only IG keyword surface; profile/hashtag pulls go through scrapecreators_fetch with a handle). Returns compact JSON {desc, author, handle, plays, likes, link, cover} per reel, ranked by plays. Spends ScrapeCreators credits (~1).",
      "group": "research",
      "section": "structured ad-spy (webapp Explore-chat parity: direct library/social pulls, no LLM loop)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "query": {
            "type": "string",
            "description": "keyword to search reels for"
          },
          "limit": {
            "description": "max reels returned (1–25, default 8)",
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          }
        },
        "required": [
          "query"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "search_youtube",
      "title": "Search YouTube",
      "description": "Organic YouTube keyword search (/v1/youtube/search) — videos to mine for hooks/angles/long-form structure. Returns compact JSON {desc (title), author, handle, plays, link, cover} per video, ranked by views. Spends ScrapeCreators credits (~1).",
      "group": "research",
      "section": "structured ad-spy (webapp Explore-chat parity: direct library/social pulls, no LLM loop)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "query": {
            "type": "string",
            "description": "keyword to search videos for"
          },
          "limit": {
            "description": "max videos returned (1–25, default 8)",
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          }
        },
        "required": [
          "query"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "search_reddit",
      "title": "Search Reddit",
      "description": "Reddit keyword search (/v1/reddit/search, top-ranked) — a goldmine for the customer's OWN words (pain points, objections, language) to mine into ad hooks and copy. Returns compact JSON {desc (title+selftext), subreddit, upvotes, comments, link} per post. Spends ScrapeCreators credits (~1).",
      "group": "research",
      "section": "structured ad-spy (webapp Explore-chat parity: direct library/social pulls, no LLM loop)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "query": {
            "type": "string",
            "description": "what to search Reddit for"
          },
          "limit": {
            "description": "max posts returned (1–25, default 8)",
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          }
        },
        "required": [
          "query"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "search_threads",
      "title": "Search Threads",
      "description": "Organic Threads keyword search (/v1/threads/search) — short-form text/social posts for trend + voice research. Returns compact JSON {desc, author, handle, likes, link, cover} per post. Spends ScrapeCreators credits (~1).",
      "group": "research",
      "section": "structured ad-spy (webapp Explore-chat parity: direct library/social pulls, no LLM loop)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "query": {
            "type": "string",
            "description": "keyword to search Threads for"
          },
          "limit": {
            "description": "max posts returned (1–25, default 8)",
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          }
        },
        "required": [
          "query"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "scrapecreators_fetch",
      "title": "Fetch ScrapeCreators endpoint",
      "description": "Generic ScrapeCreators escape hatch for any ALLOWLISTED long-tail endpoint the dedicated search_* tools don't cover — e.g. {path:'/v1/instagram/profile', params:{handle:'nike'}}. Allowlisted platform families: TikTok (+ TikTok Shop), Instagram, YouTube, Facebook (organic profiles/posts/events/marketplace), LinkedIn (organic posts/companies), Twitter/X, Reddit, Threads, Snapchat, Pinterest, Twitch, Bluesky, Truth Social, Rumble, Spotify, SoundCloud, GitHub, Google search, link-in-bio pages (Linktree etc.). Param names vary per endpoint (profiles use `handle`, keyword searches use `query`, Reddit uses `subreddit`). WARNING: returns RAW provider JSON — large and messy; prefer the dedicated search_* tools. Spends ScrapeCreators credits.",
      "group": "research",
      "section": "structured ad-spy (webapp Explore-chat parity: direct library/social pulls, no LLM loop)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "path": {
            "type": "string",
            "description": "exact SC endpoint path, e.g. '/v1/tiktok/profile' — non-allowlisted paths are rejected"
          },
          "params": {
            "description": "endpoint query params, e.g. {handle:'nike'}",
            "type": "object",
            "properties": {},
            "additionalProperties": {}
          }
        },
        "required": [
          "path"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "get_brand",
      "title": "Get saved brand",
      "description": "What Hermoso ALREADY KNOWS for this account/workspace — the same saved brand profile (products, logos, palette, positioning) + learned memory the web Studio uses. Call this FIRST: if hasBrand is true you can omit brand everywhere; if false, onboard with draft_brand. 0 credits.",
      "group": "workspace",
      "section": "brand onboarding",
      "annotations": {
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {},
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "draft_brand",
      "title": "Draft brand profile",
      "description": "Onboard a brand profile — from a website domain, a free-text description, or a social handle — into a {name, products, logo, …} object you can pass to plan_ad / generate. 0 ScrapeCreators credits. IMPORTANT: a domain can resolve to a DIFFERENT company than intended (e.g. bala.com is an engineering firm, not the Bala fitness brand at shopbala.com). Before spending any credits on research or renders, VERIFY the returned `name` (and `summary`) match the brand the user meant; if it looks wrong, re-draft with the correct domain or a description (pass save:false until confirmed) — this tool cannot ask the user, so the caller owns that check.",
      "group": "workspace",
      "section": "brand onboarding",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "domain": {
            "description": "a website to scrape",
            "type": "string"
          },
          "description": {
            "description": "a free-text brand description (no website)",
            "type": "string"
          },
          "socialHandle": {
            "description": "a social handle to draft from (influencers/creators) — pair with platform",
            "type": "string"
          },
          "platform": {
            "description": "platform for socialHandle (instagram/tiktok/…)",
            "type": "string"
          },
          "save": {
            "description": "save as the workspace’s brand (like Studio onboarding) so plan_ad/create use it automatically. Default: saves only when NO brand is saved yet; pass true to overwrite, false to never save",
            "type": "boolean"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_library",
      "title": "List library",
      "description": "Browse this workspace's Library — every image/video generated in the Studio, newest first (the same Library the web app shows). Returns served URLs you can open directly or hand to fetch_asset for a download link, plus each asset's kind, model, and age. Free, read-only.",
      "group": "create",
      "section": "assets",
      "annotations": {
        "readOnlyHint": true,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "kind": {
            "description": "filter by asset kind (default 'all')",
            "type": "string",
            "enum": [
              "image",
              "video",
              "all"
            ]
          },
          "limit": {
            "description": "max assets to return (default 20, max 60)",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "fetch_asset",
      "title": "Fetch asset",
      "description": "Resolve a generated asset reference (a /generated/… path or any URL) to a clickable absolute URL + a direct download URL.",
      "group": "create",
      "section": "assets",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "description": "the asset url or /generated/ path"
          },
          "name": {
            "description": "optional filename for the download",
            "type": "string"
          }
        },
        "required": [
          "url"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "analyze_video",
      "title": "Analyze video",
      "description": "Break a video ad down into its structure: the verbatim transcript (voiceover + on-screen text) with a beat list, plus duration and sampled frame timestamps. Use to study a reference/competitor ad before remixing its structure. Costs ~a transcription call; no ScrapeCreators credits.",
      "group": "create",
      "section": "post-production & analysis (Higgsfield-parity wave: each wraps an EXISTING worker/route)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "description": "the video URL (a served /generated/ path or a public http(s) video)"
          }
        },
        "required": [
          "url"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "score_ad",
      "title": "Score ad",
      "description": "Virality/performance prediction for a finished ad (image or video URL): overall score, per-dimension breakdown (scroll-stop, hook, clarity, brand/product, CTA, retention, goal fit), strengths, and the single biggest fix. Use BEFORE spending on distribution, or to rank variants.",
      "group": "create",
      "section": "post-production & analysis (Higgsfield-parity wave: each wraps an EXISTING worker/route)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "description": "the ad asset URL (a /generated/ path or public URL)"
          },
          "kind": {
            "description": "'image' (default) or 'video'",
            "type": "string",
            "enum": [
              "image",
              "video"
            ]
          },
          "intent": {
            "description": "what the ad is trying to achieve, for goal-fit scoring",
            "type": "string"
          }
        },
        "required": [
          "url"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "reframe_video",
      "title": "Reframe video",
      "description": "Reframe a video to a different aspect ratio (e.g. 16:9 master → 9:16 vertical) with smart subject tracking. Paid render; returns the served URL of the reframed video.",
      "group": "create",
      "section": "post-production & analysis (Higgsfield-parity wave: each wraps an EXISTING worker/route)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "video": {
            "type": "string",
            "description": "the source video URL"
          },
          "aspectRatio": {
            "type": "string",
            "enum": [
              "9:16",
              "1:1",
              "16:9",
              "4:3",
              "3:4",
              "21:9",
              "9:21"
            ],
            "description": "the target aspect ratio"
          }
        },
        "required": [
          "video",
          "aspectRatio"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "upscale_video",
      "title": "Upscale video",
      "description": "Upscale a video to higher resolution (2x) for final delivery. Paid render; returns the served URL.",
      "group": "create",
      "section": "post-production & analysis (Higgsfield-parity wave: each wraps an EXISTING worker/route)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "video": {
            "type": "string",
            "description": "the source video URL"
          }
        },
        "required": [
          "video"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "edit_video",
      "title": "Edit a video clip",
      "description": "EDIT/transform an existing video clip with a natural-language instruction (video-to-video) — KEEPS the original motion, timing and edit, changes the subject/setting/style. Use for 'change the background to a city', 'make it nighttime', 'restyle it as claymation', 'swap the product'. Best on 3–10s clips. NOT for mechanical cuts, trims, end cards or watermarks (use post_edit — seconds, ~2 credits, no AI model), NOT for making a new video (generate_video / render_ad), NOT for translating the spoken track (dub_video) and NOT for putting a saved creator's face on the motion (recast_motion). Paid render; returns the served URL of the edited clip.",
      "group": "create",
      "section": "post-production & analysis (Higgsfield-parity wave: each wraps an EXISTING worker/route)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "video": {
            "type": "string",
            "description": "the source video URL (from a previous render, a job result, or list_library)"
          },
          "instruction": {
            "type": "string",
            "description": "the exact transformation to apply, in the user’s own words"
          },
          "keepAudio": {
            "description": "default true — keep the source clip’s audio track. Set false to return the edit silent",
            "type": "boolean"
          },
          "elements": {
            "description": "OPTIONAL identity/product grounding (≤4): a creator portrait or the real product photo, so the edit restores the REAL thing instead of re-inventing it. Describe each one in the instruction. Leave out for a plain restyle",
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "frontal": {
                  "type": "string",
                  "description": "the reference image URL"
                },
                "refs": {
                  "description": "up to 2 extra angles of the SAME subject",
                  "type": "array",
                  "items": {
                    "type": "string"
                  }
                }
              },
              "required": [
                "frontal"
              ],
              "additionalProperties": {}
            }
          }
        },
        "required": [
          "video",
          "instruction"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "dub_video",
      "title": "Dub video",
      "description": "Localize a finished video into another language WITHOUT re-rendering it: the spoken track is transcribed, translated, re-voiced and lip-synced back onto the SAME footage, so the visuals, timing and edit are untouched. Just pass the video and the language — the script is read off the source automatically (pass `script` only to override what it heard). Paid; returns the served URL of the localized video.",
      "group": "create",
      "section": "post-production & analysis (Higgsfield-parity wave: each wraps an EXISTING worker/route)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "video": {
            "type": "string",
            "description": "the source video URL"
          },
          "language": {
            "type": "string",
            "description": "target language, e.g. 'Spanish', 'de', 'French (Canada)'"
          },
          "script": {
            "description": "OPTIONAL override for the original spoken words. Leave this out — the source video is transcribed automatically. Only pass it when you already know the exact script and the auto-transcript got it wrong.",
            "type": "string"
          },
          "voice": {
            "description": "optional target voice preset, e.g. 'Aria' (warm female) or 'George' (confident male). Defaults to a voice matching the source speaker's register.",
            "type": "string"
          }
        },
        "required": [
          "video",
          "language"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "change_voice",
      "title": "Change narrator voice",
      "description": "Swap the narration of a finished video into a different voice — keeps the performance, lip-sync, and background sound. Use when the user likes the video but wants a different narrator voice; use dub_video only for language translation. Paid; returns the served URL.",
      "group": "create",
      "section": "post-production & analysis (Higgsfield-parity wave: each wraps an EXISTING worker/route)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "video": {
            "type": "string",
            "description": "the source video URL"
          },
          "voice": {
            "description": "target narrator voice preset name, e.g. 'Aria', 'George', 'Rachel', 'Sarah', 'Brian', 'Charlotte' (defaults to a warm female read)",
            "type": "string"
          }
        },
        "required": [
          "video"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "recast_motion",
      "title": "Recast motion",
      "description": "Motion transfer: re-perform a reference video's motion with a different person/character (supply their image). The reference clip drives the movement; the image supplies the identity. Paid render.",
      "group": "create",
      "section": "post-production & analysis (Higgsfield-parity wave: each wraps an EXISTING worker/route)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "image": {
            "type": "string",
            "description": "the actor/character image URL (who should appear)"
          },
          "video": {
            "type": "string",
            "description": "the reference video whose motion to re-perform"
          },
          "prompt": {
            "description": "optional scene/style guidance",
            "type": "string"
          },
          "orientation": {
            "description": "which aspect to keep: the video's (default) or the image's",
            "type": "string",
            "enum": [
              "video",
              "image"
            ]
          }
        },
        "required": [
          "image",
          "video"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "plan_variations",
      "title": "Plan ad variations",
      "description": "Fan a brief into N DISTINCT ad angles (different hooks/mechanics/audiences), each with its own headline + visual brief — then render each with generate_image and rank with score_ad. LLM planning only; renders nothing itself.",
      "group": "create",
      "section": "post-production & analysis (Higgsfield-parity wave: each wraps an EXISTING worker/route)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "brand": {
            "description": "brand name or profile object; OMIT to use the workspace’s saved brand",
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "object",
                "properties": {},
                "additionalProperties": {}
              }
            ]
          },
          "product": {
            "type": "string",
            "description": "what to advertise"
          },
          "count": {
            "description": "how many distinct variants (default 6)",
            "type": "integer",
            "minimum": 2,
            "maximum": 8
          },
          "language": {
            "description": "output language for the variant copy (e.g. Spanish) — default English",
            "type": "string"
          }
        },
        "required": [
          "product"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "competitor_teardown",
      "title": "Competitor teardown",
      "description": "Tear a competitor's ad strategy down into an actionable playbook: their opening-hook MIX, longest-running campaign THEMES, the WHITE SPACE nobody in their set runs, 2-3 render-ready COUNTER-PLAYS, and the territories they own that you should avoid. Pass `competitor` {name, domain?}. CONTRACT: supply `ads` (raw ad objects from a prior pull_competitor_ads / search_meta_ads call) to tear exactly those down, OR omit `ads` and this pulls the competitor's real Meta ads first (spends ~1-2 ScrapeCreators credits, longest-running = proven winners). Auto-tailors the white space + counter-plays to YOUR saved brand. Spends LLM tokens (0 SC credits when you pass ads).",
      "group": "research",
      "section": "research analysis & creative remix (webapp Create-chat parity — the last four app-only chat tools, now headless)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "competitor": {
            "type": "object",
            "properties": {
              "name": {
                "type": "string",
                "description": "the competitor brand name"
              },
              "domain": {
                "description": "their domain — sharpens the auto-pull page match",
                "type": "string"
              }
            },
            "required": [
              "name"
            ],
            "description": "the competitor to tear down"
          },
          "ads": {
            "description": "ad objects to tear down (from pull_competitor_ads / search_meta_ads). Omit to auto-pull their Meta ads first.",
            "type": "array",
            "items": {
              "type": "object",
              "properties": {},
              "additionalProperties": {}
            }
          },
          "language": {
            "description": "output language (default English)",
            "type": "string"
          }
        },
        "required": [
          "competitor"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "check_ad_policy",
      "title": "Check ad policy",
      "description": "Pre-flight ad copy against Meta's REAL, live Advertising Standards before you run it — a flat 1-credit check. Pulls Meta's actual policy pages and returns a verdict (pass / fix / block) where every flagged issue QUOTES Meta's own policy text verbatim plus a compliant rewrite that keeps the sell. It's a check, not an edit — it never changes the creative. Especially worth running for regulated-adjacent categories (health/supplements, weight-loss or beauty results claims, finance/crypto/insurance, alcohol, dating, gambling) or ANY strong/absolute/guaranteed claim.",
      "group": "research",
      "section": "research analysis & creative remix (webapp Create-chat parity — the last four app-only chat tools, now headless)",
      "annotations": {
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "copy": {
            "type": "string",
            "description": "the ad copy / script / on-screen text to check"
          },
          "claims": {
            "description": "the claims / proof points the ad makes",
            "type": "string"
          },
          "category": {
            "description": "the product category — helps pick the relevant policy pages",
            "type": "string"
          },
          "imageDescription": {
            "description": "a description of the creative / image when relevant",
            "type": "string"
          }
        },
        "required": [
          "copy"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "remix_static",
      "title": "Remix a static ad",
      "description": "One-click STATIC-AD REMIX: rebuild a competitor/reference STATIC (image) ad as an on-brand version — SAME layout, composition and energy, but YOUR product, brand colours, logo and voice, with every trace of the source brand removed. Pass `imageUrl` = the static ad image to remix. Uses your saved brand (pass brandId to target a specific brand — that switches this key's active brand like use_brand). IMAGES ONLY — for video ads use render_ad. Bills as one image generation.",
      "group": "research",
      "section": "research analysis & creative remix (webapp Create-chat parity — the last four app-only chat tools, now headless)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "imageUrl": {
            "type": "string",
            "description": "the URL of the static ad image to remix"
          },
          "brandId": {
            "description": "a brand id/name from list_brands to remix for; omit to use the active brand",
            "type": "string"
          }
        },
        "required": [
          "imageUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "mine_angles",
      "title": "Mine customer angles",
      "description": "Mine ad ANGLES from real customer language: gathers the customer's own words (Reddit, TikTok, the brand's review page + review-site results) and returns a RANKED angle bank — each angle tagged (pain / outcome / identity / fear / competitive-displacement / social-proof / contrast), 2-5 VERBATIM proof quotes, a 0-100 score with breakdown, and a ready-to-run hook in the customer's own voice. Reads YOUR saved brand (pass brandId to target a specific brand — that switches this key's active brand like use_brand). To tear down a COMPETITOR use competitor_teardown instead. Spends a few ScrapeCreators credits + LLM tokens.",
      "group": "research",
      "section": "research analysis & creative remix (webapp Create-chat parity — the last four app-only chat tools, now headless)",
      "annotations": {
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "brandId": {
            "description": "a brand id/name from list_brands to mine for; omit to use the active brand",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_product_photos",
      "title": "List product photos",
      "description": "List the product photos ALREADY saved in your workspace — the brand's product library plus any app-store screens (also surfaces photos locked in your OTHER creations, since a set product lands in the shared library). FREE — returns each photo's url + label. Call it before set_product_image to see the existing photos you can reuse. Reads YOUR saved brand (pass brandId to target a specific brand — that switches this key's active brand like use_brand).",
      "group": "create",
      "section": "product-photo tools (Studio-chat parity)",
      "annotations": {
        "readOnlyHint": true,
        "destructiveHint": false,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "brandId": {
            "description": "a brand id/name from list_brands whose product library to list; omit to use the active brand",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "set_product_image",
      "title": "Set product photo",
      "description": "Lock an image as the ad's real PRODUCT photo so every render grounds on the true packaging. Pass `imageUrl` = a product shot's URL — an image from a prior research result (an organic Instagram/TikTok post, a scraped page image), a workspace / list_product_photos url, or any public product photo. The server downloads it and runs a product+safety check: a lifestyle/scene shot with no clear product, or an off-category / unsafe image, is REJECTED and NOTHING is locked (the summary says why). On PASS it persists the photo to a DURABLE url and returns it — pass that url as a reference to generate_image / render_ad. Bills one vision check. Reads YOUR saved brand for the category match (pass brandId to target a specific brand — switches this key's active brand like use_brand).",
      "group": "create",
      "section": "product-photo tools (Studio-chat parity)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "imageUrl": {
            "type": "string",
            "description": "the image URL to lock as the product (from a research result, a workspace / list_product_photos url, or any public product photo)"
          },
          "source_note": {
            "description": "a short note on where it came from, e.g. \"from their IG post\"",
            "type": "string"
          },
          "brandId": {
            "description": "a brand id/name from list_brands to lock the product for; omit to use the active brand",
            "type": "string"
          }
        },
        "required": [
          "imageUrl"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "fetch_app_screens",
      "title": "Fetch App Store screens",
      "description": "Pull an APP brand's REAL App Store screenshots into the workspace brand, so a screen-hungry native format can use them. Use when the brand has 0–1 app screens on file and you want make_template_ad(template:'app-ui-tour'), or the user asks to 'pull my app's screenshots'. Pass appName (defaults to the saved brand's name). FREE — a keyless App Store lookup. It needs a CONFIDENT match: an ambiguous or unknown app returns 0 screens and saves nothing, which you should relay plainly rather than retrying with guesses. On success the screens are saved to the brand (durable URLs) and are immediately usable.",
      "group": "create",
      "section": "product-photo tools (Studio-chat parity)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "appName": {
            "description": "the app's name to look up on the App Store — defaults to the saved brand's name",
            "type": "string"
          },
          "brandId": {
            "description": "a brand id/name from list_brands to save the screens onto; omit to use the active brand",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_meta_posts",
      "title": "List the Page’s / Instagram account’s own posts",
      "description": "List the connected Facebook Page's or Instagram account's OWN existing posts — id, caption, permalink, publish date and format. THIS IS THE TOOL THAT GETS YOU THE postId every other Meta read needs: meta_post_insights, list_meta_comments and manage_meta_post all require one, and until now the only way to have a postId was to have just published it yourself with post_to_meta. Use it for \"how did our last few posts do\", to find a post the user describes loosely, or before backfill_posts. Pass target:'instagram' for the linked IG account (Stories are excluded — Meta's media edge does not return them); Facebook hides unpublished drafts unless you ask for them. Only ever reads a Page the brand has connected. Read-only, 0 credits.",
      "group": "publish",
      "section": "POST PERFORMANCE (2026-08-04)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "target": {
            "description": "default facebook; 'instagram' reads the Page's linked IG business account",
            "type": "string",
            "enum": [
              "facebook",
              "instagram"
            ]
          },
          "pageId": {
            "description": "which connected Page — omit when the brand has only one",
            "type": "string"
          },
          "limit": {
            "description": "how many posts (default 25, max 100)",
            "type": "number"
          },
          "cursor": {
            "description": "paging cursor returned by a previous call",
            "type": "string"
          },
          "includeUnpublished": {
            "description": "Facebook only — also return unpublished drafts (hidden by default)",
            "type": "boolean"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_published_posts",
      "title": "List what this brand has published",
      "description": "List every post Hermoso has recorded publishing for this brand — channel, permalink, caption, format, the HOOK and SUBJECT it was written to, and its measured engagement. This is the brand's own publishing history across all nine channels in one place, and it is the memory that makes 'which hook worked?' answerable at all. Each row says how it was recorded: 'captured' (written at publish time — the hook is what the author actually intended) or 'backfilled' (reconstructed from the platform afterwards, where the hook is only known if the post matched a Hermoso creation). A dash for engagement means the platform reported no number — that is NOT zero engagement. Read-only, 0 credits.",
      "group": "publish",
      "section": "POST PERFORMANCE (2026-08-04)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "channel": {
            "description": "filter to one channel: facebook, instagram, threads, x, linkedin, youtube, tiktok, reddit, pinterest, google_business",
            "type": "string"
          },
          "limit": {
            "description": "max posts (default 50, max 200), newest first",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "list_hooks",
      "title": "The hook + setting libraries, and which hooks are working",
      "description": "The curated menu of VISUAL scroll-stop HOOKS (how an ad opens) and SETTINGS (where it is staged) that plan_ad and render_ad accept, PLUS this brand's measured traction per hook. Call it before planning an ad to pick a hook deliberately instead of letting the model improvise one, and call it after publishing to see which ones are actually landing. Three things it will not do: it never recommends a hook from thin data — a verdict is SUPPRESSED below 5 measured posts and the reason is stated; it never compares across channels; and it reports hooks you have NEVER TRIED as a fact, not as advice, because 'you haven't tried this' is an observation and 'you should' would be a verdict drawn from zero data. A hook marked unusable in this brief says WHY (an on-screen-text hook cannot ride an authentic/UGC render, which carries zero on-screen text). Read-only, 0 credits.",
      "group": "publish",
      "section": "POST PERFORMANCE (2026-08-04)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "channel": {
            "description": "restrict the performance half to one channel (facebook, instagram, threads, x, linkedin, youtube, tiktok, reddit, pinterest)",
            "type": "string"
          },
          "authentic": {
            "description": "true if the planned ad is an authentic/UGC/creator-register render — on-screen-text hooks are then reported unusable, with the reason",
            "type": "boolean"
          },
          "category": {
            "description": "the product category (e.g. 'skincare serum', 'protein powder', 'sunglasses') — returns the setting Higgsfield's Location x Tier matrix puts that category in, with the reason",
            "type": "string"
          },
          "tier": {
            "description": "product tier, used with category — changes the FINISH of the room, never the room. Default premium.",
            "type": "string",
            "enum": [
              "luxury",
              "premium",
              "drugstore"
            ]
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "post_performance",
      "title": "Which hooks and subjects are getting traction",
      "description": "Aggregate this brand's published posts to answer WHICH HOOKS AND SUBJECTS WORK. Groups by hook (default), subject, channel, media format or posting hour, and reports the engagement RATE within each channel. THREE THINGS IT DELIBERATELY WILL NOT DO, and you should repeat them rather than paper over them: (1) it never sums metrics across channels — a LinkedIn impression and a TikTok view are different units, so every comparison is within one channel; (2) it SUPPRESSES a verdict below 5 measured posts and says so, because a confident recommendation from 3 posts is worse than none; (3) a post with no recorded hook (published outside Hermoso, or backfilled without a creation match) counts toward channel and format totals but never votes on which hook works. Present the `finding` verbatim if there is one, and the reason if there is not. Read-only, 0 credits.",
      "group": "publish",
      "section": "POST PERFORMANCE (2026-08-04)",
      "annotations": {
        "readOnlyHint": true,
        "openWorldHint": false
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "axis": {
            "description": "what to group by — default hook",
            "type": "string",
            "enum": [
              "hook",
              "subject",
              "channel",
              "media",
              "hour"
            ]
          },
          "channel": {
            "description": "restrict to one channel",
            "type": "string"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "collect_post_metrics",
      "title": "Read how the recorded posts performed",
      "description": "Fetch fresh performance numbers for this brand's recorded posts and store them as a time-series. Metrics ACCRUE, so a post is read at ~24 hours and again at ~7 days; this collects whichever readings are due and skips the ones already taken. A channel that cannot report a metric records it as ABSENT with the reason — never as zero — and a read that fails is recorded as 'could not tell', which contributes to nothing. X IS SKIPPED BY DEFAULT because X bills us per API call: pass includeMetered:true to include it, and tell the user it costs credits BEFORE you do. The skip is always reported so a channel missing from the numbers is never mistaken for one that performed badly. Free except for X.",
      "group": "publish",
      "section": "POST PERFORMANCE (2026-08-04)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "includeMetered": {
            "description": "also read X, which BILLS CREDITS per post read — ask the user first",
            "type": "boolean"
          },
          "max": {
            "description": "cap how many posts to read in this run (default 40)",
            "type": "number"
          }
        },
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    },
    {
      "name": "backfill_posts",
      "title": "Import a channel’s past posts",
      "description": "Import this brand's PAST posts from a channel into the performance record, so 'which hook works' can draw on history rather than only on what was published since Hermoso started recording. Supports facebook, instagram, threads, youtube, tiktok and pinterest; the others say plainly why they cannot (LinkedIn and Reddit have no enumerate-my-posts endpoint on our grant, and X bills per read so it is excluded from bulk import). BOUNDED, RESUMABLE AND QUOTED: it runs as a DRY RUN by default and tells you how many posts it found and what reading them will cost — pass confirm:true to import, and pass the returned cursor to continue. AN IMPORTED POST IS WEAKER EVIDENCE THAN A RECORDED ONE and is labelled 'backfilled': its hook is recovered ONLY where the post matches a Hermoso creation by asset or caption. A post made outside Hermoso stays UNATTRIBUTED — it counts toward channel and format totals but never votes on which hook works. Never guess a hook from a caption. Free.",
      "group": "publish",
      "section": "POST PERFORMANCE (2026-08-04)",
      "annotations": {
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      },
      "inputSchema": {
        "type": "object",
        "properties": {
          "channel": {
            "type": "string",
            "enum": [
              "facebook",
              "instagram",
              "threads",
              "youtube",
              "tiktok",
              "pinterest"
            ],
            "description": "which channel to import from"
          },
          "confirm": {
            "description": "actually import — omit for a dry run that only quotes the cost",
            "type": "boolean"
          },
          "limit": {
            "description": "how many posts this page (default 50, max 200)",
            "type": "number"
          },
          "cursor": {
            "description": "resume from a previous run",
            "type": "string"
          },
          "accountRef": {
            "description": "which Page / account, when the brand has more than one",
            "type": "string"
          }
        },
        "required": [
          "channel"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      }
    }
  ]
}
